diff --git a/.ci/Dockerfile b/.ci/Dockerfile index 065b7f2aafd61..d6eee046611bb 100644 --- a/.ci/Dockerfile +++ b/.ci/Dockerfile @@ -1,7 +1,7 @@ # NOTE: This Dockerfile is ONLY used to run certain tasks in CI. It is not used to run Kibana or as a distributable. # If you're looking for the Kibana Docker image distributable, please see: src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts -ARG NODE_VERSION=10.22.0 +ARG NODE_VERSION=10.22.1 FROM node:${NODE_VERSION} AS base diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 9a49c19b94df2..791cacf7abb4c 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -11,14 +11,14 @@ kibanaPipeline(timeoutMinutes: 120) { 'CI_PARALLEL_PROCESS_NUMBER=1' ]) { parallel([ - 'oss-visualRegression': { - workers.ci(name: 'oss-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('oss-visualRegression', './test/scripts/jenkins_visual_regression.sh')() + 'oss-baseline': { + workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, - 'xpack-visualRegression': { - workers.ci(name: 'xpack-visualRegression', size: 's-highmem', ramDisk: true) { - kibanaPipeline.functionalTestProcess('xpack-visualRegression', './test/scripts/jenkins_xpack_visual_regression.sh')() + 'xpack-baseline': { + workers.ci(name: 'xpack-baseline', size: 'l', ramDisk: true, runErrorReporter: false) { + kibanaPipeline.functionalTestProcess('xpack-baseline', './test/scripts/jenkins_xpack_baseline.sh')() } }, ]) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d81f6af4cec28..0bdddddab8de5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,6 +7,7 @@ /x-pack/plugins/discover_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app +/src/plugins/charts/ @elastic/kibana-app /src/plugins/dashboard/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app /src/plugins/input_control_vis/ @elastic/kibana-app @@ -59,7 +60,6 @@ # APM /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui -/src/legacy/core_plugins/apm_oss/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @watson @vigneshshanmugam @@ -83,9 +83,6 @@ /src/plugins/home/public @elastic/kibana-core-ui /src/plugins/home/server/*.ts @elastic/kibana-core-ui /src/plugins/home/server/services/ @elastic/kibana-core-ui -# Exclude tutorial resources folder for now because they are not owned by Kibana app and most will move out soon -/src/legacy/core_plugins/kibana/public/home/*.ts @elastic/kibana-core-ui -/src/legacy/core_plugins/kibana/public/home/np_ready/ @elastic/kibana-core-ui /x-pack/plugins/global_search_bar/ @elastic/kibana-core-ui # Observability UIs @@ -167,8 +164,6 @@ # Security /src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform -/x-pack/legacy/plugins/security/ @elastic/kibana-security -/x-pack/legacy/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security @@ -286,9 +281,6 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Core design /src/plugins/dashboard/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/canvas/**/*.scss @elastic/kibana-core-ui-designers -/src/legacy/core_plugins/kibana/public/home/**/*.scss @elastic/kibana-core-ui-designers -/x-pack/legacy/plugins/security/**/*.scss @elastic/kibana-core-ui-designers -/x-pack/legacy/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/spaces/**/*.scss @elastic/kibana-core-ui-designers /x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers @@ -297,7 +289,7 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib /x-pack/plugins/infra/**/*.scss @elastic/observability-design /x-pack/plugins/ingest_manager/**/*.scss @elastic/observability-design /x-pack/plugins/observability/**/*.scss @elastic/observability-design -/x-pack/plugins/monitoring/**/*.scss @elastic/observability-design +/x-pack/plugins/monitoring/**/*.scss @elastic/observability-design # Ent. Search design /x-pack/plugins/enterprise_search/**/*.scss @elastic/ent-search-design diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index 039b520561d65..2e8529b4a7704 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -16,3 +16,11 @@ - "x-pack/test/epm_api_integration/**/*.*" - "Team:uptime": - "x-pack/plugins/uptime/**/*.*" + - "x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/*.*" + - "x-pack/plugins/apm/public/application/csmApp.tsx" + - "x-pack/plugins/apm/public/components/app/RumDashboard/**/*.*" + - "x-pack/plugins/apm/public/components/app/RumDashboard/*.*" + - "x-pack/plugins/apm/server/lib/rum_client/**/*.*" + - "x-pack/plugins/apm/server/lib/rum_client/*.*" + - "x-pack/plugins/apm/server/routes/rum_client.ts" + - "x-pack/plugins/apm/server/projections/rum_overview.ts" diff --git a/.i18nrc.json b/.i18nrc.json index e8431fdb3f0e1..153a5a6cafece 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -57,7 +57,8 @@ "visTypeXy": "src/plugins/vis_type_xy", "visualizations": "src/plugins/visualizations", "visualize": "src/plugins/visualize", - "apmOss": "src/plugins/apm_oss" + "apmOss": "src/plugins/apm_oss", + "usageCollection": "src/plugins/usage_collection" }, "exclude": [ "src/legacy/ui/ui_render/ui_render_mixin.js" diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index db2f85c54c762..d629a95073a74 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -2,11 +2,6 @@ [[service-maps]] === Service maps -beta::[] - -WARNING: Service map support for Internet Explorer 11 is extremely limited. -Please use Chrome or Firefox if available. - A service map is a real-time visual representation of the instrumented services in your application's architecture. It shows you how these services are connected, along with high-level metrics like average transaction duration, requests per minute, and errors per minute. diff --git a/docs/apm/troubleshooting.asciidoc b/docs/apm/troubleshooting.asciidoc index e00a67f6c78a4..b4c9c6a4ec39e 100644 --- a/docs/apm/troubleshooting.asciidoc +++ b/docs/apm/troubleshooting.asciidoc @@ -49,7 +49,7 @@ GET /_template/apm-{version} *Using Logstash, Kafka, etc.* If you're not outputting data directly from APM Server to Elasticsearch (perhaps you're using Logstash or Kafka), then the index template will not be set up automatically. Instead, you'll need to -{apm-server-ref}/configuration-template.html[load the template manually]. +{apm-server-ref}/apm-server-template.html[load the template manually]. *Using a custom index names* This problem can also occur if you've customized the index name that you write APM data to. diff --git a/docs/developer/architecture/security/feature-registration.asciidoc b/docs/developer/architecture/security/feature-registration.asciidoc index 3ff83e9db8c43..b27e457940d93 100644 --- a/docs/developer/architecture/security/feature-registration.asciidoc +++ b/docs/developer/architecture/security/feature-registration.asciidoc @@ -38,6 +38,12 @@ Registering a feature consists of the following fields. For more information, co |`"Sample Feature"` |A human readable name for your feature. +|`category` (required) +|{kib-repo}blob/{branch}/src/core/types/app_category.ts[`AppCategory`] +|`DEFAULT_APP_CATEGORIES.kibana` +|The `AppCategory` which best represents your feature. Used to organize the display +of features within the management screens. + |`app` (required) |`string[]` |`["sample_app", "kibana"]` @@ -96,6 +102,7 @@ public setup(core, { features }) { name: 'Canvas', icon: 'canvasApp', navLinkId: 'canvas', + category: DEFAULT_APP_CATEGORIES.kibana, app: ['canvas', 'kibana'], catalogue: ['canvas'], privileges: { @@ -155,6 +162,7 @@ public setup(core, { features }) { }), icon: 'devToolsApp', navLinkId: 'dev_tools', + category: DEFAULT_APP_CATEGORIES.management, app: ['kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], privileges: { @@ -217,6 +225,7 @@ public setup(core, { features }) { order: 100, icon: 'discoverApp', navLinkId: 'discover', + category: DEFAULT_APP_CATEGORIES.kibana, app: ['kibana'], catalogue: ['discover'], privileges: { diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 7727cd322181f..b426621fed296 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -181,6 +181,12 @@ which also contains the timelion APIs and backend, look at the vis_type_timelion To integrate with the telemetry services for usage collection of your feature, there are 2 steps: +|{kib-repo}blob/{branch}/src/plugins/vis_default_editor/README.md[visDefaultEditor] +|The default editor is used in most primary visualizations, e.x. Area, Data table, Pie, etc. +It acts as a container for a particular visualization and options tabs. Contains the default "Data" tab in public/components/sidebar/data_tab.tsx. +The plugin exposes the static DefaultEditorController class to consume. + + |{kib-repo}blob/{branch}/src/plugins/vis_type_markdown/README.md[visTypeMarkdown] |The markdown visualization that can be used to place text panels on dashboards. @@ -494,6 +500,10 @@ in their infrastructure. |This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): +|{kib-repo}blob/{branch}/x-pack/plugins/xpack_legacy/README.md[xpackLegacy] +|Contains HTTP endpoints and UiSettings that are slated for removal. + + |=== include::{kibana-root}/src/plugins/dashboard/README.asciidoc[leveloffset=+1] diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 903462ac3039d..470a41f30afbf 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsfindoptions.type.md) | string | string[] | | +| [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md new file mode 100644 index 0000000000000..4af8c9ddeaff4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) + +## SavedObjectsFindOptions.typeToNamespacesMap property + +This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + +Signature: + +```typescript +typeToNamespacesMap?: Map; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.arialabel.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.arialabel.md new file mode 100644 index 0000000000000..fe81f7cffaa41 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.arialabel.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) > [ariaLabel](./kibana-plugin-core-server.appcategory.arialabel.md) + +## AppCategory.ariaLabel property + +If the visual label isn't appropriate for screen readers, can override it here + +Signature: + +```typescript +ariaLabel?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.euiicontype.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.euiicontype.md new file mode 100644 index 0000000000000..79de37ea619f3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.euiicontype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) > [euiIconType](./kibana-plugin-core-server.appcategory.euiicontype.md) + +## AppCategory.euiIconType property + +Define an icon to be used for the category If the category is only 1 item, and no icon is defined, will default to the product icon Defaults to initials if no icon is defined + +Signature: + +```typescript +euiIconType?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.id.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.id.md new file mode 100644 index 0000000000000..f0889d200725a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) > [id](./kibana-plugin-core-server.appcategory.id.md) + +## AppCategory.id property + +Unique identifier for the categories + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.label.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.label.md new file mode 100644 index 0000000000000..9405118ed7a11 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.label.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) > [label](./kibana-plugin-core-server.appcategory.label.md) + +## AppCategory.label property + +Label used for category name. Also used as aria-label if one isn't set. + +Signature: + +```typescript +label: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.md new file mode 100644 index 0000000000000..a761bf4e5b393 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) + +## AppCategory interface + +A category definition for nav links to know where to sort them in the left hand nav + +Signature: + +```typescript +export interface AppCategory +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [ariaLabel](./kibana-plugin-core-server.appcategory.arialabel.md) | string | If the visual label isn't appropriate for screen readers, can override it here | +| [euiIconType](./kibana-plugin-core-server.appcategory.euiicontype.md) | string | Define an icon to be used for the category If the category is only 1 item, and no icon is defined, will default to the product icon Defaults to initials if no icon is defined | +| [id](./kibana-plugin-core-server.appcategory.id.md) | string | Unique identifier for the categories | +| [label](./kibana-plugin-core-server.appcategory.label.md) | string | Label used for category name. Also used as aria-label if one isn't set. | +| [order](./kibana-plugin-core-server.appcategory.order.md) | number | The order that categories will be sorted in Prefer large steps between categories to allow for further editing (Default categories are in steps of 1000) | + diff --git a/docs/development/core/server/kibana-plugin-core-server.appcategory.order.md b/docs/development/core/server/kibana-plugin-core-server.appcategory.order.md new file mode 100644 index 0000000000000..aba1b886076ad --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.appcategory.order.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [AppCategory](./kibana-plugin-core-server.appcategory.md) > [order](./kibana-plugin-core-server.appcategory.order.md) + +## AppCategory.order property + +The order that categories will be sorted in Prefer large steps between categories to allow for further editing (Default categories are in steps of 1000) + +Signature: + +```typescript +order?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index ccc73d4fb858e..75da8df2ae15a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -23,6 +23,7 @@ export interface CoreSetupStartServicesAccessor<TPluginsStart, TStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup & {
resources: HttpResources;
} | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [logging](./kibana-plugin-core-server.coresetup.logging.md) | LoggingServiceSetup | [LoggingServiceSetup](./kibana-plugin-core-server.loggingservicesetup.md) | +| [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [status](./kibana-plugin-core-server.coresetup.status.md) | StatusServiceSetup | [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.metrics.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.metrics.md new file mode 100644 index 0000000000000..77c9e867ef8ea --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.metrics.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [metrics](./kibana-plugin-core-server.coresetup.metrics.md) + +## CoreSetup.metrics property + +[MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) + +Signature: + +```typescript +metrics: MetricsServiceSetup; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.md b/docs/development/core/server/kibana-plugin-core-server.corestart.md index 610c85c71e362..0d5474fae5e16 100644 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.md @@ -20,7 +20,7 @@ export interface CoreStart | [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | CapabilitiesStart | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) | | [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | ElasticsearchServiceStart | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) | | [http](./kibana-plugin-core-server.corestart.http.md) | HttpServiceStart | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) | -| [metrics](./kibana-plugin-core-server.corestart.metrics.md) | MetricsServiceStart | | +| [metrics](./kibana-plugin-core-server.corestart.metrics.md) | MetricsServiceStart | [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) | | [savedObjects](./kibana-plugin-core-server.corestart.savedobjects.md) | SavedObjectsServiceStart | [SavedObjectsServiceStart](./kibana-plugin-core-server.savedobjectsservicestart.md) | | [uiSettings](./kibana-plugin-core-server.corestart.uisettings.md) | UiSettingsServiceStart | [UiSettingsServiceStart](./kibana-plugin-core-server.uisettingsservicestart.md) | diff --git a/docs/development/core/server/kibana-plugin-core-server.corestart.metrics.md b/docs/development/core/server/kibana-plugin-core-server.corestart.metrics.md index a51c2f842c346..2c32f730c4c9b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.corestart.metrics.md +++ b/docs/development/core/server/kibana-plugin-core-server.corestart.metrics.md @@ -4,6 +4,7 @@ ## CoreStart.metrics property +[MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.md index 05e408ab49995..0a49ee6e63d6c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.md @@ -33,4 +33,5 @@ export declare class KibanaRequestRecursiveReadonly<KibanaRequestRoute<Method>> | matched route details | | [socket](./kibana-plugin-core-server.kibanarequest.socket.md) | | IKibanaSocket | [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) | | [url](./kibana-plugin-core-server.kibanarequest.url.md) | | Url | a WHATWG URL standard object. | +| [uuid](./kibana-plugin-core-server.kibanarequest.uuid.md) | | string | A UUID to identify this request. | diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.uuid.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.uuid.md new file mode 100644 index 0000000000000..8b980b82d0adb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.uuid.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [uuid](./kibana-plugin-core-server.kibanarequest.uuid.md) + +## KibanaRequest.uuid property + +A UUID to identify this request. + +Signature: + +```typescript +readonly uuid: string; +``` + +## Remarks + +This value is NOT sourced from the incoming request's `X-Opaque-Id` header. it is always a UUID uniquely identifying the request. + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 69f2cf0338a01..be8b7c27495ad 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -50,6 +50,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | Interface | Description | | --- | --- | +| [AppCategory](./kibana-plugin-core-server.appcategory.md) | A category definition for nav links to know where to sort them in the left hand nav | | [AssistanceAPIResponse](./kibana-plugin-core-server.assistanceapiresponse.md) | | | [AssistantAPIClientParams](./kibana-plugin-core-server.assistantapiclientparams.md) | | | [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) | Event to audit. | @@ -253,6 +254,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [LegacyElasticsearchClientConfig](./kibana-plugin-core-server.legacyelasticsearchclientconfig.md) | | | [LifecycleResponseFactory](./kibana-plugin-core-server.lifecycleresponsefactory.md) | Creates an object containing redirection or error response with error details, HTTP headers, and other data transmitted to the client. | | [LoggerConfigType](./kibana-plugin-core-server.loggerconfigtype.md) | | +| [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) | APIs to retrieves metrics gathered and exposed by the core platform. | | [MIGRATION\_ASSISTANCE\_INDEX\_ACTION](./kibana-plugin-core-server.migration_assistance_index_action.md) | | | [MIGRATION\_DEPRECATION\_LEVEL](./kibana-plugin-core-server.migration_deprecation_level.md) | | | [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | diff --git a/docs/development/core/server/kibana-plugin-core-server.metricsservicestart.md b/docs/development/core/server/kibana-plugin-core-server.metricsservicestart.md new file mode 100644 index 0000000000000..8b3280d528c18 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.metricsservicestart.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [MetricsServiceStart](./kibana-plugin-core-server.metricsservicestart.md) + +## MetricsServiceStart type + +APIs to retrieves metrics gathered and exposed by the core platform. + +Signature: + +```typescript +export declare type MetricsServiceStart = MetricsServiceSetup; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 804c83f7c1b48..ce5c20e60ca11 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsfindoptions.type.md) | string | string[] | | +| [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md new file mode 100644 index 0000000000000..8bec759f05580 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) + +## SavedObjectsFindOptions.typeToNamespacesMap property + +This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + +Signature: + +```typescript +typeToNamespacesMap?: Map; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index 1b562263145da..d3e93e7af2aa0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,14 +7,14 @@ Signature: ```typescript -find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | +| options | SavedObjectsFindOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 14d3741425987..1d11d5262a9c4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -24,7 +24,7 @@ export declare class SavedObjectsRepository | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | -| [find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md new file mode 100644 index 0000000000000..40e865cb02ce8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) + +## SavedObjectsUtils.createEmptyFindResponse property + +Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. + +Signature: + +```typescript +static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md index e365dfbcb5142..83831f65bd41a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -15,6 +15,7 @@ export declare class SavedObjectsUtils | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static | <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T> | Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. | | [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static | (namespace?: string | undefined) => string | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined namespace ID (which has a namespace string of 'default'). | | [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static | (namespace: string) => string | undefined | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default' namespace string (which has a namespace ID of undefined). | diff --git a/docs/development/core/server/kibana-plugin-core-server.servicestatuslevels.md b/docs/development/core/server/kibana-plugin-core-server.servicestatuslevels.md index a66cec78c736b..e57dc192cd572 100644 --- a/docs/development/core/server/kibana-plugin-core-server.servicestatuslevels.md +++ b/docs/development/core/server/kibana-plugin-core-server.servicestatuslevels.md @@ -13,18 +13,22 @@ ServiceStatusLevels: Readonly<{ available: Readonly<{ toString: () => "available"; valueOf: () => 0; + toJSON: () => "available"; }>; degraded: Readonly<{ toString: () => "degraded"; valueOf: () => 1; + toJSON: () => "degraded"; }>; unavailable: Readonly<{ toString: () => "unavailable"; valueOf: () => 2; + toJSON: () => "unavailable"; }>; critical: Readonly<{ toString: () => "critical"; valueOf: () => 3; + toJSON: () => "critical"; }>; }> ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md new file mode 100644 index 0000000000000..c417aaa2cef48 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StatusServiceSetup](./kibana-plugin-core-server.statusservicesetup.md) > [isStatusPageAnonymous](./kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md) + +## StatusServiceSetup.isStatusPageAnonymous property + +Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is present. + +Signature: + +```typescript +isStatusPageAnonymous: () => boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md index ba0645be4d26c..f522d11a7ffef 100644 --- a/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.statusservicesetup.md @@ -74,6 +74,7 @@ core.status.set( | [core$](./kibana-plugin-core-server.statusservicesetup.core_.md) | Observable<CoreStatus> | Current status for all Core services. | | [dependencies$](./kibana-plugin-core-server.statusservicesetup.dependencies_.md) | Observable<Record<string, ServiceStatus>> | Current status for all plugins this plugin depends on. Each key of the Record is a plugin id. | | [derivedStatus$](./kibana-plugin-core-server.statusservicesetup.derivedstatus_.md) | Observable<ServiceStatus> | The status of this plugin as derived from its dependencies. | +| [isStatusPageAnonymous](./kibana-plugin-core-server.statusservicesetup.isstatuspageanonymous.md) | () => boolean | Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is present. | | [overall$](./kibana-plugin-core-server.statusservicesetup.overall_.md) | Observable<ServiceStatus> | Overall system status for all of Kibana. | ## Methods diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md index bc34d4113f847..4422b755faa77 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esfilters.md @@ -35,7 +35,7 @@ esFilters: { type?: string | undefined; key?: string | undefined; params?: any; - value?: string | ((formatter?: import("../common").FilterValueFormatter | undefined) => string) | undefined; + value?: string | undefined; }; $state?: import("../common").FilterState | undefined; query?: any; diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter._state.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter._state.md deleted file mode 100644 index bfb5dff71e70d..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter._state.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Filter](./kibana-plugin-plugins-data-public.filter.md) > [$state](./kibana-plugin-plugins-data-public.filter._state.md) - -## Filter.$state property - -Signature: - -```typescript -$state?: FilterState; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.md index f993721ee96ad..9212b757e07df 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.md @@ -2,19 +2,14 @@ [Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Filter](./kibana-plugin-plugins-data-public.filter.md) -## Filter interface +## Filter type Signature: ```typescript -export interface Filter +export declare type Filter = { + $state?: FilterState; + meta: FilterMeta; + query?: any; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [$state](./kibana-plugin-plugins-data-public.filter._state.md) | FilterState | | -| [meta](./kibana-plugin-plugins-data-public.filter.meta.md) | FilterMeta | | -| [query](./kibana-plugin-plugins-data-public.filter.query.md) | any | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.meta.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.meta.md deleted file mode 100644 index 3385a3773a2aa..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.meta.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Filter](./kibana-plugin-plugins-data-public.filter.md) > [meta](./kibana-plugin-plugins-data-public.filter.meta.md) - -## Filter.meta property - -Signature: - -```typescript -meta: FilterMeta; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.query.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.query.md deleted file mode 100644 index 083b544493e80..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.filter.query.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Filter](./kibana-plugin-plugins-data-public.filter.md) > [query](./kibana-plugin-plugins-data-public.filter.query.md) - -## Filter.query property - -Signature: - -```typescript -query?: any; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.language.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.language.md deleted file mode 100644 index 127ee9210799e..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.language.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Query](./kibana-plugin-plugins-data-public.query.md) > [language](./kibana-plugin-plugins-data-public.query.language.md) - -## Query.language property - -Signature: - -```typescript -language: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.md index a1dffe5ff5fa4..e15b04236a0b5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.md @@ -2,18 +2,15 @@ [Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Query](./kibana-plugin-plugins-data-public.query.md) -## Query interface +## Query type Signature: ```typescript -export interface Query +export declare type Query = { + query: string | { + [key: string]: any; + }; + language: string; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [language](./kibana-plugin-plugins-data-public.query.language.md) | string | | -| [query](./kibana-plugin-plugins-data-public.query.query.md) | string | {
[key: string]: any;
} | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.query.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.query.md deleted file mode 100644 index 9fcd0310af0fe..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.query.query.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [Query](./kibana-plugin-plugins-data-public.query.md) > [query](./kibana-plugin-plugins-data-public.query.query.md) - -## Query.query property - -Signature: - -```typescript -query: string | { - [key: string]: any; - }; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.from.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.from.md deleted file mode 100644 index b428bd9cd90ca..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.from.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimeRange](./kibana-plugin-plugins-data-public.timerange.md) > [from](./kibana-plugin-plugins-data-public.timerange.from.md) - -## TimeRange.from property - -Signature: - -```typescript -from: string; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.md index 69078ca40d20d..482501e494c7a 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.md @@ -2,19 +2,14 @@ [Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimeRange](./kibana-plugin-plugins-data-public.timerange.md) -## TimeRange interface +## TimeRange type Signature: ```typescript -export interface TimeRange +export declare type TimeRange = { + from: string; + to: string; + mode?: 'absolute' | 'relative'; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [from](./kibana-plugin-plugins-data-public.timerange.from.md) | string | | -| [mode](./kibana-plugin-plugins-data-public.timerange.mode.md) | 'absolute' | 'relative' | | -| [to](./kibana-plugin-plugins-data-public.timerange.to.md) | string | | - diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.mode.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.mode.md deleted file mode 100644 index fb9ebd3c9165f..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.mode.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimeRange](./kibana-plugin-plugins-data-public.timerange.md) > [mode](./kibana-plugin-plugins-data-public.timerange.mode.md) - -## TimeRange.mode property - -Signature: - -```typescript -mode?: 'absolute' | 'relative'; -``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.to.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.to.md deleted file mode 100644 index 342acd5e049f1..0000000000000 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.timerange.to.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [TimeRange](./kibana-plugin-plugins-data-public.timerange.md) > [to](./kibana-plugin-plugins-data-public.timerange.to.md) - -## TimeRange.to property - -Signature: - -```typescript -to: string; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter._state.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter._state.md deleted file mode 100644 index 079f352609a70..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter._state.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Filter](./kibana-plugin-plugins-data-server.filter.md) > [$state](./kibana-plugin-plugins-data-server.filter._state.md) - -## Filter.$state property - -Signature: - -```typescript -$state?: FilterState; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md index 4e4c49b222f01..519bbaf8f9416 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.md @@ -2,19 +2,14 @@ [Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Filter](./kibana-plugin-plugins-data-server.filter.md) -## Filter interface +## Filter type Signature: ```typescript -export interface Filter +export declare type Filter = { + $state?: FilterState; + meta: FilterMeta; + query?: any; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [$state](./kibana-plugin-plugins-data-server.filter._state.md) | FilterState | | -| [meta](./kibana-plugin-plugins-data-server.filter.meta.md) | FilterMeta | | -| [query](./kibana-plugin-plugins-data-server.filter.query.md) | any | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.meta.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.meta.md deleted file mode 100644 index 6d11804704d82..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.meta.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Filter](./kibana-plugin-plugins-data-server.filter.md) > [meta](./kibana-plugin-plugins-data-server.filter.meta.md) - -## Filter.meta property - -Signature: - -```typescript -meta: FilterMeta; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.query.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.query.md deleted file mode 100644 index 942c7930f449d..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.filter.query.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Filter](./kibana-plugin-plugins-data-server.filter.md) > [query](./kibana-plugin-plugins-data-server.filter.query.md) - -## Filter.query property - -Signature: - -```typescript -query?: any; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsforwildcard.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsforwildcard.md index 6bd3bbf2433cd..52382372d6d96 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsforwildcard.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsfetcher.getfieldsforwildcard.md @@ -12,6 +12,9 @@ Get a list of field objects for an index pattern that may contain wildcards getFieldsForWildcard(options: { pattern: string | string[]; metaFields?: string[]; + fieldCapsOptions?: { + allowNoIndices: boolean; + }; }): Promise; ``` @@ -19,7 +22,7 @@ getFieldsForWildcard(options: { | Parameter | Type | Description | | --- | --- | --- | -| options | {
pattern: string | string[];
metaFields?: string[];
} | | +| options | {
pattern: string | string[];
metaFields?: string[];
fieldCapsOptions?: {
allowNoIndices: boolean;
};
} | | Returns: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index f5b587d86b349..3c477e17503f4 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -42,7 +42,6 @@ | [AggParamOption](./kibana-plugin-plugins-data-server.aggparamoption.md) | | | [EsQueryConfig](./kibana-plugin-plugins-data-server.esqueryconfig.md) | | | [FieldFormatConfig](./kibana-plugin-plugins-data-server.fieldformatconfig.md) | | -| [Filter](./kibana-plugin-plugins-data-server.filter.md) | | | [IEsSearchRequest](./kibana-plugin-plugins-data-server.iessearchrequest.md) | | | [IEsSearchResponse](./kibana-plugin-plugins-data-server.iessearchresponse.md) | | | [IFieldSubType](./kibana-plugin-plugins-data-server.ifieldsubtype.md) | | @@ -58,12 +57,10 @@ | [OptionedValueProp](./kibana-plugin-plugins-data-server.optionedvalueprop.md) | | | [PluginSetup](./kibana-plugin-plugins-data-server.pluginsetup.md) | | | [PluginStart](./kibana-plugin-plugins-data-server.pluginstart.md) | | -| [Query](./kibana-plugin-plugins-data-server.query.md) | | | [RefreshInterval](./kibana-plugin-plugins-data-server.refreshinterval.md) | | | [SearchUsage](./kibana-plugin-plugins-data-server.searchusage.md) | | | [TabbedAggColumn](./kibana-plugin-plugins-data-server.tabbedaggcolumn.md) | \* | | [TabbedTable](./kibana-plugin-plugins-data-server.tabbedtable.md) | \* | -| [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) | | ## Variables @@ -91,11 +88,14 @@ | [AggParam](./kibana-plugin-plugins-data-server.aggparam.md) | | | [EsaggsExpressionFunctionDefinition](./kibana-plugin-plugins-data-server.esaggsexpressionfunctiondefinition.md) | | | [FieldFormatsGetConfigFn](./kibana-plugin-plugins-data-server.fieldformatsgetconfigfn.md) | | +| [Filter](./kibana-plugin-plugins-data-server.filter.md) | | | [IAggConfig](./kibana-plugin-plugins-data-server.iaggconfig.md) | AggConfig This class represents an aggregation, which is displayed in the left-hand nav of the Visualize app. | | [IAggType](./kibana-plugin-plugins-data-server.iaggtype.md) | | | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [IFieldParamType](./kibana-plugin-plugins-data-server.ifieldparamtype.md) | | | [IMetricAggType](./kibana-plugin-plugins-data-server.imetricaggtype.md) | | | [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | +| [Query](./kibana-plugin-plugins-data-server.query.md) | | | [TabbedAggRow](./kibana-plugin-plugins-data-server.tabbedaggrow.md) | \* | +| [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.language.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.language.md deleted file mode 100644 index 384fc77d801c0..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.language.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Query](./kibana-plugin-plugins-data-server.query.md) > [language](./kibana-plugin-plugins-data-server.query.language.md) - -## Query.language property - -Signature: - -```typescript -language: string; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.md index 5d61c75bc5e99..6a7bdfe51f1c0 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.md @@ -2,18 +2,15 @@ [Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Query](./kibana-plugin-plugins-data-server.query.md) -## Query interface +## Query type Signature: ```typescript -export interface Query +export declare type Query = { + query: string | { + [key: string]: any; + }; + language: string; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [language](./kibana-plugin-plugins-data-server.query.language.md) | string | | -| [query](./kibana-plugin-plugins-data-server.query.query.md) | string | {
[key: string]: any;
} | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.query.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.query.md deleted file mode 100644 index 5c2aa700bc603..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.query.query.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [Query](./kibana-plugin-plugins-data-server.query.md) > [query](./kibana-plugin-plugins-data-server.query.query.md) - -## Query.query property - -Signature: - -```typescript -query: string | { - [key: string]: any; - }; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.from.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.from.md deleted file mode 100644 index b6f40cc2e4203..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.from.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) > [from](./kibana-plugin-plugins-data-server.timerange.from.md) - -## TimeRange.from property - -Signature: - -```typescript -from: string; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.md index 8280d924eb609..1ac59343220fd 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.md @@ -2,19 +2,14 @@ [Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) -## TimeRange interface +## TimeRange type Signature: ```typescript -export interface TimeRange +export declare type TimeRange = { + from: string; + to: string; + mode?: 'absolute' | 'relative'; +}; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [from](./kibana-plugin-plugins-data-server.timerange.from.md) | string | | -| [mode](./kibana-plugin-plugins-data-server.timerange.mode.md) | 'absolute' | 'relative' | | -| [to](./kibana-plugin-plugins-data-server.timerange.to.md) | string | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.mode.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.mode.md deleted file mode 100644 index 1408fb43cbf39..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.mode.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) > [mode](./kibana-plugin-plugins-data-server.timerange.mode.md) - -## TimeRange.mode property - -Signature: - -```typescript -mode?: 'absolute' | 'relative'; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.to.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.to.md deleted file mode 100644 index 98aca5474d350..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.timerange.to.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [TimeRange](./kibana-plugin-plugins-data-server.timerange.md) > [to](./kibana-plugin-plugins-data-server.timerange.to.md) - -## TimeRange.to property - -Signature: - -```typescript -to: string; -``` diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 9c8d753a2d668..3489dcd018293 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -104,15 +104,14 @@ security is enabled, `xpack.security.encryptionKey`. [cols="2*<"] |=== | `xpack.reporting.queue.pollInterval` - | Specifies the number of milliseconds that the reporting poller waits between polling the - index for any pending Reporting jobs. Defaults to `3000` (3 seconds). + | Specify the {ref}/common-options.html#time-units[time] that the reporting poller waits between polling the index for any + pending Reporting jobs. Can be specified as number of milliseconds. Defaults to `3s`. | [[xpack-reporting-q-timeout]] `xpack.reporting.queue.timeout` {ess-icon} - | How long each worker has to produce a report. If your machine is slow or under - heavy load, you might need to increase this timeout. Specified in milliseconds. - If a Reporting job execution time goes over this time limit, the job will be - marked as a failure and there will not be a download available. - Defaults to `120000` (two minutes). + | {ref}/common-options.html#time-units[How long] each worker has to produce a report. If your machine is slow or under heavy + load, you might need to increase this timeout. If a Reporting job execution goes over this time limit, the job is marked as a + failure and no download will be available. Can be specified as number of milliseconds. + Defaults to `2m`. |=== @@ -127,24 +126,24 @@ control the capturing process. |=== a| `xpack.reporting.capture.timeouts` `.openUrl` {ess-icon} - | Specify how long to allow the Reporting browser to wait for the "Loading..." screen - to dismiss and find the initial data for the Kibana page. If the time is - exceeded, a page screenshot is captured showing the current state, and the download link shows a warning message. - Defaults to `60000` (1 minute). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for the "Loading..." screen + to dismiss and find the initial data for the page. If the time is exceeded, a screenshot is captured showing the current + page, and the download link shows a warning message. Can be specified as number of milliseconds. + Defaults to `1m`. a| `xpack.reporting.capture.timeouts` `.waitForElements` {ess-icon} - | Specify how long to allow the Reporting browser to wait for all visualization - panels to load on the Kibana page. If the time is exceeded, a page screenshot - is captured showing the current state, and the download link shows a warning message. Defaults to `30000` (30 - seconds). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for all visualization panels + to load on the page. If the time is exceeded, a screenshot is captured showing the current page, and the download link shows + a warning message. Can be specified as number of milliseconds. + Defaults to `30s`. a| `xpack.reporting.capture.timeouts` `.renderComplete` {ess-icon} - | Specify how long to allow the Reporting browser to wait for all visualizations to - fetch and render the data. If the time is exceeded, a - page screenshot is captured showing the current state, and the download link shows a warning message. Defaults to - `30000` (30 seconds). + | Specify the {ref}/common-options.html#time-units[time] to allow the Reporting browser to wait for all visualizations to + fetch and render the data. If the time is exceeded, a screenshot is captured showing the current page, and the download link shows a + warning message. Can be specified as number of milliseconds. + Defaults to `30s`. |=== @@ -163,11 +162,10 @@ available, but there will likely be errors in the visualizations in the report. job, as many times as this setting. Defaults to `3`. | `xpack.reporting.capture.loadDelay` - | When visualizations are not evented, this is the amount of time before - taking a screenshot. All visualizations that ship with {kib} are evented, so this - setting should not have much effect. If you are seeing empty images instead of - visualizations, try increasing this value. - Defaults to `3000` (3 seconds). + | Specify the {ref}/common-options.html#time-units[amount of time] before taking a screenshot when visualizations are not evented. + All visualizations that ship with {kib} are evented, so this setting should not have much effect. If you are seeing empty images + instead of visualizations, try increasing this value. + Defaults to `3s`. | [[xpack-reporting-browser]] `xpack.reporting.capture.browser.type` {ess-icon} | Specifies the browser to use to capture screenshots. This setting exists for @@ -213,9 +211,9 @@ a| `xpack.reporting.capture.browser` [cols="2*<"] |=== | [[xpack-reporting-csv]] `xpack.reporting.csv.maxSizeBytes` {ess-icon} - | The maximum size of a CSV file before being truncated. This setting exists to prevent - large exports from causing performance and storage issues. - Defaults to `10485760` (10mB). + | The maximum {ref}/common-options.html#byte-units[byte size] of a CSV file before being truncated. This setting exists to + prevent large exports from causing performance and storage issues. Can be specified as number of bytes. + Defaults to `10mb`. | `xpack.reporting.csv.scroll.size` | Number of documents retrieved from {es} for each scroll iteration during a CSV @@ -223,7 +221,7 @@ a| `xpack.reporting.capture.browser` Defaults to `500`. | `xpack.reporting.csv.scroll.duration` - | Amount of time allowed before {kib} cleans the scroll context during a CSV export. + | Amount of {ref}/common-options.html#time-units[time] allowed before {kib} cleans the scroll context during a CSV export. Defaults to `30s`. | `xpack.reporting.csv.checkForFormulas` diff --git a/docs/user/alerting/action-types/server-log.asciidoc b/docs/user/alerting/action-types/server-log.asciidoc index eadca229bc19c..7022320328c85 100644 --- a/docs/user/alerting/action-types/server-log.asciidoc +++ b/docs/user/alerting/action-types/server-log.asciidoc @@ -2,7 +2,7 @@ [[server-log-action-type]] === Server log action -This action type writes and entry to the {kib} server log. +This action type writes an entry to the {kib} server log. [float] [[server-log-connector-configuration]] diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index 85230f1b6f70d..e3d0e16630c5c 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -1,3 +1,4 @@ +[role="xpack"] [[drilldowns]] == Use drilldowns for dashboard actions diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 16f82477756b7..e6daf89d72718 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -1,6 +1,8 @@ [[url-drilldown]] === URL drilldown +beta[] + The URL drilldown allows you to navigate from a dashboard to an internal or external URL. The destination URL can be dynamic, depending on the dashboard context or user’s interaction with a visualization. @@ -197,6 +199,7 @@ context.panel.timeRange.indexPatternIds | ID of saved object behind a panel. | *Single click* + | event.value | Value behind clicked data point. @@ -208,6 +211,22 @@ context.panel.timeRange.indexPatternIds | event.negate | Boolean, indicating whether clicked data point resulted in negative filter. +| +| event.points +| Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient. + + +Example: + +`{{json event.points}}` + +`{{event.points.[0].key}}` + +`{{event.points.[0].value}}` +`{{#each event.points}}key=value&{{/each}}` + +Note: + +`{{event.value}}` is a shorthand for `{{event.points.[0].value}}` + +`{{event.key}}` is a shorthand for `{{event.points.[0].key}}` + | *Range selection* | event.from + event.to diff --git a/docs/user/monitoring/monitoring-kibana.asciidoc b/docs/user/monitoring/monitoring-kibana.asciidoc index 47fbe1bea9f2a..9d735ea1fe3db 100644 --- a/docs/user/monitoring/monitoring-kibana.asciidoc +++ b/docs/user/monitoring/monitoring-kibana.asciidoc @@ -8,9 +8,20 @@ If you enable the Elastic {monitor-features} in your cluster, you can optionally collect metrics about {kib}. +[IMPORTANT] +========================= +{metricbeat} is the recommended method for collecting and shipping monitoring +data to a monitoring cluster. + +If you have previously configured legacy collection methods, you should migrate +to using {metricbeat} collection methods. Use either {metricbeat} collection or +legacy collection methods; do not use both. + +For the recommended method, refer to <>. +========================= + The following method involves sending the metrics to the production cluster, -which ultimately routes them to the monitoring cluster. For the recommended -method, see <>. +which ultimately routes them to the monitoring cluster. To learn about monitoring in general, see {ref}/monitor-elasticsearch-cluster.html[Monitor a cluster]. diff --git a/docs/user/monitoring/viewing-metrics.asciidoc b/docs/user/monitoring/viewing-metrics.asciidoc index f35caea025cdd..0c48e3b7d011d 100644 --- a/docs/user/monitoring/viewing-metrics.asciidoc +++ b/docs/user/monitoring/viewing-metrics.asciidoc @@ -13,13 +13,19 @@ At a minimum, you must have monitoring data for the {es} production cluster. Once that data exists, {kib} can display monitoring data for other products in the cluster. +TIP: If you use a separate monitoring cluster to store the monitoring data, it +is strongly recommended that you use a separate {kib} instance to view it. If +you log in to {kib} using SAML, Kerberos, PKI, OpenID Connect, or token +authentication providers, a dedicated {kib} instance is *required*. The security +tokens that are used in these contexts are cluster-specific, therefore you +cannot use a single {kib} instance to connect to both production and monitoring +clusters. For more information about the recommended configuration, see +{ref}/monitoring-overview.html[Monitoring overview]. + . Identify where to retrieve monitoring data from. + -- -The cluster that contains the monitoring data is referred to -as the _monitoring cluster_. - -TIP: If the monitoring data is stored on a *dedicated* monitoring cluster, it is +If the monitoring data is stored on a dedicated monitoring cluster, it is accessible even when the cluster you're monitoring is not. If you have at least a gold license, you can send data from multiple clusters to the same monitoring cluster and view them all through the same instance of {kib}. diff --git a/examples/alerting_example/server/plugin.ts b/examples/alerting_example/server/plugin.ts index 8e246960937ec..4141b48ffeeaf 100644 --- a/examples/alerting_example/server/plugin.ts +++ b/examples/alerting_example/server/plugin.ts @@ -19,6 +19,7 @@ import { Plugin, CoreSetup } from 'kibana/server'; import { i18n } from '@kbn/i18n'; +import { DEFAULT_APP_CATEGORIES } from '../../../src/core/server'; import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerts/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../../x-pack/plugins/features/server'; @@ -47,6 +48,7 @@ export class AlertingExamplePlugin implements Plugin { + + + + + diff --git a/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx new file mode 100644 index 0000000000000..ea00b22fa3cdc --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/context_menu_examples.tsx @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { PanelView } from './panel_view'; +import { PanelViewWithSharing } from './panel_view_with_sharing'; +import { PanelViewWithSharingLong } from './panel_view_with_sharing_long'; +import { PanelEdit } from './panel_edit'; +import { PanelEditWithDrilldowns } from './panel_edit_with_drilldowns'; +import { PanelEditWithDrilldownsAndContextActions } from './panel_edit_with_drilldowns_and_context_actions'; + +export const ContextMenuExamples: React.FC = () => { + return ( + +

Context menu examples

+

+ Below examples show how context menu panels look with varying number of actions and how the + actions can be grouped into different panels using grouping field. +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/legacy/server/status/lib/index.js b/examples/ui_actions_explorer/public/context_menu_examples/index.tsx similarity index 92% rename from src/legacy/server/status/lib/index.js rename to examples/ui_actions_explorer/public/context_menu_examples/index.tsx index 93db8b2d22561..4a8c2fd00cd4d 100644 --- a/src/legacy/server/status/lib/index.js +++ b/examples/ui_actions_explorer/public/context_menu_examples/index.tsx @@ -17,4 +17,4 @@ * under the License. */ -export { getKibanaInfoForStats } from './get_kibana_info_for_stats'; +export * from './context_menu_examples'; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx new file mode 100644 index 0000000000000..794a8d0348baf --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelEdit: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const actions = [ + sampleAction('test-1', 100, 'Edit visualization', 'pencil'), + sampleAction('test-2', 99, 'Clone panel', 'partial'), + sampleAction('test-3', 98, 'Edit panel title', 'pencil'), + sampleAction('test-4', 97, 'Customize time range', 'calendar'), + sampleAction('test-5', 96, 'Inspect', 'inspect'), + sampleAction('test-6', 95, 'Full screen', 'fullScreen'), + sampleAction('test-7', 94, 'Replace panel', 'submodule'), + sampleAction('test-8', 93, 'Delete from dashboard', 'trash'), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}>Edit mode} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx new file mode 100644 index 0000000000000..185011066e8e4 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns.tsx @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelEditWithDrilldowns: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const grouping: Action['grouping'] = [ + { + id: 'drilldowns', + getDisplayName: () => 'Drilldowns', + getIconType: () => 'popout', + order: 20, + }, + ]; + const actions = [ + sampleAction('test-1', 100, 'Edit visualization', 'pencil'), + sampleAction('test-2', 99, 'Clone panel', 'partial'), + sampleAction('test-3', 98, 'Edit panel title', 'pencil'), + sampleAction('test-4', 97, 'Customize time range', 'calendar'), + sampleAction('test-5', 96, 'Inspect', 'inspect'), + sampleAction('test-6', 95, 'Full screen', 'fullScreen'), + sampleAction('test-7', 94, 'Replace panel', 'submodule'), + sampleAction('test-8', 93, 'Delete from dashboard', 'trash'), + + sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', grouping), + sampleAction('test-10', 9, 'Manage drilldowns', 'list', grouping), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}>Edit mode with drilldowns} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx new file mode 100644 index 0000000000000..e9543814ff015 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_edit_with_drilldowns_and_context_actions.tsx @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelEditWithDrilldownsAndContextActions: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const drilldownGrouping: Action['grouping'] = [ + { + id: 'drilldowns', + getDisplayName: () => 'Drilldowns', + getIconType: () => 'popout', + order: 20, + }, + ]; + const customActionGrouping: Action['grouping'] = [ + { + id: 'actions', + getDisplayName: () => 'Custom actions', + getIconType: () => 'cloudStormy', + order: 20, + }, + ]; + const actions = [ + sampleAction('test-1', 100, 'Edit visualization', 'pencil'), + sampleAction('test-2', 99, 'Clone panel', 'partial'), + sampleAction('test-3', 98, 'Edit panel title', 'pencil'), + sampleAction('test-4', 97, 'Customize time range', 'calendar'), + sampleAction('test-5', 96, 'Inspect', 'inspect'), + sampleAction('test-6', 95, 'Full screen', 'fullScreen'), + sampleAction('test-7', 94, 'Replace panel', 'submodule'), + sampleAction('test-8', 93, 'Delete from dashboard', 'trash'), + + sampleAction('test-9', 10, 'Create drilldown', 'plusInCircle', drilldownGrouping), + sampleAction('test-10', 9, 'Manage drilldowns', 'list', drilldownGrouping), + + sampleAction('test-11', 10, 'Go to Sales dashboard', 'dashboardApp', customActionGrouping), + sampleAction('test-12', 9, 'Go to Traffic dashboard', 'dashboardApp', customActionGrouping), + sampleAction('test-13', 8, 'Custom actions', 'cloudStormy', customActionGrouping), + sampleAction('test-14', 7, 'View in Salesforce', 'link', customActionGrouping), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}> + Edit mode with drilldowns and custom actions + + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx new file mode 100644 index 0000000000000..db8763fdf17f8 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view.tsx @@ -0,0 +1,55 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelView: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const actions = [ + sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'), + sampleAction('test-2', 99, 'Customize time range', 'calendar'), + sampleAction('test-3', 98, 'Inspect', 'inspect'), + sampleAction('test-4', 97, 'Full screen', 'fullScreen'), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}>View mode} + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx new file mode 100644 index 0000000000000..2c99d04e7d19a --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing.tsx @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelViewWithSharing: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const grouping: Action['grouping'] = [ + { + id: 'sharing', + getDisplayName: () => 'Sharing', + getIconType: () => 'share', + order: 50, + }, + ]; + const actions = [ + sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'), + sampleAction('test-2', 99, 'Customize time range', 'calendar'), + sampleAction('test-3', 98, 'Inspect', 'inspect'), + sampleAction('test-4', 97, 'Full screen', 'fullScreen'), + sampleAction('test-5', 10, 'Copy link', 'link', grouping), + sampleAction('test-6', 9, 'Copy .png', 'image', grouping), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}>View mode with few sharing options + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx new file mode 100644 index 0000000000000..99b8cbec57677 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/panel_view_with_sharing_long.tsx @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { EuiButton, EuiContextMenu, EuiPopover } from '@elastic/eui'; +import useAsync from 'react-use/lib/useAsync'; +import { buildContextMenuForActions, Action } from '../../../../src/plugins/ui_actions/public'; +import { sampleAction } from './util'; + +export const PanelViewWithSharingLong: React.FC = () => { + const [open, setOpen] = React.useState(false); + + const context = {}; + const trigger: any = 'TEST_TRIGGER'; + const grouping: Action['grouping'] = [ + { + id: 'sharing', + getDisplayName: () => 'Sharing', + getIconType: () => 'share', + order: 50, + }, + ]; + const actions = [ + sampleAction('test-1', 100, 'Explore underlying data', 'discoverApp'), + sampleAction('test-2', 99, 'Customize time range', 'calendar'), + sampleAction('test-3', 98, 'Inspect', 'inspect'), + sampleAction('test-4', 97, 'Full screen', 'fullScreen'), + sampleAction('test-5', 10, 'Copy link', 'link', grouping), + sampleAction('test-6', 9, 'Copy .png', 'image', grouping), + sampleAction('test-7', 8, 'Copy .pdf', 'link', grouping), + sampleAction('test-8', 7, 'Send to slack', 'link', grouping), + sampleAction('test-9', 6, 'Send by e-mail', 'email', grouping), + ]; + + const panels = useAsync(() => + buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context, trigger })), + }) + ); + + return ( + setOpen((x) => !x)}> + View mode with many sharing options + + } + isOpen={open} + panelPaddingSize="none" + anchorPosition="downLeft" + closePopover={() => setOpen(false)} + > + + + ); +}; diff --git a/examples/ui_actions_explorer/public/context_menu_examples/util.ts b/examples/ui_actions_explorer/public/context_menu_examples/util.ts new file mode 100644 index 0000000000000..ea14ceec7b0a3 --- /dev/null +++ b/examples/ui_actions_explorer/public/context_menu_examples/util.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Action } from '../../../../src/plugins/ui_actions/public'; + +export const sampleAction = ( + id: string, + order: number, + name: string, + icon: string, + grouping?: Action['grouping'] +): Action => { + return { + id, + type: 'SAMPLE' as any, + order, + getDisplayName: () => name, + getIconType: () => icon, + isCompatible: async () => true, + execute: async () => {}, + grouping, + }; +}; diff --git a/kibana.d.ts b/kibana.d.ts index 517bda374af9d..b707405ffbeaf 100644 --- a/kibana.d.ts +++ b/kibana.d.ts @@ -28,7 +28,6 @@ export { Public, Server }; /** * All exports from TS ambient definitions (where types are added for JS source in a .d.ts file). */ -import * as LegacyElasticsearch from './src/legacy/core_plugins/elasticsearch'; import * as LegacyKibanaPluginSpec from './src/legacy/plugin_discovery/plugin_spec/plugin_spec_options'; import * as LegacyKibanaServer from './src/legacy/server/kbn_server'; @@ -44,13 +43,4 @@ export namespace Legacy { export type InitPluginFunction = LegacyKibanaPluginSpec.InitPluginFunction; export type UiExports = LegacyKibanaPluginSpec.UiExports; export type PluginSpecOptions = LegacyKibanaPluginSpec.PluginSpecOptions; - - export namespace Plugins { - export namespace elasticsearch { - export type Plugin = LegacyElasticsearch.ElasticsearchPlugin; - export type Cluster = LegacyElasticsearch.Cluster; - export type ClusterConfig = LegacyElasticsearch.ClusterConfig; - export type CallClusterOptions = LegacyElasticsearch.CallClusterOptions; - } - } } diff --git a/package.json b/package.json index 2698b19e74d19..8f9315eed9010 100644 --- a/package.json +++ b/package.json @@ -78,27 +78,18 @@ }, "resolutions": { "**/@types/node": ">=10.17.17 <10.20.0", - "**/@types/react": "^16.9.36", - "**/@types/hapi": "^17.0.18", - "**/@types/angular": "^1.6.56", - "**/@types/hoist-non-react-statics": "^3.3.1", - "**/@types/chai": "^4.2.11", - "**/cypress/@types/lodash": "^4.14.159", - "**/cypress/lodash": "^4.17.20", - "**/typescript": "4.0.2", + "**/cross-fetch/node-fetch": "^2.6.1", + "**/deepmerge": "^4.2.2", + "**/fast-deep-equal": "^3.1.1", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", - "**/isomorphic-git/**/base64-js": "^1.2.1", + "**/isomorphic-fetch/node-fetch": "^2.6.1", "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", - "**/image-diff/gm/debug": "^2.6.9", "**/load-grunt-config/lodash": "^4.17.20", + "**/minimist": "^1.2.5", "**/node-jose/node-forge": "^0.10.0", - "**/react-dom": "^16.12.0", - "**/react": "^16.12.0", - "**/react-test-renderer": "^16.12.0", "**/request": "^2.88.2", - "**/deepmerge": "^4.2.2", - "**/fast-deep-equal": "^3.1.1" + "**/typescript": "4.0.2" }, "workspaces": { "packages": [ @@ -128,7 +119,7 @@ "@babel/register": "^7.10.5", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.0-rc.2", - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", "@elastic/request-crypto": "1.1.4", @@ -163,6 +154,7 @@ "color": "1.0.3", "commander": "3.0.2", "core-js": "^3.6.4", + "cypress-promise": "^1.1.0", "deep-freeze-strict": "^1.1.1", "del": "^5.1.0", "elastic-apm-node": "^3.7.0", @@ -194,7 +186,7 @@ "moment": "^2.24.0", "moment-timezone": "^0.5.27", "mustache": "2.3.2", - "node-fetch": "1.7.3", + "node-fetch": "2.6.1", "node-forge": "^0.10.0", "opn": "^5.5.0", "oppsy": "^2.0.0", @@ -316,7 +308,7 @@ "@types/pegjs": "^0.10.1", "@types/pngjs": "^3.3.2", "@types/podium": "^1.0.0", - "@types/prop-types": "^15.5.3", + "@types/prop-types": "^15.7.3", "@types/reach__router": "^1.2.6", "@types/react": "^16.9.36", "@types/react-dom": "^16.9.8", @@ -361,7 +353,7 @@ "babel-eslint": "^10.0.3", "babel-jest": "^25.5.1", "babel-plugin-istanbul": "^6.0.0", - "backport": "5.5.1", + "backport": "5.6.0", "brace": "0.11.1", "chai": "3.5.0", "chance": "1.0.18", @@ -455,7 +447,7 @@ "pngjs": "^3.4.0", "postcss": "^7.0.32", "prettier": "^2.1.1", - "prop-types": "15.6.0", + "prop-types": "^15.7.2", "proxyquire": "1.8.0", "react-grid-layout": "^0.16.2", "react-markdown": "^4.3.1", diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 41abe83c148cd..87df07fe865bd 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -144,7 +144,7 @@ export function runCli() { const query = flags.query; let parsedQuery; - if (typeof query === 'string') { + if (typeof query === 'string' && query.length > 0) { try { parsedQuery = JSON.parse(query); } catch (err) { diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index dabf11fdd0b66..52ef3fe05e751 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -14,7 +14,7 @@ "execa": "^4.0.2", "getopts": "^2.2.4", "glob": "^7.1.2", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "simple-git": "^1.91.0", "tar-fs": "^2.1.0", "tree-kill": "^1.2.2", diff --git a/packages/kbn-i18n/package.json b/packages/kbn-i18n/package.json index 680c789bc9d9d..eccdff9060cbe 100644 --- a/packages/kbn-i18n/package.json +++ b/packages/kbn-i18n/package.json @@ -27,7 +27,7 @@ "intl-format-cache": "^2.1.0", "intl-messageformat": "^2.2.0", "intl-relativeformat": "^2.1.0", - "prop-types": "^15.6.2", + "prop-types": "^15.7.2", "react": "^16.12.0", "react-intl": "^2.8.0" } diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index a2151ca3381bc..363f97522a901 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -18,7 +18,7 @@ "classnames": "2.2.6", "focus-trap-react": "^3.1.1", "lodash": "^4.17.15", - "prop-types": "15.6.0", + "prop-types": "^15.7.2", "react": "^16.12.0", "react-ace": "^5.9.0", "react-color": "^2.13.8", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index bbe7b1bc2e8da..372126c4418f5 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "21.1.2", - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index b1d1335eb1888..78472bb3f517d 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -224,7 +224,6 @@ export class ClusterManager { new Set( [ fromRoot('src/core'), - fromRoot('src/legacy/core_plugins'), fromRoot('src/legacy/server'), fromRoot('src/legacy/ui'), fromRoot('src/legacy/utils'), diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index eeb5564667ec4..d8bd39b9dcdf4 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -178,7 +178,7 @@ export default function (program) { 'A path to scan for plugins, this can be specified multiple ' + 'times to specify multiple directories', pluginDirCollector, - [fromRoot('plugins'), fromRoot('src/legacy/core_plugins')] + [fromRoot('plugins')] ) .option( '--plugin-path ', diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 1bff6cd9301ed..47b8aaefaf86a 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -171,6 +171,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { + "euiIconType": "managementApp", "id": "management", "label": "Management", "order": 5000, @@ -658,6 +659,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-controls="mockId" aria-expanded={true} className="euiAccordion__button euiAccordion__buttonReverse euiCollapsibleNavGroup__heading" + id="mockId" onClick={[Function]} type="button" > @@ -911,6 +913,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-controls="mockId" aria-expanded={true} className="euiAccordion__button euiAccordion__buttonReverse euiCollapsibleNavGroup__heading" + id="mockId" onClick={[Function]} type="button" > @@ -1200,6 +1203,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-controls="mockId" aria-expanded={true} className="euiAccordion__button euiAccordion__buttonReverse euiCollapsibleNavGroup__heading" + id="mockId" onClick={[Function]} type="button" > @@ -1450,6 +1454,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-controls="mockId" aria-expanded={true} className="euiAccordion__button euiAccordion__buttonReverse euiCollapsibleNavGroup__heading" + id="mockId" onClick={[Function]} type="button" > @@ -1602,6 +1607,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + + + @@ -1681,6 +1696,23 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
+ +
+ +
+ +
+
- +
@@ -2999,12 +3029,9 @@ exports[`CollapsibleNav renders the default nav 3`] = ` ] } > - +
@@ -5943,12 +5944,9 @@ exports[`Header renders 1`] = ` ] } > - +
; +export const DEFAULT_APP_CATEGORIES: Record; // @public (undocumented) export interface DocLinksStart { @@ -1109,6 +1079,7 @@ export interface SavedObjectsFindOptions { sortOrder?: string; // (undocumented) type: string | string[]; + typeToNamespacesMap?: Map; } // @public diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 5a8949ca2f55f..6a10eb44d9ca4 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -34,7 +34,7 @@ import { HttpFetchOptions, HttpSetup } from '../http'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'namespace' | 'sortOrder' | 'rootSearchFields' + 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' >; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/context/context_service.mock.ts b/src/core/server/context/context_service.mock.ts index a8d895acad624..24e0d52100bb1 100644 --- a/src/core/server/context/context_service.mock.ts +++ b/src/core/server/context/context_service.mock.ts @@ -21,9 +21,9 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { ContextService, ContextSetup } from './context_service'; import { contextMock } from '../../utils/context.mock'; -const createSetupContractMock = () => { +const createSetupContractMock = (mockContext = {}) => { const setupContract: jest.Mocked = { - createContextContainer: jest.fn().mockImplementation(() => contextMock.create()), + createContextContainer: jest.fn().mockImplementation(() => contextMock.create(mockContext)), }; return setupContract; }; diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index 121ef3aa42d51..e35d9962e9e7e 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -206,7 +206,7 @@ describe('ClusterClient', () => { const clusterClient = new ClusterClient(config, logger, getAuthHeaders); const request = httpServerMock.createKibanaRequest({ - kibanaRequestState: { requestId: 'my-fake-id' }, + kibanaRequestState: { requestId: 'my-fake-id', requestUuid: 'ignore-this-id' }, }); clusterClient.asScoped(request); @@ -284,7 +284,7 @@ describe('ClusterClient', () => { const clusterClient = new ClusterClient(config, logger, getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'request' }, - kibanaRequestState: { requestId: 'from request' }, + kibanaRequestState: { requestId: 'from request', requestUuid: 'ignore-this-id' }, }); clusterClient.asScoped(request); diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts index 73d941053e84b..745ef4304d0b1 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts @@ -351,7 +351,9 @@ describe('#asScoped', () => { test('passes x-opaque-id header with request id', () => { clusterClient.asScoped( - httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'alpha' } }) + httpServerMock.createKibanaRequest({ + kibanaRequestState: { requestId: 'alpha', requestUuid: 'ignore-this-id' }, + }) ); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.ts index 5f926215d167f..70ff8857117de 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.ts @@ -72,7 +72,7 @@ export function mapNodesVersionCompatibility( kibanaVersion: string, ignoreVersionMismatch: boolean ): NodesVersionCompatibility { - if (Object.keys(nodesInfo.nodes).length === 0) { + if (Object.keys(nodesInfo.nodes ?? {}).length === 0) { return { isCompatible: false, message: 'Unable to retrieve version information from Elasticsearch nodes.', diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 6d096b76263b5..9deaa73d8aacf 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -68,7 +68,7 @@ function createKibanaRequestMock

({ routeAuthRequired, validation = {}, kibanaRouteOptions = { xsrfRequired: true }, - kibanaRequestState = { requestId: '123' }, + kibanaRequestState = { requestId: '123', requestUuid: '123e4567-e89b-12d3-a456-426614174000' }, auth = { isAuthenticated: true }, }: RequestFixtureOptions = {}) { const queryString = stringify(query, { sort: false }); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 7609f23fe0c51..2440f2b1da0bd 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -19,6 +19,7 @@ import { Server } from 'hapi'; import HapiStaticFiles from 'inert'; import url from 'url'; +import uuid from 'uuid'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; @@ -315,6 +316,7 @@ export class HttpServer { request.app = { ...(request.app ?? {}), requestId: getRequestId(request, config.requestId), + requestUuid: uuid.v4(), } as KibanaRequestState; return responseToolkit.continue; }); diff --git a/src/core/server/http/integration_tests/request.test.ts b/src/core/server/http/integration_tests/request.test.ts index 0727ff848c189..0170e94867c06 100644 --- a/src/core/server/http/integration_tests/request.test.ts +++ b/src/core/server/http/integration_tests/request.test.ts @@ -16,6 +16,11 @@ * specific language governing permissions and limitations * under the License. */ + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'), +})); + import supertest from 'supertest'; import { HttpService } from '../http_service'; @@ -308,4 +313,20 @@ describe('KibanaRequest', () => { expect(resp3.body).toEqual({ requestId: 'gamma' }); }); }); + + describe('request uuid', () => { + it('generates a UUID', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + return res.ok({ body: { requestUuid: req.uuid } }); + }); + await server.start(); + + const st = supertest(innerServer.listener); + + const resp1 = await st.get('/').expect(200); + expect(resp1.body.requestUuid).toBe('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + }); + }); }); diff --git a/src/core/server/http/router/request.test.ts b/src/core/server/http/router/request.test.ts index e741121f3d70c..0bf81a7aca852 100644 --- a/src/core/server/http/router/request.test.ts +++ b/src/core/server/http/router/request.test.ts @@ -55,6 +55,34 @@ describe('KibanaRequest', () => { }); }); + describe('uuid property', () => { + it('uses the request.app.requestUuid property if present', () => { + const request = httpServerMock.createRawRequest({ + app: { requestUuid: '123e4567-e89b-12d3-a456-426614174000' }, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.uuid).toEqual('123e4567-e89b-12d3-a456-426614174000'); + }); + + it('generates a new UUID if request.app property is not present', () => { + // Undefined app property + const request = httpServerMock.createRawRequest({ + app: undefined, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.uuid).toEqual('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + }); + + it('generates a new UUID if request.app.requestUuid property is not present', () => { + // Undefined app.requestUuid property + const request = httpServerMock.createRawRequest({ + app: {}, + }); + const kibanaRequest = KibanaRequest.from(request); + expect(kibanaRequest.uuid).toEqual('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'); + }); + }); + describe('get all headers', () => { it('returns all headers', () => { const request = httpServerMock.createRawRequest({ diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index e04f8585981b5..903eb75022df3 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -44,6 +44,7 @@ export interface KibanaRouteOptions extends RouteOptionsApp { */ export interface KibanaRequestState extends ApplicationState { requestId: string; + requestUuid: string; } /** @@ -152,6 +153,14 @@ export class KibanaRequest< * per request. */ public readonly id: string; + /** + * A UUID to identify this request. + * + * @remarks + * This value is NOT sourced from the incoming request's `X-Opaque-Id` header. it + * is always a UUID uniquely identifying the request. + */ + public readonly uuid: string; /** a WHATWG URL standard object. */ public readonly url: Url; /** matched route details */ @@ -189,10 +198,11 @@ export class KibanaRequest< // until that time we have to expose all the headers private readonly withoutSecretHeaders: boolean ) { - // The `requestId` property will not be populated for requests that are 'faked' by internal systems that leverage + // The `requestId` and `requestUuid` properties will not be populated for requests that are 'faked' by internal systems that leverage // KibanaRequest in conjunction with scoped Elaticcsearch and SavedObjectsClient in order to pass credentials. - // In these cases, the id defaults to a newly generated UUID. + // In these cases, the ids default to a newly generated UUID. this.id = (request.app as KibanaRequestState | undefined)?.requestId ?? uuid.v4(); + this.uuid = (request.app as KibanaRequestState | undefined)?.requestUuid ?? uuid.v4(); this.url = request.url; this.headers = deepFreeze({ ...request.headers }); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 01797d073ae2e..e136c699f7246 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -60,7 +60,7 @@ import { SavedObjectsServiceStart, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; -import { MetricsServiceStart } from './metrics'; +import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; import { StatusServiceSetup } from './status'; import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging'; @@ -320,8 +320,10 @@ export { OpsServerMetrics, OpsProcessMetrics, MetricsServiceSetup, + MetricsServiceStart, } from './metrics'; +export { AppCategory } from '../types'; export { DEFAULT_APP_CATEGORIES } from '../utils'; export { @@ -414,6 +416,8 @@ export interface CoreSetup = KbnServer as any; @@ -99,6 +100,7 @@ beforeEach(() => { status: statusServiceMock.createInternalSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), + metrics: metricsServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index fd3e3a694e6ae..4dc22be2a9971 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -302,6 +302,10 @@ export class LegacyService implements CoreService { logging: { configure: (config$) => setupDeps.core.logging.configure([], config$), }, + metrics: { + collectionInterval: setupDeps.core.metrics.collectionInterval, + getOpsMetrics$: setupDeps.core.metrics.getOpsMetrics$, + }, savedObjects: { setClientFactoryProvider: setupDeps.core.savedObjects.setClientFactoryProvider, addClientWrapper: setupDeps.core.savedObjects.addClientWrapper, @@ -309,6 +313,7 @@ export class LegacyService implements CoreService { getImportExportObjectLimit: setupDeps.core.savedObjects.getImportExportObjectLimit, }, status: { + isStatusPageAnonymous: setupDeps.core.status.isStatusPageAnonymous, core$: setupDeps.core.status.core$, overall$: setupDeps.core.status.overall$, set: () => { diff --git a/src/core/server/metrics/metrics_service.mock.ts b/src/core/server/metrics/metrics_service.mock.ts index caa7acc001db3..0d9e9af39317c 100644 --- a/src/core/server/metrics/metrics_service.mock.ts +++ b/src/core/server/metrics/metrics_service.mock.ts @@ -78,8 +78,8 @@ type MetricsServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { - setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), - start: jest.fn().mockReturnValue(createInternalStartContractMock()), + setup: jest.fn().mockReturnValue(createSetupContractMock()), + start: jest.fn().mockReturnValue(createStartContractMock()), stop: jest.fn(), }; return mocked; diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts index 269931d0e33ad..384a56c8dba94 100644 --- a/src/core/server/metrics/metrics_service.test.ts +++ b/src/core/server/metrics/metrics_service.test.ts @@ -106,6 +106,25 @@ describe('MetricsService', () => { `"#setup() needs to be run first"` ); }); + + it('emits the last value on each getOpsMetrics$ call', async () => { + const firstMetrics = { metric: 'first' }; + const secondMetrics = { metric: 'second' }; + mockOpsCollector.collect + .mockResolvedValueOnce(firstMetrics) + .mockResolvedValueOnce(secondMetrics); + + await metricsService.setup({ http: httpMock }); + const { getOpsMetrics$ } = await metricsService.start(); + + const firstEmission = getOpsMetrics$().pipe(take(1)).toPromise(); + jest.advanceTimersByTime(testInterval); + expect(await firstEmission).toEqual({ metric: 'first' }); + + const secondEmission = getOpsMetrics$().pipe(take(1)).toPromise(); + jest.advanceTimersByTime(testInterval); + expect(await secondEmission).toEqual({ metric: 'second' }); + }); }); describe('#stop', () => { diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts index d4696b3aa9aaf..ab58a75d49a98 100644 --- a/src/core/server/metrics/metrics_service.ts +++ b/src/core/server/metrics/metrics_service.ts @@ -37,7 +37,7 @@ export class MetricsService private readonly logger: Logger; private metricsCollector?: OpsMetricsCollector; private collectInterval?: NodeJS.Timeout; - private metrics$ = new ReplaySubject(); + private metrics$ = new ReplaySubject(1); private service?: InternalMetricsServiceSetup; constructor(private readonly coreContext: CoreContext) { diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 52dccb6880882..7e001ffe28100 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -52,6 +52,8 @@ export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_object export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { metricsServiceMock } from './metrics/metrics_service.mock'; export { renderingMock } from './rendering/rendering_service.mock'; +export { statusServiceMock } from './status/status_service.mock'; +export { contextServiceMock } from './context/context_service.mock'; export function pluginInitializerContextConfigMock(config: T) { const globalConfig: SharedGlobalConfig = { @@ -137,6 +139,7 @@ function createCoreSetupMock({ uiSettings: uiSettingsMock, auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createSetupContract(), + metrics: metricsServiceMock.createSetupContract(), getStartServices: jest .fn, object, any]>, []>() .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), @@ -173,6 +176,7 @@ function createInternalCoreSetupMock() { uiSettings: uiSettingsServiceMock.createSetupContract(), auditTrail: auditTrailServiceMock.createSetupContract(), logging: loggingServiceMock.createInternalSetupContract(), + metrics: metricsServiceMock.createInternalSetupContract(), }; return setupDeps; } @@ -182,7 +186,7 @@ function createInternalCoreStartMock() { capabilities: capabilitiesServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createInternalStart(), http: httpServiceMock.createInternalStartContract(), - metrics: metricsServiceMock.createStartContract(), + metrics: metricsServiceMock.createInternalStartContract(), savedObjects: savedObjectsServiceMock.createInternalStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), auditTrail: auditTrailServiceMock.createStartContract(), diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 6529e83b4d818..ab3f471fd7942 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -179,6 +179,10 @@ export function createPluginSetupContext( logging: { configure: (config$) => deps.logging.configure(['plugins', plugin.name], config$), }, + metrics: { + collectionInterval: deps.metrics.collectionInterval, + getOpsMetrics$: deps.metrics.getOpsMetrics$, + }, savedObjects: { setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider, addClientWrapper: deps.savedObjects.addClientWrapper, @@ -191,6 +195,7 @@ export function createPluginSetupContext( set: deps.status.plugins.set.bind(null, plugin.name), dependencies$: deps.status.plugins.getDependenciesStatus$(plugin.name), derivedStatus$: deps.status.plugins.getDerivedStatus$(plugin.name), + isStatusPageAnonymous: deps.status.isStatusPageAnonymous, }, uiSettings: { register: deps.uiSettings.register, diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 0bc03fbcf8038..67be2b56b4447 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -471,7 +471,11 @@ describe(`POST ${URL}`, () => { describe('createNewCopies enabled', () => { it('imports objects, regenerating all IDs/reference IDs present, and resetting all origin IDs', async () => { - mockUuidv4.mockReturnValueOnce('new-id-1').mockReturnValueOnce('new-id-2'); + mockUuidv4 + .mockReturnValueOnce('foo') // a uuid.v4() is generated for the request.id + .mockReturnValueOnce('foo') // another uuid.v4() is used for the request.uuid + .mockReturnValueOnce('new-id-1') + .mockReturnValueOnce('new-id-2'); savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [mockIndexPattern] }); const obj1 = { type: 'visualization', @@ -490,7 +494,6 @@ describe(`POST ${URL}`, () => { const result = await supertest(httpSetup.server.listener) .post(`${URL}?createNewCopies=true`) .set('content-Type', 'multipart/form-data; boundary=EXAMPLE') - .set('x-opaque-id', uuidv4()) // prevents src/core/server/http/http_tools.ts from using our mocked uuidv4 to generate a unique ID for this request .send( [ '--EXAMPLE', diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 352ce4c1c16eb..0e72ad2fec06c 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2477,6 +2477,33 @@ describe('SavedObjectsRepository', () => { expect(client.search).not.toHaveBeenCalled(); }); + it(`throws when namespaces is an empty array`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', namespaces: [] }) + ).rejects.toThrowError('options.namespaces cannot be an empty array'); + expect(client.search).not.toHaveBeenCalled(); + }); + + it(`throws when type is not falsy and typeToNamespacesMap is defined`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', typeToNamespacesMap: new Map() }) + ).rejects.toThrowError( + 'options.type must be an empty string when options.typeToNamespacesMap is used' + ); + expect(client.search).not.toHaveBeenCalled(); + }); + + it(`throws when type is not an empty array and typeToNamespacesMap is defined`, async () => { + const test = async (args) => { + await expect(savedObjectsRepository.find(args)).rejects.toThrowError( + 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' + ); + expect(client.search).not.toHaveBeenCalled(); + }; + await test({ type: '', typeToNamespacesMap: new Map() }); + await test({ type: '', namespaces: ['some-ns'], typeToNamespacesMap: new Map() }); + }); + it(`throws when searchFields is defined but not an array`, async () => { await expect( savedObjectsRepository.find({ type, searchFields: 'string' }) @@ -2493,7 +2520,7 @@ describe('SavedObjectsRepository', () => { it(`throws when KQL filter syntax is invalid`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], @@ -2577,38 +2604,70 @@ describe('SavedObjectsRepository', () => { const test = async (types) => { const result = await savedObjectsRepository.find({ type: types }); expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + expect(client.search).not.toHaveBeenCalled(); }; await test('unknownType'); await test(HIDDEN_TYPE); await test(['unknownType', HIDDEN_TYPE]); }); + + it(`should return empty results when attempting to find only invalid or hidden types using typeToNamespacesMap`, async () => { + const test = async (types) => { + const result = await savedObjectsRepository.find({ + typeToNamespacesMap: new Map(types.map((x) => [x, undefined])), + type: '', + namespaces: [], + }); + expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + expect(client.search).not.toHaveBeenCalled(); + }; + + await test(['unknownType']); + await test([HIDDEN_TYPE]); + await test(['unknownType', HIDDEN_TYPE]); + }); }); describe('search dsl', () => { - it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { + const commonOptions = { + type: [type], // cannot be used when `typeToNamespacesMap` is present + namespaces: [namespace], // cannot be used when `typeToNamespacesMap` is present + search: 'foo*', + searchFields: ['foo'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + kueryNode: undefined, + }; + + it(`passes mappings, registry, and search options to getSearchDsl`, async () => { + await findSuccess(commonOptions, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, commonOptions); + }); + + it(`accepts typeToNamespacesMap`, async () => { const relevantOpts = { - namespaces: [namespace], - search: 'foo*', - searchFields: ['foo'], - type: [type], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - kueryNode: undefined, + ...commonOptions, + type: '', + namespaces: [], + typeToNamespacesMap: new Map([[type, [namespace]]]), // can only be used when `type` is falsy and `namespaces` is an empty array }; await findSuccess(relevantOpts, namespace); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + type: [type], + }); }); it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], @@ -2649,7 +2708,7 @@ describe('SavedObjectsRepository', () => { it(`accepts KQL KueryNode filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 125f97e7feb11..a83c86e585628 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -67,7 +67,7 @@ import { } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; -import { SavedObjectsUtils } from './utils'; +import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -693,37 +693,51 @@ export class SavedObjectsRepository { * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ - async find({ - search, - defaultSearchOperator = 'OR', - searchFields, - rootSearchFields, - hasReference, - page = 1, - perPage = 20, - sortField, - sortOrder, - fields, - namespaces, - type, - filter, - preference, - }: SavedObjectsFindOptions): Promise> { - if (!type) { + async find(options: SavedObjectsFindOptions): Promise> { + const { + search, + defaultSearchOperator = 'OR', + searchFields, + rootSearchFields, + hasReference, + page = FIND_DEFAULT_PAGE, + perPage = FIND_DEFAULT_PER_PAGE, + sortField, + sortOrder, + fields, + namespaces, + type, + typeToNamespacesMap, + filter, + preference, + } = options; + + if (!type && !typeToNamespacesMap) { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.type must be a string or an array of strings' ); + } else if (namespaces?.length === 0 && !typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } else if (type && typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.type must be an empty string when options.typeToNamespacesMap is used' + ); + } else if ((!namespaces || namespaces?.length) && typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' + ); } - const types = Array.isArray(type) ? type : [type]; + const types = type + ? Array.isArray(type) + ? type + : [type] + : Array.from(typeToNamespacesMap!.keys()); const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { - return { - page, - per_page: perPage, - total: 0, - saved_objects: [], - }; + return SavedObjectsUtils.createEmptyFindResponse(options); } if (searchFields && !Array.isArray(searchFields)) { @@ -766,6 +780,7 @@ export class SavedObjectsRepository { sortField, sortOrder, namespaces, + typeToNamespacesMap, hasReference, kueryNode, }), diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 4adc92df31805..e13c67a720400 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -50,6 +50,40 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( .filter((x) => x.length) // exclude empty set .map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it +const createTypeClause = (type: string, namespaces?: string[]) => { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (registry.isSingleNamespace(type)) { + const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; + const should: any = []; + if (nonDefaultNamespaces.length > 0) { + should.push({ terms: { namespace: nonDefaultNamespaces } }); + } + if (namespaces?.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + return { + bool: { + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; +}; + /** * Note: these tests cases are defined in the order they appear in the source code, for readability's sake */ @@ -198,40 +232,6 @@ describe('#getQueryParams', () => { }); describe('`namespaces` parameter', () => { - const createTypeClause = (type: string, namespaces?: string[]) => { - if (registry.isMultiNamespace(type)) { - return { - bool: { - must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), - must_not: [{ exists: { field: 'namespace' } }], - }, - }; - } else if (registry.isSingleNamespace(type)) { - const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; - const should: any = []; - if (nonDefaultNamespaces.length > 0) { - should.push({ terms: { namespace: nonDefaultNamespaces } }); - } - if (namespaces?.includes('default')) { - should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); - } - return { - bool: { - must: [{ term: { type } }], - should: expect.arrayContaining(should), - minimum_should_match: 1, - must_not: [{ exists: { field: 'namespaces' } }], - }, - }; - } - // isNamespaceAgnostic - return { - bool: expect.objectContaining({ - must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], - }), - }; - }; - const expectResult = (result: Result, ...typeClauses: any) => { expect(result.query.bool.filter).toEqual( expect.arrayContaining([ @@ -281,6 +281,37 @@ describe('#getQueryParams', () => { test(['default']); }); }); + + describe('`typeToNamespacesMap` parameter', () => { + const expectResult = (result: Result, ...typeClauses: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) }, + ]) + ); + }; + + it('supersedes `type` and `namespaces` parameters', () => { + const result = getQueryParams({ + mappings, + registry, + type: ['pending', 'saved', 'shared', 'global'], + namespaces: ['foo', 'bar', 'default'], + typeToNamespacesMap: new Map([ + ['pending', ['foo']], // 'pending' is only authorized in the 'foo' namespace + // 'saved' is not authorized in any namespaces + ['shared', ['bar', 'default']], // 'shared' is only authorized in the 'bar' and 'default' namespaces + ['global', ['foo', 'bar', 'default']], // 'global' is authorized in all namespaces (which are ignored anyway) + ]), + }); + expectResult( + result, + createTypeClause('pending', ['foo']), + createTypeClause('shared', ['bar', 'default']), + createTypeClause('global') + ); + }); + }); }); describe('search clause (query.bool.must.simple_query_string)', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 642d51c70766e..eaddc05fa921c 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -129,6 +129,7 @@ interface QueryParams { registry: ISavedObjectTypeRegistry; namespaces?: string[]; type?: string | string[]; + typeToNamespacesMap?: Map; search?: string; searchFields?: string[]; rootSearchFields?: string[]; @@ -145,6 +146,7 @@ export function getQueryParams({ registry, namespaces, type, + typeToNamespacesMap, search, searchFields, rootSearchFields, @@ -152,7 +154,10 @@ export function getQueryParams({ hasReference, kueryNode, }: QueryParams) { - const types = getTypes(mappings, type); + const types = getTypes( + mappings, + typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type + ); // A de-duplicated set of namespaces makes for a more effecient query. // @@ -163,9 +168,12 @@ export function getQueryParams({ // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 - const normalizedNamespaces = namespaces - ? Array.from(new Set(namespaces.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x)))) - : undefined; + const normalizeNamespaces = (namespacesToNormalize?: string[]) => + namespacesToNormalize + ? Array.from( + new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) + ) + : undefined; const bool: any = { filter: [ @@ -197,9 +205,12 @@ export function getQueryParams({ }, ] : undefined, - should: types.map((shouldType) => - getClauseForType(registry, normalizedNamespaces, shouldType) - ), + should: types.map((shouldType) => { + const normalizedNamespaces = normalizeNamespaces( + typeToNamespacesMap ? typeToNamespacesMap.get(shouldType) : namespaces + ); + return getClauseForType(registry, normalizedNamespaces, shouldType); + }), minimum_should_match: 1, }, }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 62e629ad33cc8..7276e505bce7d 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,10 +57,11 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { const opts = { namespaces: ['foo-namespace'], type: 'foo', + typeToNamespacesMap: new Map(), search: 'bar', searchFields: ['baz'], rootSearchFields: ['qux'], @@ -78,6 +79,7 @@ describe('getSearchDsl', () => { registry, namespaces: opts.namespaces, type: opts.type, + typeToNamespacesMap: opts.typeToNamespacesMap, search: opts.search, searchFields: opts.searchFields, rootSearchFields: opts.rootSearchFields, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index aa79a10b2a9be..858770579fb9e 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -35,6 +35,7 @@ interface GetSearchDslOptions { sortField?: string; sortOrder?: string; namespaces?: string[]; + typeToNamespacesMap?: Map; hasReference?: { type: string; id: string; @@ -56,6 +57,7 @@ export function getSearchDsl( sortField, sortOrder, namespaces, + typeToNamespacesMap, hasReference, kueryNode, } = options; @@ -74,6 +76,7 @@ export function getSearchDsl( registry, namespaces, type, + typeToNamespacesMap, search, searchFields, rootSearchFields, diff --git a/src/core/server/saved_objects/service/lib/utils.test.ts b/src/core/server/saved_objects/service/lib/utils.test.ts index ea4fa68242bea..ac06ca9275783 100644 --- a/src/core/server/saved_objects/service/lib/utils.test.ts +++ b/src/core/server/saved_objects/service/lib/utils.test.ts @@ -17,10 +17,11 @@ * under the License. */ +import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsUtils } from './utils'; describe('SavedObjectsUtils', () => { - const { namespaceIdToString, namespaceStringToId } = SavedObjectsUtils; + const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils; describe('#namespaceIdToString', () => { it('converts `undefined` to default namespace string', () => { @@ -54,4 +55,26 @@ describe('SavedObjectsUtils', () => { test(''); }); }); + + describe('#createEmptyFindResponse', () => { + it('returns expected result', () => { + const options = {} as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options)).toEqual({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + }); + + it('handles `page` field', () => { + const options = { page: 42 } as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options).page).toEqual(42); + }); + + it('handles `perPage` field', () => { + const options = { perPage: 42 } as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options).per_page).toEqual(42); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 6101ad57cc401..3efe8614da1d7 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -17,7 +17,12 @@ * under the License. */ +import { SavedObjectsFindOptions } from '../../types'; +import { SavedObjectsFindResponse } from '..'; + export const DEFAULT_NAMESPACE_STRING = 'default'; +export const FIND_DEFAULT_PAGE = 1; +export const FIND_DEFAULT_PER_PAGE = 20; /** * @public @@ -50,4 +55,17 @@ export class SavedObjectsUtils { return namespace !== DEFAULT_NAMESPACE_STRING ? namespace : undefined; }; + + /** + * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. + */ + public static createEmptyFindResponse = ({ + page = FIND_DEFAULT_PAGE, + perPage = FIND_DEFAULT_PER_PAGE, + }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ + page, + per_page: perPage, + total: 0, + saved_objects: [], + }); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 1885f5ec50139..01128e4f8cf51 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -89,6 +89,14 @@ export interface SavedObjectsFindOptions { defaultSearchOperator?: 'AND' | 'OR'; filter?: string | KueryNode; namespaces?: string[]; + /** + * This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved + * object client wrapper. + * If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. + * Any types that are not included in this map will be excluded entirely. + * If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + */ + typeToNamespacesMap?: Map; /** An optional ES preference value to be used for the query **/ preference?: string; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ef5eb55632ed1..8a764d9bd2f66 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -164,6 +164,15 @@ import { UpdateDocumentByQueryParams } from 'elasticsearch'; import { UpdateDocumentParams } from 'elasticsearch'; import { Url } from 'url'; +// @public +export interface AppCategory { + ariaLabel?: string; + euiIconType?: string; + id: string; + label: string; + order?: number; +} + // Warning: (ae-forgotten-export) The symbol "ConsoleAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "FileAppenderConfig" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "LegacyAppenderConfig" needs to be exported by the entry point index.d.ts @@ -416,6 +425,8 @@ export interface CoreSetup; +export const DEFAULT_APP_CATEGORIES: Record; // @public (undocumented) export interface DeleteDocumentResponse { @@ -965,6 +943,7 @@ export class KibanaRequest Observable; } +// @public +export type MetricsServiceStart = MetricsServiceSetup; + // @public @deprecated (undocumented) export type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; @@ -2195,6 +2177,7 @@ export interface SavedObjectsFindOptions { sortOrder?: string; // (undocumented) type: string | string[]; + typeToNamespacesMap?: Map; } // @public @@ -2406,7 +2389,7 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) - find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2514,6 +2497,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; static namespaceIdToString: (namespace?: string | undefined) => string; static namespaceStringToId: (namespace: string) => string | undefined; } @@ -2589,18 +2573,22 @@ export const ServiceStatusLevels: Readonly<{ available: Readonly<{ toString: () => "available"; valueOf: () => 0; + toJSON: () => "available"; }>; degraded: Readonly<{ toString: () => "degraded"; valueOf: () => 1; + toJSON: () => "degraded"; }>; unavailable: Readonly<{ toString: () => "unavailable"; valueOf: () => 2; + toJSON: () => "unavailable"; }>; critical: Readonly<{ toString: () => "critical"; valueOf: () => 3; + toJSON: () => "critical"; }>; }>; @@ -2676,6 +2664,7 @@ export interface StatusServiceSetup { dependencies$: Observable>; // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "StatusSetup" derivedStatus$: Observable; + isStatusPageAnonymous: () => boolean; overall$: Observable; set(status$: Observable): void; } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index c689e2cb70cc9..8502f563cb0c2 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -152,12 +152,15 @@ export class Server { savedObjects: savedObjectsSetup, }); - await this.metrics.setup({ http: httpSetup }); + const metricsSetup = await this.metrics.setup({ http: httpSetup }); const statusSetup = await this.status.setup({ elasticsearch: elasticsearchServiceSetup, pluginDependencies: pluginTree.asNames, savedObjects: savedObjectsSetup, + environment: environmentSetup, + http: httpSetup, + metrics: metricsSetup, }); const renderingSetup = await this.rendering.setup({ @@ -189,6 +192,7 @@ export class Server { httpResources: httpResourcesSetup, auditTrail: auditTrailSetup, logging: loggingSetup, + metrics: metricsSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); diff --git a/src/core/server/status/legacy_status.test.ts b/src/core/server/status/legacy_status.test.ts new file mode 100644 index 0000000000000..e3e55442cabd2 --- /dev/null +++ b/src/core/server/status/legacy_status.test.ts @@ -0,0 +1,114 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ServiceStatus, ServiceStatusLevels } from './types'; +import { calculateLegacyStatus } from './legacy_status'; + +const available: ServiceStatus = { level: ServiceStatusLevels.available, summary: 'Available' }; +const degraded: ServiceStatus = { + level: ServiceStatusLevels.degraded, + summary: 'This is degraded!', +}; +const unavailable: ServiceStatus = { + level: ServiceStatusLevels.unavailable, + summary: 'This is unavailable!', +}; +const critical: ServiceStatus = { + level: ServiceStatusLevels.critical, + summary: 'This is critical!', +}; + +describe('calculateLegacyStatus', () => { + it('translates the overall status to the legacy format', () => { + const legacyStatus = calculateLegacyStatus({ + overall: available, + core: {} as any, + plugins: {}, + versionWithoutSnapshot: '1.1.1', + }); + + expect(legacyStatus.overall).toEqual({ + state: 'green', + title: 'Green', + nickname: 'Looking good', + icon: 'success', + uiColor: 'secondary', + since: expect.any(String), + }); + }); + + it('combines core and plugins statuses into statuses array in legacy format', () => { + const legacyStatus = calculateLegacyStatus({ + overall: available, + core: { + elasticsearch: degraded, + savedObjects: critical, + }, + plugins: { + a: available, + b: unavailable, + c: degraded, + }, + versionWithoutSnapshot: '1.1.1', + }); + + expect(legacyStatus.statuses).toEqual([ + { + icon: 'warning', + id: 'core:elasticsearch@1.1.1', + message: 'This is degraded!', + since: expect.any(String), + state: 'yellow', + uiColor: 'warning', + }, + { + icon: 'danger', + id: 'core:savedObjects@1.1.1', + message: 'This is critical!', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + { + icon: 'success', + id: 'plugin:a@1.1.1', + message: 'Available', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'danger', + id: 'plugin:b@1.1.1', + message: 'This is unavailable!', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + { + icon: 'warning', + id: 'plugin:c@1.1.1', + message: 'This is degraded!', + since: expect.any(String), + state: 'yellow', + uiColor: 'warning', + }, + ]); + }); +}); diff --git a/src/core/server/status/legacy_status.ts b/src/core/server/status/legacy_status.ts new file mode 100644 index 0000000000000..41777ae97c3da --- /dev/null +++ b/src/core/server/status/legacy_status.ts @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { pick } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { deepFreeze } from '@kbn/std'; + +import { ServiceStatusLevels, ServiceStatus, CoreStatus } from './types'; +import { PluginName } from '../plugins'; + +interface Deps { + overall: ServiceStatus; + core: CoreStatus; + plugins: Record; + versionWithoutSnapshot: string; +} + +export interface LegacyStatusInfo { + overall: LegacyStatusOverall; + statuses: StatusComponentHttp[]; +} + +interface LegacyStatusOverall { + state: LegacyStatusState; + title: string; + nickname: string; + uiColor: LegacyStatusUiColor; + /** ISO-8601 date string w/o timezone */ + since: string; + icon?: string; +} + +type LegacyStatusState = 'green' | 'yellow' | 'red'; +type LegacyStatusIcon = 'danger' | 'warning' | 'success'; +type LegacyStatusUiColor = 'secondary' | 'warning' | 'danger'; + +interface LegacyStateAttr { + id: LegacyStatusState; + state: LegacyStatusState; + title: string; + icon: LegacyStatusIcon; + uiColor: LegacyStatusUiColor; + nickname: string; +} + +export const calculateLegacyStatus = ({ + core, + overall, + plugins, + versionWithoutSnapshot, +}: Deps): LegacyStatusInfo => { + const since = new Date().toISOString(); + const overallLegacy: LegacyStatusOverall = { + since, + ...pick(STATUS_LEVEL_LEGACY_ATTRS[overall.level.toString()], [ + 'state', + 'title', + 'nickname', + 'icon', + 'uiColor', + ]), + }; + const coreStatuses = Object.entries(core).map(([serviceName, s]) => + serviceStatusToHttpComponent(`core:${serviceName}@${versionWithoutSnapshot}`, s, since) + ); + const pluginStatuses = Object.entries(plugins).map(([pluginName, s]) => + serviceStatusToHttpComponent(`plugin:${pluginName}@${versionWithoutSnapshot}`, s, since) + ); + + const componentStatuses: StatusComponentHttp[] = [...coreStatuses, ...pluginStatuses]; + + return { + overall: overallLegacy, + statuses: componentStatuses, + }; +}; + +interface StatusComponentHttp { + id: string; + state: LegacyStatusState; + message: string; + uiColor: LegacyStatusUiColor; + icon: string; + since: string; +} + +const serviceStatusToHttpComponent = ( + serviceName: string, + status: ServiceStatus, + since: string +): StatusComponentHttp => ({ + id: serviceName, + message: status.summary, + since, + ...serviceStatusAttrs(status), +}); + +const serviceStatusAttrs = (status: ServiceStatus) => + pick(STATUS_LEVEL_LEGACY_ATTRS[status.level.toString()], ['state', 'icon', 'uiColor']); + +const STATUS_LEVEL_LEGACY_ATTRS = deepFreeze>({ + [ServiceStatusLevels.critical.toString()]: { + id: 'red', + state: 'red', + title: i18n.translate('core.status.redTitle', { + defaultMessage: 'Red', + }), + icon: 'danger', + uiColor: 'danger', + nickname: 'Danger Will Robinson! Danger!', + }, + [ServiceStatusLevels.unavailable.toString()]: { + id: 'red', + state: 'red', + title: i18n.translate('core.status.redTitle', { + defaultMessage: 'Red', + }), + icon: 'danger', + uiColor: 'danger', + nickname: 'Danger Will Robinson! Danger!', + }, + [ServiceStatusLevels.degraded.toString()]: { + id: 'yellow', + state: 'yellow', + title: i18n.translate('core.status.yellowTitle', { + defaultMessage: 'Yellow', + }), + icon: 'warning', + uiColor: 'warning', + nickname: "I'll be back", + }, + [ServiceStatusLevels.available.toString()]: { + id: 'green', + state: 'green', + title: i18n.translate('core.status.greenTitle', { + defaultMessage: 'Green', + }), + icon: 'success', + uiColor: 'secondary', + nickname: 'Looking good', + }, +}); diff --git a/src/legacy/server/status/routes/index.js b/src/core/server/status/routes/index.ts similarity index 87% rename from src/legacy/server/status/routes/index.js rename to src/core/server/status/routes/index.ts index 12736a76d4915..db2e8daf0b9ac 100644 --- a/src/legacy/server/status/routes/index.js +++ b/src/core/server/status/routes/index.ts @@ -17,5 +17,4 @@ * under the License. */ -export { registerStatusApi } from './api/register_status'; -export { registerStatsApi } from './api/register_stats'; +export { registerStatusRoute } from './status'; diff --git a/src/core/server/status/routes/integration_tests/status.test.ts b/src/core/server/status/routes/integration_tests/status.test.ts new file mode 100644 index 0000000000000..e0f86342e3a8a --- /dev/null +++ b/src/core/server/status/routes/integration_tests/status.test.ts @@ -0,0 +1,322 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import supertest from 'supertest'; +import { omit } from 'lodash'; + +import { createCoreContext, createHttpServer } from '../../../http/test_utils'; +import { ContextService } from '../../../context'; +import { metricsServiceMock } from '../../../metrics/metrics_service.mock'; +import { MetricsServiceSetup } from '../../../metrics'; +import { HttpService, InternalHttpServiceSetup } from '../../../http'; + +import { registerStatusRoute } from '../status'; +import { ServiceStatus, ServiceStatusLevels } from '../../types'; +import { statusServiceMock } from '../../status_service.mock'; + +const coreId = Symbol('core'); + +describe('GET /api/status', () => { + let server: HttpService; + let httpSetup: InternalHttpServiceSetup; + let metrics: jest.Mocked; + + const setupServer = async ({ allowAnonymous = true }: { allowAnonymous?: boolean } = {}) => { + const coreContext = createCoreContext({ coreId }); + const contextService = new ContextService(coreContext); + + server = createHttpServer(coreContext); + httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), + }); + + metrics = metricsServiceMock.createSetupContract(); + const status = statusServiceMock.createSetupContract(); + const pluginsStatus$ = new BehaviorSubject>({ + a: { level: ServiceStatusLevels.available, summary: 'a is available' }, + b: { level: ServiceStatusLevels.degraded, summary: 'b is degraded' }, + c: { level: ServiceStatusLevels.unavailable, summary: 'c is unavailable' }, + d: { level: ServiceStatusLevels.critical, summary: 'd is critical' }, + }); + + const router = httpSetup.createRouter(''); + registerStatusRoute({ + router, + config: { + allowAnonymous, + packageInfo: { + branch: 'xbranch', + buildNum: 1234, + buildSha: 'xsha', + dist: true, + version: '9.9.9-SNAPSHOT', + }, + serverName: 'xkibana', + uuid: 'xxxx-xxxxx', + }, + metrics, + status: { + overall$: status.overall$, + core$: status.core$, + plugins$: pluginsStatus$, + }, + }); + + // Register dummy auth provider for testing auth + httpSetup.registerAuth((req, res, auth) => { + if (req.headers.authorization === 'let me in') { + return auth.authenticated(); + } else { + return auth.notHandled(); + } + }); + + await server.start(); + }; + + afterEach(async () => { + await server.stop(); + }); + + describe('allowAnonymous: false', () => { + it('rejects requests with no credentials', async () => { + await setupServer({ allowAnonymous: false }); + await supertest(httpSetup.server.listener).get('/api/status').expect(401); + }); + + it('rejects requests with bad credentials', async () => { + await setupServer({ allowAnonymous: false }); + await supertest(httpSetup.server.listener) + .get('/api/status') + .set('Authorization', 'fake creds') + .expect(401); + }); + + it('accepts authenticated requests', async () => { + await setupServer({ allowAnonymous: false }); + await supertest(httpSetup.server.listener) + .get('/api/status') + .set('Authorization', 'let me in') + .expect(200); + }); + }); + + it('returns basic server info & metrics', async () => { + await setupServer(); + const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200); + + expect(result.body.name).toEqual('xkibana'); + expect(result.body.uuid).toEqual('xxxx-xxxxx'); + expect(result.body.version).toEqual({ + number: '9.9.9', + build_hash: 'xsha', + build_number: 1234, + build_snapshot: true, + }); + const metricsMockValue = await metrics.getOpsMetrics$().pipe(first()).toPromise(); + expect(result.body.metrics).toEqual({ + last_updated: expect.any(String), + collection_interval_in_millis: metrics.collectionInterval, + ...omit(metricsMockValue, ['collected_at']), + requests: { + ...metricsMockValue.requests, + status_codes: metricsMockValue.requests.statusCodes, + }, + }); + }); + + describe('legacy status format', () => { + it('returns legacy status format when no query params provided', async () => { + await setupServer(); + const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200); + expect(result.body.status).toEqual({ + overall: { + icon: 'success', + nickname: 'Looking good', + since: expect.any(String), + state: 'green', + title: 'Green', + uiColor: 'secondary', + }, + statuses: [ + { + icon: 'success', + id: 'core:elasticsearch@9.9.9', + message: 'Service is working', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'success', + id: 'core:savedObjects@9.9.9', + message: 'Service is working', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'success', + id: 'plugin:a@9.9.9', + message: 'a is available', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'warning', + id: 'plugin:b@9.9.9', + message: 'b is degraded', + since: expect.any(String), + state: 'yellow', + uiColor: 'warning', + }, + { + icon: 'danger', + id: 'plugin:c@9.9.9', + message: 'c is unavailable', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + { + icon: 'danger', + id: 'plugin:d@9.9.9', + message: 'd is critical', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + ], + }); + }); + + it('returns legacy status format when v8format=false is provided', async () => { + await setupServer(); + const result = await supertest(httpSetup.server.listener) + .get('/api/status?v8format=false') + .expect(200); + expect(result.body.status).toEqual({ + overall: { + icon: 'success', + nickname: 'Looking good', + since: expect.any(String), + state: 'green', + title: 'Green', + uiColor: 'secondary', + }, + statuses: [ + { + icon: 'success', + id: 'core:elasticsearch@9.9.9', + message: 'Service is working', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'success', + id: 'core:savedObjects@9.9.9', + message: 'Service is working', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'success', + id: 'plugin:a@9.9.9', + message: 'a is available', + since: expect.any(String), + state: 'green', + uiColor: 'secondary', + }, + { + icon: 'warning', + id: 'plugin:b@9.9.9', + message: 'b is degraded', + since: expect.any(String), + state: 'yellow', + uiColor: 'warning', + }, + { + icon: 'danger', + id: 'plugin:c@9.9.9', + message: 'c is unavailable', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + { + icon: 'danger', + id: 'plugin:d@9.9.9', + message: 'd is critical', + since: expect.any(String), + state: 'red', + uiColor: 'danger', + }, + ], + }); + }); + }); + + describe('v8format', () => { + it('returns new status format when v8format=true is provided', async () => { + await setupServer(); + const result = await supertest(httpSetup.server.listener) + .get('/api/status?v8format=true') + .expect(200); + expect(result.body.status).toEqual({ + core: { + elasticsearch: { + level: 'available', + summary: 'Service is working', + }, + savedObjects: { + level: 'available', + summary: 'Service is working', + }, + }, + overall: { + level: 'available', + summary: 'Service is working', + }, + plugins: { + a: { + level: 'available', + summary: 'a is available', + }, + b: { + level: 'degraded', + summary: 'b is degraded', + }, + c: { + level: 'unavailable', + summary: 'c is unavailable', + }, + d: { + level: 'critical', + summary: 'd is critical', + }, + }, + }); + }); + }); +}); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts new file mode 100644 index 0000000000000..da01a44095529 --- /dev/null +++ b/src/core/server/status/routes/status.ts @@ -0,0 +1,177 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Observable, combineLatest, ReplaySubject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { schema } from '@kbn/config-schema'; + +import { IRouter } from '../../http'; +import { MetricsServiceSetup } from '../../metrics'; +import { ServiceStatus, CoreStatus } from '../types'; +import { PluginName } from '../../plugins'; +import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status'; +import { PackageInfo } from '../../config'; + +const SNAPSHOT_POSTFIX = /-SNAPSHOT$/; + +interface Deps { + router: IRouter; + config: { + allowAnonymous: boolean; + packageInfo: PackageInfo; + serverName: string; + uuid: string; + }; + metrics: MetricsServiceSetup; + status: { + overall$: Observable; + core$: Observable; + plugins$: Observable>; + }; +} + +interface StatusInfo { + overall: ServiceStatus; + core: CoreStatus; + plugins: Record; +} + +interface StatusHttpBody { + name: string; + uuid: string; + version: { + number: string; + build_hash: string; + build_number: number; + build_snapshot: boolean; + }; + status: StatusInfo | LegacyStatusInfo; + metrics: { + /** ISO-8601 date string w/o timezone */ + last_updated: string; + collection_interval_in_millis: number; + process: { + memory: { + heap: { + total_in_bytes: number; + used_in_bytes: number; + size_limit: number; + }; + resident_set_size_in_bytes: number; + }; + event_loop_delay: number; + pid: number; + uptime_in_millis: number; + }; + os: { + load: Record; + memory: { + total_in_bytes: number; + used_in_bytes: number; + free_in_bytes: number; + }; + uptime_in_millis: number; + platform: string; + platformRelease: string; + }; + response_times: { + max_in_millis: number; + }; + requests: { + total: number; + disconnects: number; + statusCodes: Record; + status_codes: Record; + }; + concurrent_connections: number; + }; +} + +export const registerStatusRoute = ({ router, config, metrics, status }: Deps) => { + // Since the status.plugins$ observable is not subscribed to elsewhere, we need to subscribe it here to eagerly load + // the plugins status when Kibana starts up so this endpoint responds quickly on first boot. + const combinedStatus$ = new ReplaySubject< + [ServiceStatus, CoreStatus, Record>] + >(1); + combineLatest([status.overall$, status.core$, status.plugins$]).subscribe(combinedStatus$); + + router.get( + { + path: '/api/status', + options: { + authRequired: !config.allowAnonymous, + tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page + }, + validate: { + query: schema.object({ + v8format: schema.boolean({ defaultValue: false }), + }), + }, + }, + async (context, req, res) => { + const { version, buildSha, buildNum } = config.packageInfo; + const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, ''); + const [overall, core, plugins] = await combinedStatus$.pipe(first()).toPromise(); + + let statusInfo: StatusInfo | LegacyStatusInfo; + if (req.query?.v8format) { + statusInfo = { + overall, + core, + plugins, + }; + } else { + statusInfo = calculateLegacyStatus({ + overall, + core, + plugins, + versionWithoutSnapshot, + }); + } + + const lastMetrics = await metrics.getOpsMetrics$().pipe(first()).toPromise(); + + const body: StatusHttpBody = { + name: config.serverName, + uuid: config.uuid, + version: { + number: versionWithoutSnapshot, + build_hash: buildSha, + build_number: buildNum, + build_snapshot: SNAPSHOT_POSTFIX.test(version), + }, + status: statusInfo, + metrics: { + last_updated: lastMetrics.collected_at.toISOString(), + collection_interval_in_millis: metrics.collectionInterval, + os: lastMetrics.os, + process: lastMetrics.process, + response_times: lastMetrics.response_times, + concurrent_connections: lastMetrics.concurrent_connections, + requests: { + ...lastMetrics.requests, + status_codes: lastMetrics.requests.statusCodes, + }, + }, + }; + + return res.ok({ body }); + } + ); +}; diff --git a/src/core/server/status/status_service.mock.ts b/src/core/server/status/status_service.mock.ts index 930ee2970cf55..0ee2d03229a78 100644 --- a/src/core/server/status/status_service.mock.ts +++ b/src/core/server/status/status_service.mock.ts @@ -43,6 +43,7 @@ const createSetupContractMock = () => { set: jest.fn(), dependencies$: new BehaviorSubject({}), derivedStatus$: new BehaviorSubject(available), + isStatusPageAnonymous: jest.fn().mockReturnValue(false), }; return setupContract; diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index dcb1e0a559f5d..afacaff044b6f 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -24,6 +24,9 @@ import { StatusService } from './status_service'; import { first } from 'rxjs/operators'; import { mockCoreContext } from '../core_context.mock'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; +import { environmentServiceMock } from '../environment/environment_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { metricsServiceMock } from '../metrics/metrics_service.mock'; expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); @@ -44,18 +47,36 @@ describe('StatusService', () => { summary: 'This is degraded!', }; + type SetupDeps = Parameters[0]; + const setupDeps = (overrides: Partial): SetupDeps => { + return { + elasticsearch: { + status$: of(available), + }, + savedObjects: { + status$: of(available), + }, + pluginDependencies: new Map(), + environment: environmentServiceMock.createSetupContract(), + http: httpServiceMock.createInternalSetupContract(), + metrics: metricsServiceMock.createInternalSetupContract(), + ...overrides, + }; + }; + describe('setup', () => { describe('core$', () => { it('rolls up core status observables into single observable', async () => { - const setup = await service.setup({ - elasticsearch: { - status$: of(available), - }, - savedObjects: { - status$: of(degraded), - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(available), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); expect(await setup.core$.pipe(first()).toPromise()).toEqual({ elasticsearch: available, savedObjects: degraded, @@ -63,15 +84,16 @@ describe('StatusService', () => { }); it('replays last event', async () => { - const setup = await service.setup({ - elasticsearch: { - status$: of(available), - }, - savedObjects: { - status$: of(degraded), - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(available), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); const subResult1 = await setup.core$.pipe(first()).toPromise(); const subResult2 = await setup.core$.pipe(first()).toPromise(); const subResult3 = await setup.core$.pipe(first()).toPromise(); @@ -92,15 +114,16 @@ describe('StatusService', () => { it('does not emit duplicate events', async () => { const elasticsearch$ = new BehaviorSubject(available); const savedObjects$ = new BehaviorSubject(degraded); - const setup = await service.setup({ - elasticsearch: { - status$: elasticsearch$, - }, - savedObjects: { - status$: savedObjects$, - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: elasticsearch$, + }, + savedObjects: { + status$: savedObjects$, + }, + }) + ); const statusUpdates: CoreStatus[] = []; const subscription = setup.core$.subscribe((status) => statusUpdates.push(status)); @@ -155,15 +178,16 @@ describe('StatusService', () => { describe('overall$', () => { it('exposes an overall summary', async () => { - const setup = await service.setup({ - elasticsearch: { - status$: of(degraded), - }, - savedObjects: { - status$: of(degraded), - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(degraded), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, summary: '[2] services are degraded', @@ -171,15 +195,16 @@ describe('StatusService', () => { }); it('replays last event', async () => { - const setup = await service.setup({ - elasticsearch: { - status$: of(degraded), - }, - savedObjects: { - status$: of(degraded), - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: of(degraded), + }, + savedObjects: { + status$: of(degraded), + }, + }) + ); const subResult1 = await setup.overall$.pipe(first()).toPromise(); const subResult2 = await setup.overall$.pipe(first()).toPromise(); const subResult3 = await setup.overall$.pipe(first()).toPromise(); @@ -200,15 +225,16 @@ describe('StatusService', () => { it('does not emit duplicate events', async () => { const elasticsearch$ = new BehaviorSubject(available); const savedObjects$ = new BehaviorSubject(degraded); - const setup = await service.setup({ - elasticsearch: { - status$: elasticsearch$, - }, - savedObjects: { - status$: savedObjects$, - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: elasticsearch$, + }, + savedObjects: { + status$: savedObjects$, + }, + }) + ); const statusUpdates: ServiceStatus[] = []; const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status)); @@ -256,15 +282,16 @@ describe('StatusService', () => { it('debounces events in quick succession', async () => { const savedObjects$ = new BehaviorSubject(available); - const setup = await service.setup({ - elasticsearch: { - status$: new BehaviorSubject(available), - }, - savedObjects: { - status$: savedObjects$, - }, - pluginDependencies: new Map(), - }); + const setup = await service.setup( + setupDeps({ + elasticsearch: { + status$: new BehaviorSubject(available), + }, + savedObjects: { + status$: savedObjects$, + }, + }) + ); const statusUpdates: ServiceStatus[] = []; const subscription = setup.overall$.subscribe((status) => statusUpdates.push(status)); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 8fe65eddb61d3..9acf93f2f8197 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Observable, combineLatest } from 'rxjs'; +import { Observable, combineLatest, Subscription } from 'rxjs'; import { map, distinctUntilChanged, shareReplay, take, debounceTime } from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; @@ -25,8 +25,12 @@ import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { InternalElasticsearchServiceSetup } from '../elasticsearch'; +import { InternalHttpServiceSetup } from '../http'; import { InternalSavedObjectsServiceSetup } from '../saved_objects'; import { PluginName } from '../plugins'; +import { InternalMetricsServiceSetup } from '../metrics'; +import { registerStatusRoute } from './routes'; +import { InternalEnvironmentServiceSetup } from '../environment'; import { config, StatusConfigType } from './status_config'; import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types'; @@ -35,7 +39,10 @@ import { PluginsStatusService } from './plugins_status'; interface SetupDeps { elasticsearch: Pick; + environment: InternalEnvironmentServiceSetup; pluginDependencies: ReadonlyMap; + http: InternalHttpServiceSetup; + metrics: InternalMetricsServiceSetup; savedObjects: Pick; } @@ -44,13 +51,21 @@ export class StatusService implements CoreService { private readonly config$: Observable; private pluginsStatus?: PluginsStatusService; + private overallSubscription?: Subscription; - constructor(coreContext: CoreContext) { + constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('status'); this.config$ = coreContext.configService.atPath(config.path); } - public async setup({ elasticsearch, pluginDependencies, savedObjects }: SetupDeps) { + public async setup({ + elasticsearch, + pluginDependencies, + http, + metrics, + savedObjects, + environment, + }: SetupDeps) { const statusConfig = await this.config$.pipe(take(1)).toPromise(); const core$ = this.setupCoreStatus({ elasticsearch, savedObjects }); this.pluginsStatus = new PluginsStatusService({ core$, pluginDependencies }); @@ -73,6 +88,26 @@ export class StatusService implements CoreService { shareReplay(1) ); + // Create an unused subscription to ensure all underlying lazy observables are started. + this.overallSubscription = overall$.subscribe(); + + const router = http.createRouter(''); + registerStatusRoute({ + router, + config: { + allowAnonymous: statusConfig.allowAnonymous, + packageInfo: this.coreContext.env.packageInfo, + serverName: http.getServerInfo().name, + uuid: environment.instanceUuid, + }, + metrics, + status: { + overall$, + plugins$: this.pluginsStatus.getAll$(), + core$, + }, + }); + return { core$, overall$, @@ -87,7 +122,12 @@ export class StatusService implements CoreService { public start() {} - public stop() {} + public stop() { + if (this.overallSubscription) { + this.overallSubscription.unsubscribe(); + this.overallSubscription = undefined; + } + } private setupCoreStatus({ elasticsearch, diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 9fa33a8c6d40c..8efaede79e9d4 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -71,6 +71,9 @@ export const ServiceStatusLevels = deepFreeze({ available: { toString: () => 'available', valueOf: () => 0, + toJSON() { + return this.toString(); + }, }, /** * Some features may not be working. @@ -78,6 +81,9 @@ export const ServiceStatusLevels = deepFreeze({ degraded: { toString: () => 'degraded', valueOf: () => 1, + toJSON() { + return this.toString(); + }, }, /** * The service is unavailable, but other functions that do not depend on this service should work. @@ -85,6 +91,9 @@ export const ServiceStatusLevels = deepFreeze({ unavailable: { toString: () => 'unavailable', valueOf: () => 2, + toJSON() { + return this.toString(); + }, }, /** * Block all user functions and display the status page, reserved for Core services only. @@ -92,6 +101,9 @@ export const ServiceStatusLevels = deepFreeze({ critical: { toString: () => 'critical', valueOf: () => 3, + toJSON() { + return this.toString(); + }, }, }); @@ -217,11 +229,17 @@ export interface StatusServiceSetup { * through the dependency tree */ derivedStatus$: Observable; + + /** + * Whether or not the status HTTP APIs are available to unauthenticated users when an authentication provider is + * present. + */ + isStatusPageAnonymous: () => boolean; } /** @internal */ -export interface InternalStatusServiceSetup extends Pick { - isStatusPageAnonymous: () => boolean; +export interface InternalStatusServiceSetup + extends Pick { // Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically. plugins: { set(plugin: PluginName, status$: Observable): void; diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index b8780321e11dd..a3531057767d4 100644 --- a/src/core/tsconfig.json +++ b/src/core/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, - "outDir": "./target", + "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, "declarationMap": true diff --git a/src/core/utils/context.mock.ts b/src/core/utils/context.mock.ts index de844f3f0f07d..273d64ec8f822 100644 --- a/src/core/utils/context.mock.ts +++ b/src/core/utils/context.mock.ts @@ -21,15 +21,13 @@ import { IContextContainer } from './context'; export type ContextContainerMock = jest.Mocked>; -const createContextMock = () => { +const createContextMock = (mockContext = {}) => { const contextMock: ContextContainerMock = { registerContext: jest.fn(), - createHandler: jest.fn((id, handler) => (...args: any[]) => - Promise.resolve(handler({}, ...args)) - ), + createHandler: jest.fn(), }; contextMock.createHandler.mockImplementation((pluginId, handler) => (...args) => - handler({}, ...args) + handler(mockContext, ...args) ); return contextMock; }; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 1fb7c284c0dfd..809aaddb74172 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -18,9 +18,10 @@ */ import { i18n } from '@kbn/i18n'; +import { AppCategory } from '../types'; /** @internal */ -export const DEFAULT_APP_CATEGORIES = Object.freeze({ +export const DEFAULT_APP_CATEGORIES: Record = Object.freeze({ kibana: { id: 'kibana', label: i18n.translate('core.ui.kibanaNavList.label', { @@ -59,5 +60,6 @@ export const DEFAULT_APP_CATEGORIES = Object.freeze({ defaultMessage: 'Management', }), order: 5000, + euiIconType: 'managementApp', }, }); 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 362c34d416743..19487efe1366c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -40,7 +40,7 @@ export async function runDockerGenerator( ubi: boolean = false ) { // UBI var config - const baseOSImage = ubi ? 'registry.access.redhat.com/ubi8/ubi-minimal:latest' : 'centos:8'; + const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; diff --git a/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json b/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json index 30e78635ec2e9..017d208133cdc 100644 --- a/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json @@ -1 +1 @@ -{"description":"Kibana code coverage team assignments","processors":[{"script":{"lang":"painless","source":"\n String path = ctx.coveredFilePath; \n if (path.indexOf('src/legacy/core_plugins/kibana/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/kibana/common/utils') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/migrations') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dev_tools/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home/np_ready/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/local_application_service/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib/management/saved_objects') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/import/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/export/') == 0) ctx.team = 'kibana-platform';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/core_plugins/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/legacy/core_plugins/console_legacy') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/elasticsearch') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/embeddable_api/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/input_control_vis') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/region_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/status_page/public') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/testbed') == 0) ctx.team = 'kibana-platform';\n // else if (path.indexOf('src/legacy/core_plugins/tests_bundle/') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('src/legacy/core_plugins/tile_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/ui_metric/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_tagcloud') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vega') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vislib/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/server/') == 0) {\n\n if (path.indexOf('src/legacy/server/config/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/http/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/legacy/server/index_patterns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/server/keystore/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/logging/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/pid/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/sample_data/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/sass/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/saved_objects/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/status/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/url_shortening/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/utils/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/warnings/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/ui') == 0) {\n\n if (path.indexOf('src/legacy/ui/public/field_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/timefilter') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/management') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/state_management') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/ui/public/new_platform') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/plugin_discovery') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/chrome') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/notify') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/documentation_links') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/autoload') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/capabilities') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/legacy/ui/public/apm') == 0) ctx.team = 'apm-ui';\n\n } else if (path.indexOf('src/plugins/') == 0) {\n\n if (path.indexOf('src/plugins/advanced_settings/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/plugins/bfetch/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/public/static/color_maps') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/console/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/data/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/dev_tools/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/embeddable/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/expressions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/home/public') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/server/tutorials') == 0) ctx.team = 'observability';\n else if (path.indexOf('src/plugins/home/server/services/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/index_pattern_management/public/service') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/index_pattern_management/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/input_control_vis/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/inspector/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_legacy/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/kibana_react/public/code_editor') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('src/plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_utils/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/legacy_export/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/maps_legacy/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/region_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/tile_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/navigation/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/saved_objects_management/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/saved_objects/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/share/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/status_page/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/telemetry') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/testbed/server/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/vis_default_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/vis_type') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/visualize/') == 0) ctx.team = 'kibana-app';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/legacy/') == 0) {\n\n if (path.indexOf('x-pack/legacy/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/alerting/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/legacy/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/legacy/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/legacy/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/dashboard_mode/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/legacy/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/legacy/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/legacy/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/legacy/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/legacy/plugins/monitoring/') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/legacy/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/legacy/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/legacy/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/task_manager') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/uptime') == 0) ctx.team = 'uptime';\n else if (path.indexOf('x-pack/legacy/plugins/xpack_main/server/') == 0) ctx.team = 'kibana-platform';\n\n else if (path.indexOf('x-pack/legacy/server/lib/create_router/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/server/lib/check_license/') == 0) ctx.team = 'es-ui'; \n else if (path.indexOf('x-pack/legacy/server/lib/') == 0) ctx.team = 'kibana-platform'; \n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/plugins/') == 0) {\n\n if (path.indexOf('x-pack/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/advanced_ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/alerts') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/alerting_builtins') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/plugins/case') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/cloud/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/code/') == 0) ctx.team = 'code';\n else if (path.indexOf('x-pack/plugins/console_extensions/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/dashboard_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/dashboard_mode') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/discover_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/embeddable_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/data_enhanced/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/drilldowns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/endpoint/') == 0) ctx.team = 'endpoint-app-team';\n else if (path.indexOf('x-pack/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/event_log/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/features/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/file_upload') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/global_search') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('x-pack/plugins/graph/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/grokdebugger/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_lifecycle_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/plugins/ingest_pipelines/') == 0) ctx.team = 'es-ui';\n \n else if (path.indexOf('x-pack/plugins/lens/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/licensing/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/lists/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/logstash') == 0) ctx.team = 'logstash';\n else if (path.indexOf('x-pack/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/maps_legacy_licensing') == 0) ctx.team = 'maps';\n else if (path.indexOf('x-pack/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/monitoring') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/plugins/observability/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/oss_telemetry/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/painless_lab/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/remote_clusters/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/searchprofiler/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/security_solution/') == 0) ctx.team = 'siem';\n \n else if (path.indexOf('x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/task_manager/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/telemetry_collection_xpack/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/transform/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/translations/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('x-pack/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/ui_actions_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/uptime') == 0) ctx.team = 'uptime';\n \n else if (path.indexOf('x-pack/plugins/watcher/') == 0) ctx.team = 'es-ui';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('packages') == 0) {\n\n if (path.indexOf('packages/kbn-analytics/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('packages/kbn-babel') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-config-schema/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/elastic-datemath') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-dev-utils') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-es/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-eslint') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-expect') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('packages/kbn-interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-optimizer/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-pm/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test-subj-selector/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-ui-framework/') == 0) ctx.team = 'kibana-design';\n else if (path.indexOf('packages/kbn-ui-shared-deps/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else {\n\n if (path.indexOf('config/kibana.yml') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/apm.js') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/core/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/core/public/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/core/server/csp/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/dev/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/dev/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/dev/run_check_published_api_changes.ts') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/kbn-es-archiver/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/optimize/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/setup_node_env/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/test_utils/') == 0) ctx.team = 'kibana-operations'; \n else ctx.team = 'unknown';\n }"}}]} +{"description":"Kibana code coverage team assignments","processors":[{"script":{"lang":"painless","source":"\n String path = ctx.coveredFilePath; \n if (path.indexOf('src/legacy/core_plugins/kibana/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/kibana/common/utils') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/migrations') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dev_tools/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home/np_ready/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/local_application_service/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib/management/saved_objects') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/import/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/export/') == 0) ctx.team = 'kibana-platform';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/core_plugins/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/legacy/core_plugins/console_legacy') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/elasticsearch') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/embeddable_api/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/input_control_vis') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/region_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/status_page/public') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/testbed') == 0) ctx.team = 'kibana-platform';\n // else if (path.indexOf('src/legacy/core_plugins/tests_bundle/') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('src/legacy/core_plugins/tile_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/ui_metric/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_tagcloud') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vega') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vislib/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/server/') == 0) {\n\n if (path.indexOf('src/legacy/server/config/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/http/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/legacy/server/index_patterns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/server/keystore/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/logging/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/pid/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/sample_data/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/sass/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/saved_objects/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/status/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/url_shortening/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/utils/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/warnings/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/ui') == 0) {\n\n if (path.indexOf('src/legacy/ui/public/field_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/timefilter') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/management') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/state_management') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/ui/public/new_platform') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/plugin_discovery') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/chrome') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/notify') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/documentation_links') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/autoload') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/capabilities') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/legacy/ui/public/apm') == 0) ctx.team = 'apm-ui';\n\n } else if (path.indexOf('src/plugins/') == 0) {\n\n if (path.indexOf('src/plugins/advanced_settings/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/plugins/bfetch/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/public/static/color_maps') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/console/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/data/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/dev_tools/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/embeddable/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/expressions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/home/public') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/server/tutorials') == 0) ctx.team = 'observability';\n else if (path.indexOf('src/plugins/home/server/services/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/index_pattern_management/public/service') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/index_pattern_management/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/input_control_vis/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/inspector/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_legacy/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/kibana_react/public/code_editor') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('src/plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_utils/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/legacy_export/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/maps_legacy/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/region_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/tile_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/navigation/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/saved_objects_management/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/saved_objects/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/share/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/status_page/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/telemetry') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/testbed/server/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/vis_default_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/vis_type') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/visualize/') == 0) ctx.team = 'kibana-app';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/legacy/') == 0) {\n\n if (path.indexOf('x-pack/legacy/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/alerting/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/legacy/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/legacy/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/legacy/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/dashboard_mode/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/legacy/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/legacy/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/legacy/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/legacy/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/legacy/plugins/monitoring/') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/legacy/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/legacy/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/legacy/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/task_manager') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/uptime') == 0) ctx.team = 'uptime';\n else if (path.indexOf('x-pack/legacy/plugins/xpack_main/server/') == 0) ctx.team = 'kibana-platform';\n\n else if (path.indexOf('x-pack/legacy/server/lib/create_router/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/server/lib/check_license/') == 0) ctx.team = 'es-ui'; \n else if (path.indexOf('x-pack/legacy/server/lib/') == 0) ctx.team = 'kibana-platform'; \n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/plugins/') == 0) {\n\n if (path.indexOf('x-pack/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/advanced_ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/alerts') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/alerting_builtins') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/plugins/case') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/cloud/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/code/') == 0) ctx.team = 'code';\n else if (path.indexOf('x-pack/plugins/console_extensions/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/dashboard_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/dashboard_mode') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/discover_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/embeddable_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/data_enhanced/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/drilldowns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/endpoint/') == 0) ctx.team = 'endpoint-app-team';\n else if (path.indexOf('x-pack/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/event_log/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/features/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/file_upload') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/global_search') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('x-pack/plugins/graph/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/grokdebugger/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_lifecycle_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/plugins/ingest_pipelines/') == 0) ctx.team = 'es-ui';\n \n else if (path.indexOf('x-pack/plugins/lens/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/licensing/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/lists/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/logstash') == 0) ctx.team = 'logstash';\n else if (path.indexOf('x-pack/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/maps_legacy_licensing') == 0) ctx.team = 'maps';\n else if (path.indexOf('x-pack/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/monitoring') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/plugins/observability/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/oss_telemetry/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/painless_lab/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/remote_clusters/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/searchprofiler/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/security_solution/') == 0) ctx.team = 'siem';\n \n else if (path.indexOf('x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/task_manager/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/telemetry_collection_xpack/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/transform/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/translations/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('x-pack/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/ui_actions_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/uptime') == 0) ctx.team = 'uptime';\n \n else if (path.indexOf('x-pack/plugins/watcher/') == 0) ctx.team = 'es-ui';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('packages') == 0) {\n\n if (path.indexOf('packages/kbn-analytics/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('packages/kbn-babel') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-config-schema/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/elastic-datemath') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-dev-utils') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-es/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-eslint') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-expect') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('packages/kbn-interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-optimizer/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-pm/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test-subj-selector/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-ui-framework/') == 0) ctx.team = 'kibana-design';\n else if (path.indexOf('packages/kbn-ui-shared-deps/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else {\n\n if (path.indexOf('config/kibana.yml') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/apm.js') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/core/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/core/public/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/core/server/csp/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/dev/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/dev/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/dev/run_check_published_api_changes.ts') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/kbn-es-archiver/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/optimize/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/setup_node_env/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/test_utils/') == 0) ctx.team = 'kibana-operations'; \n else ctx.team = 'unknown';\n }"}}]} diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 486c8563c5456..5d31db63773fa 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -25,7 +25,6 @@ export default { '/src/plugins', '/src/legacy/ui', '/src/core', - '/src/legacy/core_plugins', '/src/legacy/server', '/src/cli', '/src/cli_keystore', @@ -51,14 +50,11 @@ export default { 'packages/kbn-ui-framework/src/services/**/*.js', '!packages/kbn-ui-framework/src/services/index.js', '!packages/kbn-ui-framework/src/services/**/*/index.js', - 'src/legacy/core_plugins/**/*.{js,mjs,jsx,ts,tsx}', - '!src/legacy/core_plugins/**/{__test__,__snapshots__}/**/*', ], moduleNameMapper: { '@elastic/eui$': '/node_modules/@elastic/eui/test-env', '@elastic/eui/lib/(.*)?': '/node_modules/@elastic/eui/test-env/$1', '^src/plugins/(.*)': '/src/plugins/$1', - '^plugins/([^/.]*)(.*)': '/src/legacy/core_plugins/$1/public$2', '^uiExports/(.*)': '/src/dev/jest/mocks/file_mock.js', '^test_utils/(.*)': '/src/test_utils/public/$1', '^fixtures/(.*)': '/src/fixtures/$1', diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 26b0a9082d97d..9cd6ca6801bc3 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -130,11 +130,6 @@ export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ej * @type {Array} */ export const TEMPORARILY_IGNORED_PATHS = [ - 'src/legacy/core_plugins/console/public/src/directives/helpExample.txt', - 'src/legacy/core_plugins/console/public/src/sense_editor/theme-sense-dark.js', - 'src/legacy/core_plugins/tile_map/public/__tests__/scaledCircleMarkers.png', - 'src/legacy/core_plugins/tile_map/public/__tests__/shadedCircleMarkers.png', - 'src/legacy/core_plugins/tile_map/public/__tests__/shadedGeohashGrid.png', 'src/fixtures/config_upgrade_from_4.0.0_to_4.0.1-snapshot.json', 'src/core/server/core_app/assets/favicons/android-chrome-192x192.png', 'src/core/server/core_app/assets/favicons/android-chrome-256x256.png', diff --git a/src/legacy/core_plugins/elasticsearch/index.d.ts b/src/legacy/core_plugins/elasticsearch/index.d.ts deleted file mode 100644 index 83e7bb19e57ba..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/index.d.ts +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - Client as ESClient, - GenericParams, - // root params - BulkIndexDocumentsParams, - ClearScrollParams, - CountParams, - CreateDocumentParams, - DeleteDocumentParams, - DeleteDocumentByQueryParams, - DeleteScriptParams, - DeleteTemplateParams, - ExistsParams, - ExplainParams, - FieldStatsParams, - GetParams, - GetResponse, - GetScriptParams, - GetSourceParams, - GetTemplateParams, - IndexDocumentParams, - InfoParams, - MGetParams, - MSearchParams, - MSearchTemplateParams, - MTermVectorsParams, - PingParams, - PutScriptParams, - PutTemplateParams, - ReindexParams, - ReindexRethrottleParams, - RenderSearchTemplateParams, - ScrollParams, - SearchParams, - SearchShardsParams, - SearchTemplateParams, - SuggestParams, - TermvectorsParams, - UpdateDocumentParams, - UpdateDocumentByQueryParams, - MGetResponse, - MSearchResponse, - SearchResponse, - // cat - CatAliasesParams, - CatAllocationParams, - CatFielddataParams, - CatHealthParams, - CatHelpParams, - CatIndicesParams, - CatCommonParams, - CatRecoveryParams, - CatSegmentsParams, - CatShardsParams, - CatSnapshotsParams, - CatTasksParams, - CatThreadPoolParams, - // cluster - ClusterAllocationExplainParams, - ClusterGetSettingsParams, - ClusterHealthParams, - ClusterPendingTasksParams, - ClusterPutSettingsParams, - ClusterRerouteParams, - ClusterStateParams, - ClusterStatsParams, - // indices - IndicesAnalyzeParams, - IndicesClearCacheParams, - IndicesCloseParams, - IndicesCreateParams, - IndicesDeleteParams, - IndicesDeleteAliasParams, - IndicesDeleteTemplateParams, - IndicesExistsParams, - IndicesExistsAliasParams, - IndicesExistsTemplateParams, - IndicesExistsTypeParams, - IndicesFlushParams, - IndicesFlushSyncedParams, - IndicesForcemergeParams, - IndicesGetParams, - IndicesGetAliasParams, - IndicesGetFieldMappingParams, - IndicesGetMappingParams, - IndicesGetSettingsParams, - IndicesGetTemplateParams, - IndicesGetUpgradeParams, - IndicesOpenParams, - IndicesPutAliasParams, - IndicesPutMappingParams, - IndicesPutSettingsParams, - IndicesPutTemplateParams, - IndicesRecoveryParams, - IndicesRefreshParams, - IndicesRolloverParams, - IndicesSegmentsParams, - IndicesShardStoresParams, - IndicesShrinkParams, - IndicesStatsParams, - IndicesUpdateAliasesParams, - IndicesUpgradeParams, - IndicesValidateQueryParams, - // ingest - IngestDeletePipelineParams, - IngestGetPipelineParams, - IngestPutPipelineParams, - IngestSimulateParams, - // nodes - NodesHotThreadsParams, - NodesInfoParams, - NodesStatsParams, - // snapshot - SnapshotCreateParams, - SnapshotCreateRepositoryParams, - SnapshotDeleteParams, - SnapshotDeleteRepositoryParams, - SnapshotGetParams, - SnapshotGetRepositoryParams, - SnapshotRestoreParams, - SnapshotStatusParams, - SnapshotVerifyRepositoryParams, - // tasks - TasksCancelParams, - TasksGetParams, - TasksListParams, -} from 'elasticsearch'; - -export class Cluster { - public callWithRequest: CallClusterWithRequest; - public callWithInternalUser: CallCluster; - constructor(config: ClusterConfig); -} - -export interface ClusterConfig { - [option: string]: any; -} - -export interface Request { - headers: RequestHeaders; -} - -interface RequestHeaders { - [name: string]: string; -} - -interface AssistantAPIClientParams extends GenericParams { - path: '/_migration/assistance'; - method: 'GET'; -} - -type MIGRATION_ASSISTANCE_INDEX_ACTION = 'upgrade' | 'reindex'; -type MIGRATION_DEPRECATION_LEVEL = 'none' | 'info' | 'warning' | 'critical'; - -export interface AssistanceAPIResponse { - indices: { - [indexName: string]: { - action_required: MIGRATION_ASSISTANCE_INDEX_ACTION; - }; - }; -} - -interface DeprecationAPIClientParams extends GenericParams { - path: '/_migration/deprecations'; - method: 'GET'; -} - -export interface DeprecationInfo { - level: MIGRATION_DEPRECATION_LEVEL; - message: string; - url: string; - details?: string; -} - -export interface IndexSettingsDeprecationInfo { - [indexName: string]: DeprecationInfo[]; -} - -export interface DeprecationAPIResponse { - cluster_settings: DeprecationInfo[]; - ml_settings: DeprecationInfo[]; - node_settings: DeprecationInfo[]; - index_settings: IndexSettingsDeprecationInfo; -} - -export interface CallClusterOptions { - wrap401Errors?: boolean; - signal?: AbortSignal; -} - -export interface CallClusterWithRequest { - /* eslint-disable */ - (request: Request, endpoint: 'bulk', params: BulkIndexDocumentsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'clearScroll', params: ClearScrollParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'count', params: CountParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'create', params: CreateDocumentParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'delete', params: DeleteDocumentParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'deleteByQuery', params: DeleteDocumentByQueryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'deleteScript', params: DeleteScriptParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'deleteTemplate', params: DeleteTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'exists', params: ExistsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'explain', params: ExplainParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'fieldStats', params: FieldStatsParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (request: Request, endpoint: 'get', params: GetParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'getScript', params: GetScriptParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'getSource', params: GetSourceParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'getTemplate', params: GetTemplateParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (request: Request, endpoint: 'index', params: IndexDocumentParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'info', params: InfoParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (request: Request, endpoint: 'mget', params: MGetParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'msearch', params: MSearchParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'msearchTemplate', params: MSearchTemplateParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'mtermvectors', params: MTermVectorsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'ping', params: PingParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'putScript', params: PutScriptParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'putTemplate', params: PutTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'reindex', params: ReindexParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'reindexRethrottle', params: ReindexRethrottleParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'renderSearchTemplate', params: RenderSearchTemplateParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (request: Request, endpoint: 'scroll', params: ScrollParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'search', params: SearchParams, options?: CallClusterOptions): Promise>; - (request: Request, endpoint: 'searchShards', params: SearchShardsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'searchTemplate', params: SearchTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'suggest', params: SuggestParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'termvectors', params: TermvectorsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'update', params: UpdateDocumentParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'updateByQuery', params: UpdateDocumentByQueryParams, options?: CallClusterOptions): ReturnType; - - // cat namespace - (request: Request, endpoint: 'cat.aliases', params: CatAliasesParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.allocation', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.count', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.fielddata', params: CatFielddataParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.health', params: CatHealthParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.help', params: CatHelpParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.indices', params: CatIndicesParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.master', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.nodeattrs', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.nodes', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.pendingTasks', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.plugins', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.recovery', params: CatRecoveryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.repositories', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.segments', params: CatSegmentsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.shards', params: CatShardsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.snapshots', params: CatSnapshotsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.tasks', params: CatTasksParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cat.threadPool', params: CatThreadPoolParams, options?: CallClusterOptions): ReturnType; - - // cluster namespace - (request: Request, endpoint: 'cluster.allocationExplain', params: ClusterAllocationExplainParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.getSettings', params: ClusterGetSettingsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.health', params: ClusterHealthParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.pendingTasks', params: ClusterPendingTasksParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.putSettings', params: ClusterPutSettingsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.reroute', params: ClusterRerouteParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.state', params: ClusterStateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'cluster.stats', params: ClusterStatsParams, options?: CallClusterOptions): ReturnType; - - // indices namespace - (request: Request, endpoint: 'indices.analyze', params: IndicesAnalyzeParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.clearCache', params: IndicesClearCacheParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.close', params: IndicesCloseParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.create', params: IndicesCreateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.delete', params: IndicesDeleteParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.deleteAlias', params: IndicesDeleteAliasParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.deleteTemplate', params: IndicesDeleteTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.exists', params: IndicesExistsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.existsAlias', params: IndicesExistsAliasParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.existsTemplate', params: IndicesExistsTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.existsType', params: IndicesExistsTypeParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.flush', params: IndicesFlushParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.flushSynced', params: IndicesFlushSyncedParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.forcemerge', params: IndicesForcemergeParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.get', params: IndicesGetParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getAlias', params: IndicesGetAliasParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getFieldMapping', params: IndicesGetFieldMappingParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getMapping', params: IndicesGetMappingParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getSettings', params: IndicesGetSettingsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getTemplate', params: IndicesGetTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.getUpgrade', params: IndicesGetUpgradeParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.open', params: IndicesOpenParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.putAlias', params: IndicesPutAliasParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.putMapping', params: IndicesPutMappingParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.putSettings', params: IndicesPutSettingsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.putTemplate', params: IndicesPutTemplateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.recovery', params: IndicesRecoveryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.refresh', params: IndicesRefreshParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.rollover', params: IndicesRolloverParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.segments', params: IndicesSegmentsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.shardStores', params: IndicesShardStoresParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.shrink', params: IndicesShrinkParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.stats', params: IndicesStatsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.updateAliases', params: IndicesUpdateAliasesParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.upgrade', params: IndicesUpgradeParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'indices.validateQuery', params: IndicesValidateQueryParams, options?: CallClusterOptions): ReturnType; - - // ingest namepsace - (request: Request, endpoint: 'ingest.deletePipeline', params: IngestDeletePipelineParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'ingest.getPipeline', params: IngestGetPipelineParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'ingest.putPipeline', params: IngestPutPipelineParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'ingest.simulate', params: IngestSimulateParams, options?: CallClusterOptions): ReturnType; - - // nodes namespace - (request: Request, endpoint: 'nodes.hotThreads', params: NodesHotThreadsParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'nodes.info', params: NodesInfoParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'nodes.stats', params: NodesStatsParams, options?: CallClusterOptions): ReturnType; - - // snapshot namespace - (request: Request, endpoint: 'snapshot.create', params: SnapshotCreateParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.createRepository', params: SnapshotCreateRepositoryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.delete', params: SnapshotDeleteParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.deleteRepository', params: SnapshotDeleteRepositoryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.get', params: SnapshotGetParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.getRepository', params: SnapshotGetRepositoryParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.restore', params: SnapshotRestoreParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.status', params: SnapshotStatusParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'snapshot.verifyRepository', params: SnapshotVerifyRepositoryParams, options?: CallClusterOptions): ReturnType; - - // tasks namespace - (request: Request, endpoint: 'tasks.cancel', params: TasksCancelParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'tasks.get', params: TasksGetParams, options?: CallClusterOptions): ReturnType; - (request: Request, endpoint: 'tasks.list', params: TasksListParams, options?: CallClusterOptions): ReturnType; - - // other APIs accessed via transport.request - ( - request: Request, - endpoint: 'transport.request', - clientParams: AssistantAPIClientParams, - options?: {} - ): Promise; - ( - request: Request, - endpoint: 'transport.request', - clientParams: DeprecationAPIClientParams, - options?: {} - ): Promise; - - // Catch-all definition - ( - request: Request, - endpoint: string, - clientParams?: any, - options?: CallClusterOptions - ): Promise; - /* eslint-enable */ -} - -export interface CallCluster { - /* eslint-disable */ - (endpoint: 'bulk', params: BulkIndexDocumentsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'clearScroll', params: ClearScrollParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'count', params: CountParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'create', params: CreateDocumentParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'delete', params: DeleteDocumentParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'deleteByQuery', params: DeleteDocumentByQueryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'deleteScript', params: DeleteScriptParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'deleteTemplate', params: DeleteTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'exists', params: ExistsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'explain', params: ExplainParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'fieldStats', params: FieldStatsParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (endpoint: 'get', params: GetParams, options?: CallClusterOptions): Promise>; - (endpoint: 'getScript', params: GetScriptParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'getSource', params: GetSourceParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'getTemplate', params: GetTemplateParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (endpoint: 'index', params: IndexDocumentParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'info', params: InfoParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (endpoint: 'mget', params: MGetParams, options?: CallClusterOptions): Promise>; - (endpoint: 'msearch', params: MSearchParams, options?: CallClusterOptions): Promise>; - (endpoint: 'msearchTemplate', params: MSearchTemplateParams, options?: CallClusterOptions): Promise>; - (endpoint: 'mtermvectors', params: MTermVectorsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'ping', params: PingParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'putScript', params: PutScriptParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'putTemplate', params: PutTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'reindex', params: ReindexParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'reindexRethrottle', params: ReindexRethrottleParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'renderSearchTemplate', params: RenderSearchTemplateParams, options?: CallClusterOptions): ReturnType; - // Generic types cannot be properly looked up with ReturnType. Hard code these explicitly. - (endpoint: 'scroll', params: ScrollParams, options?: CallClusterOptions): Promise>; - (endpoint: 'search', params: SearchParams, options?: CallClusterOptions): Promise>; - (endpoint: 'searchShards', params: SearchShardsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'searchTemplate', params: SearchTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'suggest', params: SuggestParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'termvectors', params: TermvectorsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'update', params: UpdateDocumentParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'updateByQuery', params: UpdateDocumentByQueryParams, options?: CallClusterOptions): ReturnType; - - // cat namespace - (endpoint: 'cat.aliases', params: CatAliasesParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.allocation', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.count', params: CatAllocationParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.fielddata', params: CatFielddataParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.health', params: CatHealthParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.help', params: CatHelpParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.indices', params: CatIndicesParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.master', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.nodeattrs', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.nodes', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.pendingTasks', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.plugins', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.recovery', params: CatRecoveryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.repositories', params: CatCommonParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.segments', params: CatSegmentsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.shards', params: CatShardsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.snapshots', params: CatSnapshotsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.tasks', params: CatTasksParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cat.threadPool', params: CatThreadPoolParams, options?: CallClusterOptions): ReturnType; - - // cluster namespace - (endpoint: 'cluster.allocationExplain', params: ClusterAllocationExplainParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.getSettings', params: ClusterGetSettingsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.health', params: ClusterHealthParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.pendingTasks', params: ClusterPendingTasksParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.putSettings', params: ClusterPutSettingsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.reroute', params: ClusterRerouteParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.state', params: ClusterStateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'cluster.stats', params: ClusterStatsParams, options?: CallClusterOptions): ReturnType; - - // indices namespace - (endpoint: 'indices.analyze', params: IndicesAnalyzeParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.clearCache', params: IndicesClearCacheParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.close', params: IndicesCloseParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.create', params: IndicesCreateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.delete', params: IndicesDeleteParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.deleteAlias', params: IndicesDeleteAliasParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.deleteTemplate', params: IndicesDeleteTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.exists', params: IndicesExistsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.existsAlias', params: IndicesExistsAliasParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.existsTemplate', params: IndicesExistsTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.existsType', params: IndicesExistsTypeParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.flush', params: IndicesFlushParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.flushSynced', params: IndicesFlushSyncedParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.forcemerge', params: IndicesForcemergeParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.get', params: IndicesGetParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getAlias', params: IndicesGetAliasParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getFieldMapping', params: IndicesGetFieldMappingParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getMapping', params: IndicesGetMappingParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getSettings', params: IndicesGetSettingsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getTemplate', params: IndicesGetTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.getUpgrade', params: IndicesGetUpgradeParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.open', params: IndicesOpenParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.putAlias', params: IndicesPutAliasParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.putMapping', params: IndicesPutMappingParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.putSettings', params: IndicesPutSettingsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.putTemplate', params: IndicesPutTemplateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.recovery', params: IndicesRecoveryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.refresh', params: IndicesRefreshParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.rollover', params: IndicesRolloverParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.segments', params: IndicesSegmentsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.shardStores', params: IndicesShardStoresParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.shrink', params: IndicesShrinkParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.stats', params: IndicesStatsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.updateAliases', params: IndicesUpdateAliasesParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.upgrade', params: IndicesUpgradeParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'indices.validateQuery', params: IndicesValidateQueryParams, options?: CallClusterOptions): ReturnType; - - // ingest namespace - (endpoint: 'ingest.deletePipeline', params: IngestDeletePipelineParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'ingest.getPipeline', params: IngestGetPipelineParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'ingest.putPipeline', params: IngestPutPipelineParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'ingest.simulate', params: IngestSimulateParams, options?: CallClusterOptions): ReturnType; - - // nodes namespace - (endpoint: 'nodes.hotThreads', params: NodesHotThreadsParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'nodes.info', params: NodesInfoParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'nodes.stats', params: NodesStatsParams, options?: CallClusterOptions): ReturnType; - - // snapshot namespace - (endpoint: 'snapshot.create', params: SnapshotCreateParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.createRepository', params: SnapshotCreateRepositoryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.delete', params: SnapshotDeleteParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.deleteRepository', params: SnapshotDeleteRepositoryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.get', params: SnapshotGetParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.getRepository', params: SnapshotGetRepositoryParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.restore', params: SnapshotRestoreParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.status', params: SnapshotStatusParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'snapshot.verifyRepository', params: SnapshotVerifyRepositoryParams, options?: CallClusterOptions): ReturnType; - - // tasks namespace - (endpoint: 'tasks.cancel', params: TasksCancelParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'tasks.get', params: TasksGetParams, options?: CallClusterOptions): ReturnType; - (endpoint: 'tasks.list', params: TasksListParams, options?: CallClusterOptions): ReturnType; - - // other APIs accessed via transport.request - (endpoint: 'transport.request', clientParams: AssistantAPIClientParams, options?: {}): Promise< - AssistanceAPIResponse - >; - (endpoint: 'transport.request', clientParams: DeprecationAPIClientParams, options?: {}): Promise< - DeprecationAPIResponse - >; - - // Catch-all definition - (endpoint: string, clientParams?: any, options?: CallClusterOptions): Promise; - /* eslint-enable */ -} - -export interface ElasticsearchPlugin { - status: { on: (status: string, cb: () => void) => void }; - getCluster(name: string): Cluster; -} diff --git a/src/legacy/core_plugins/elasticsearch/index.js b/src/legacy/core_plugins/elasticsearch/index.js deleted file mode 100644 index f90f490d68035..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/index.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { Cluster } from './server/lib/cluster'; -import { createProxy } from './server/lib/create_proxy'; - -export default function (kibana) { - return new kibana.Plugin({ - require: [], - - async init(server) { - // All methods that ES plugin exposes are synchronous so we should get the first - // value from all observables here to be able to synchronously return and create - // cluster clients afterwards. - const { client } = server.newPlatform.setup.core.elasticsearch.legacy; - const adminCluster = new Cluster(client); - const dataCluster = new Cluster(client); - - const clusters = new Map(); - server.expose('getCluster', (name) => { - if (name === 'admin') { - return adminCluster; - } - - if (name === 'data') { - return dataCluster; - } - - return clusters.get(name); - }); - - server.events.on('stop', () => { - for (const cluster of clusters.values()) { - cluster.close(); - } - - clusters.clear(); - }); - - createProxy(server); - }, - }); -} diff --git a/src/legacy/core_plugins/elasticsearch/package.json b/src/legacy/core_plugins/elasticsearch/package.json deleted file mode 100644 index b5403e1f13de7..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "elasticsearch", - "version": "kibana", - "types": "index.d.ts" -} diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.js b/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.js deleted file mode 100644 index 0b8786f0c2841..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { AbortController } from 'abortcontroller-polyfill/dist/cjs-ponyfill'; - -/* - * A simple utility for generating a handler that provides a signal to the handler that signals when - * the client has closed the connection on this request. - */ -export function abortableRequestHandler(fn) { - return (req, ...args) => { - const controller = new AbortController(); - req.events.once('disconnect', () => { - controller.abort(); - }); - return fn(controller.signal, req, ...args); - }; -} diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.test.js b/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.test.js deleted file mode 100644 index d79dd4ae4e449..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/abortable_request_handler.test.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { AbortSignal } from 'abortcontroller-polyfill/dist/cjs-ponyfill'; -import { abortableRequestHandler } from './abortable_request_handler'; - -describe('abortableRequestHandler', () => { - jest.useFakeTimers(); - - it('should call abort if disconnected', () => { - const eventHandlers = new Map(); - const mockRequest = { - events: { - once: jest.fn((key, fn) => eventHandlers.set(key, fn)), - }, - }; - - const handler = jest.fn(); - const onAborted = jest.fn(); - const abortableHandler = abortableRequestHandler(handler); - abortableHandler(mockRequest); - - const [signal, request] = handler.mock.calls[0]; - - expect(signal instanceof AbortSignal).toBe(true); - expect(request).toBe(mockRequest); - - signal.addEventListener('abort', onAborted); - - // Shouldn't be aborted or call onAborted prior to disconnecting - expect(signal.aborted).toBe(false); - expect(onAborted).not.toBeCalled(); - - expect(eventHandlers.has('disconnect')).toBe(true); - eventHandlers.get('disconnect')(); - jest.runAllTimers(); - - // Should be aborted and call onAborted after disconnecting - expect(signal.aborted).toBe(true); - expect(onAborted).toBeCalled(); - }); -}); diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/cluster.ts b/src/legacy/core_plugins/elasticsearch/server/lib/cluster.ts deleted file mode 100644 index 0e7692f6be755..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/cluster.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Request } from 'hapi'; -import { errors } from 'elasticsearch'; -import { LegacyCallAPIOptions, LegacyClusterClient, FakeRequest } from 'kibana/server'; - -export class Cluster { - public readonly errors = errors; - - constructor(private readonly clusterClient: LegacyClusterClient) {} - - public callWithRequest = async ( - req: Request | FakeRequest, - endpoint: string, - clientParams?: Record, - options?: LegacyCallAPIOptions - ) => { - return await this.clusterClient - .asScoped(req) - .callAsCurrentUser(endpoint, clientParams, options); - }; - - public callWithInternalUser = async ( - endpoint: string, - clientParams?: Record, - options?: LegacyCallAPIOptions - ) => { - return await this.clusterClient.callAsInternalUser(endpoint, clientParams, options); - }; - - public close() { - this.clusterClient.close(); - } -} diff --git a/src/legacy/core_plugins/elasticsearch/server/lib/create_proxy.js b/src/legacy/core_plugins/elasticsearch/server/lib/create_proxy.js deleted file mode 100644 index 7302241c46939..0000000000000 --- a/src/legacy/core_plugins/elasticsearch/server/lib/create_proxy.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Joi from 'joi'; -import { abortableRequestHandler } from './abortable_request_handler'; - -export function createProxy(server) { - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - - server.route({ - method: 'POST', - path: '/elasticsearch/_msearch', - config: { - payload: { - parse: 'gunzip', - }, - }, - handler: abortableRequestHandler((signal, req, h) => { - const { query, payload } = req; - return callWithRequest( - req, - 'transport.request', - { - path: '/_msearch', - method: 'POST', - query, - body: payload.toString('utf8'), - }, - { signal } - ).finally((r) => h.response(r)); - }), - }); - - server.route({ - method: 'POST', - path: '/elasticsearch/{index}/_search', - config: { - validate: { - params: Joi.object().keys({ - index: Joi.string().required(), - }), - }, - }, - handler: abortableRequestHandler(async (signal, req) => { - const { query, payload: body } = req; - try { - return await callWithRequest( - req, - 'transport.request', - { - path: `/${encodeURIComponent(req.params.index)}/_search`, - method: 'POST', - query, - body, - }, - { signal } - ); - } catch (error) { - return JSON.parse(error.response); - } - }), - }); -} diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 8827dc53c5275..3cfda0e0696bb 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -33,7 +33,6 @@ import { import { LegacyConfig, ILegacyInternals } from '../../core/server/legacy'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { UiPlugins } from '../../core/server/plugins'; -import { ElasticsearchPlugin } from '../core_plugins/elasticsearch'; // lot of legacy code was assuming this type only had these two methods export type KibanaConfig = Pick; @@ -41,10 +40,7 @@ export type KibanaConfig = Pick; // Extend the defaults with the plugins and server methods we need. declare module 'hapi' { interface PluginProperties { - elasticsearch: ElasticsearchPlugin; - kibana: any; spaces: any; - // add new plugin types here } interface Server { diff --git a/src/legacy/server/kbn_server.js b/src/legacy/server/kbn_server.js index 24d00abb99a05..107e5f6387833 100644 --- a/src/legacy/server/kbn_server.js +++ b/src/legacy/server/kbn_server.js @@ -28,7 +28,6 @@ import httpMixin from './http'; import { coreMixin } from './core'; import { loggingMixin } from './logging'; import warningsMixin from './warnings'; -import { statusMixin } from './status'; import configCompleteMixin from './config/complete'; import { optimizeMixin } from '../../optimize'; import * as Plugins from './plugins'; @@ -90,7 +89,6 @@ export default class KbnServer { loggingMixin, warningsMixin, - statusMixin, // scan translations dirs, register locale files and initialize i18n engine. i18nMixin, diff --git a/src/legacy/server/plugins/lib/plugin.js b/src/legacy/server/plugins/lib/plugin.js index 2b392d13d595a..48389061199ff 100644 --- a/src/legacy/server/plugins/lib/plugin.js +++ b/src/legacy/server/plugins/lib/plugin.js @@ -79,12 +79,7 @@ export class Plugin { ); } - // Many of the plugins are simply adding static assets to the server and we don't need - // to track their "status". Since plugins must have an init() function to even set its status - // we shouldn't even create a status unless the plugin can use it. if (this.externalInit) { - this.status = kbnServer.status.createForPlugin(this); - server.expose('status', this.status); await this.externalInit(server, options); } }; @@ -93,12 +88,6 @@ export class Plugin { plugin: { register, name: id, version }, options: config.has(configPrefix) ? config.get(configPrefix) : null, }); - - // Only change the plugin status to green if the - // initial status has not been changed - if (this.status && this.status.state === 'uninitialized') { - this.status.green('Ready'); - } } async postInit() { diff --git a/src/legacy/server/status/index.js b/src/legacy/server/status/index.js deleted file mode 100644 index ab7ec471a67ff..0000000000000 --- a/src/legacy/server/status/index.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import ServerStatus from './server_status'; -import { Metrics } from './lib/metrics'; -import { registerStatusApi, registerStatsApi } from './routes'; -import Oppsy from 'oppsy'; -import { cloneDeep } from 'lodash'; -import { getOSInfo } from './lib/get_os_info'; - -export function statusMixin(kbnServer, server, config) { - kbnServer.status = new ServerStatus(kbnServer.server); - const { usageCollection } = server.newPlatform.setup.plugins; - - const metrics = new Metrics(config, server); - - const oppsy = new Oppsy(server); - oppsy.on('ops', (event) => { - // Oppsy has a bad race condition that will modify this data before - // we ship it off to the buffer. Let's create our copy first. - event = cloneDeep(event); - // Oppsy used to provide this, but doesn't anymore. Grab it ourselves. - server.listener.getConnections((_, count) => { - event.concurrent_connections = count; - - // captures (performs transforms on) the latest event data and stashes - // the metrics for status/stats API payload - metrics.capture(event).then((data) => { - kbnServer.metrics = data; - }); - }); - }); - oppsy.start(config.get('ops.interval')); - - server.events.on('stop', () => { - oppsy.stop(); - }); - - // init routes - registerStatusApi(kbnServer, server, config); - registerStatsApi(usageCollection, server, config, kbnServer); - - // expore shared functionality - server.decorate('server', 'getOSInfo', getOSInfo); -} diff --git a/src/legacy/server/status/lib/__mocks__/_fs_stubs.js b/src/legacy/server/status/lib/__mocks__/_fs_stubs.js deleted file mode 100644 index 2be6402baa5fe..0000000000000 --- a/src/legacy/server/status/lib/__mocks__/_fs_stubs.js +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function cGroups(hierarchy) { - if (!hierarchy) { - hierarchy = Math.random().toString(36).substring(7); - } - - const cpuAcctDir = `/sys/fs/cgroup/cpuacct/${hierarchy}`; - const cpuDir = `/sys/fs/cgroup/cpu/${hierarchy}`; - - const cGroupContents = [ - '10:freezer:/', - '9:net_cls,net_prio:/', - '8:pids:/', - '7:blkio:/', - '6:memory:/', - '5:devices:/user.slice', - '4:hugetlb:/', - '3:perf_event:/', - '2:cpu,cpuacct,cpuset:/' + hierarchy, - '1:name=systemd:/user.slice/user-1000.slice/session-2359.scope', - ].join('\n'); - - const cpuStatContents = ['nr_periods 0', 'nr_throttled 10', 'throttled_time 20'].join('\n'); - - return { - hierarchy, - cGroupContents, - cpuStatContents, - cpuAcctDir, - cpuDir, - files: { - '/proc/self/cgroup': cGroupContents, - [`${cpuAcctDir}/cpuacct.usage`]: '357753491408', - [`${cpuDir}/cpu.cfs_period_us`]: '100000', - [`${cpuDir}/cpu.cfs_quota_us`]: '5000', - [`${cpuDir}/cpu.stat`]: cpuStatContents, - }, - }; -} - -class FSError extends Error { - constructor(fileName, code) { - super('Stub File System Stub Error: ' + fileName); - this.code = code; - this.stack = null; - } -} - -let _mockFiles = Object.create({}); - -export const setMockFiles = (mockFiles) => { - _mockFiles = Object.create({}); - if (mockFiles) { - const files = Object.keys(mockFiles); - for (const file of files) { - _mockFiles[file] = mockFiles[file]; - } - } -}; - -export const readFileMock = (fileName, callback) => { - if (_mockFiles.hasOwnProperty(fileName)) { - callback(null, _mockFiles[fileName]); - } else { - const err = new FSError(fileName, 'ENOENT'); - callback(err, null); - } -}; diff --git a/src/legacy/server/status/lib/cgroup.js b/src/legacy/server/status/lib/cgroup.js deleted file mode 100644 index 4d21cafbedcaa..0000000000000 --- a/src/legacy/server/status/lib/cgroup.js +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import fs from 'fs'; -import { promisify } from 'bluebird'; -import { join as joinPath } from 'path'; - -// Logic from elasticsearch/core/src/main/java/org/elasticsearch/monitor/os/OsProbe.java - -const CONTROL_GROUP_RE = new RegExp('\\d+:([^:]+):(/.*)'); -const CONTROLLER_SEPARATOR_RE = ','; - -const PROC_SELF_CGROUP_FILE = '/proc/self/cgroup'; -const PROC_CGROUP_CPU_DIR = '/sys/fs/cgroup/cpu'; -const PROC_CGROUP_CPUACCT_DIR = '/sys/fs/cgroup/cpuacct'; - -const GROUP_CPUACCT = 'cpuacct'; -const CPUACCT_USAGE_FILE = 'cpuacct.usage'; - -const GROUP_CPU = 'cpu'; -const CPU_FS_PERIOD_US_FILE = 'cpu.cfs_period_us'; -const CPU_FS_QUOTA_US_FILE = 'cpu.cfs_quota_us'; -const CPU_STATS_FILE = 'cpu.stat'; - -const readFile = promisify(fs.readFile); - -export function readControlGroups() { - return readFile(PROC_SELF_CGROUP_FILE).then((data) => { - const response = {}; - - data - .toString() - .split(/\n/) - .forEach((line) => { - const matches = line.match(CONTROL_GROUP_RE); - - if (matches === null) { - return; - } - - const controllers = matches[1].split(CONTROLLER_SEPARATOR_RE); - controllers.forEach((controller) => { - response[controller] = matches[2]; - }); - }); - - return response; - }); -} - -function fileContentsToInteger(path) { - return readFile(path).then((data) => { - return parseInt(data.toString(), 10); - }); -} - -function readCPUAcctUsage(controlGroup) { - return fileContentsToInteger(joinPath(PROC_CGROUP_CPUACCT_DIR, controlGroup, CPUACCT_USAGE_FILE)); -} - -function readCPUFsPeriod(controlGroup) { - return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_PERIOD_US_FILE)); -} - -function readCPUFsQuota(controlGroup) { - return fileContentsToInteger(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_FS_QUOTA_US_FILE)); -} - -export function readCPUStat(controlGroup) { - return new Promise((resolve, reject) => { - const stat = { - number_of_elapsed_periods: -1, - number_of_times_throttled: -1, - time_throttled_nanos: -1, - }; - - readFile(joinPath(PROC_CGROUP_CPU_DIR, controlGroup, CPU_STATS_FILE)) - .then((data) => { - data - .toString() - .split(/\n/) - .forEach((line) => { - const fields = line.split(/\s+/); - - switch (fields[0]) { - case 'nr_periods': - stat.number_of_elapsed_periods = parseInt(fields[1], 10); - break; - - case 'nr_throttled': - stat.number_of_times_throttled = parseInt(fields[1], 10); - break; - - case 'throttled_time': - stat.time_throttled_nanos = parseInt(fields[1], 10); - break; - } - }); - - resolve(stat); - }) - .catch((err) => { - if (err.code === 'ENOENT') { - return resolve(stat); - } - - reject(err); - }); - }); -} - -export function getAllStats(options = {}) { - return new Promise((resolve, reject) => { - readControlGroups() - .then((groups) => { - const cpuPath = options.cpuPath || groups[GROUP_CPU]; - const cpuAcctPath = options.cpuAcctPath || groups[GROUP_CPUACCT]; - - // prevents undefined cgroup paths - if (!cpuPath || !cpuAcctPath) { - return resolve(null); - } - - return Promise.all([ - readCPUAcctUsage(cpuAcctPath), - readCPUFsPeriod(cpuPath), - readCPUFsQuota(cpuPath), - readCPUStat(cpuPath), - ]) - .then(([cpuAcctUsage, cpuFsPeriod, cpuFsQuota, cpuStat]) => { - resolve({ - cpuacct: { - control_group: cpuAcctPath, - usage_nanos: cpuAcctUsage, - }, - - cpu: { - control_group: cpuPath, - cfs_period_micros: cpuFsPeriod, - cfs_quota_micros: cpuFsQuota, - stat: cpuStat, - }, - }); - }) - .catch(rejectUnlessFileNotFound); - }) - .catch(rejectUnlessFileNotFound); - - function rejectUnlessFileNotFound(err) { - if (err.code === 'ENOENT') { - resolve(null); - } else { - reject(err); - } - } - }); -} diff --git a/src/legacy/server/status/lib/cgroup.test.js b/src/legacy/server/status/lib/cgroup.test.js deleted file mode 100644 index 62feba45d1b3c..0000000000000 --- a/src/legacy/server/status/lib/cgroup.test.js +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('fs', () => ({ - readFile: jest.fn(), -})); - -import fs from 'fs'; -import { cGroups as cGroupsFsStub, setMockFiles, readFileMock } from './__mocks__/_fs_stubs'; -import { getAllStats, readControlGroups, readCPUStat } from './cgroup'; - -describe('Control Group', function () { - const fsStub = cGroupsFsStub(); - - beforeAll(() => { - fs.readFile.mockImplementation(readFileMock); - }); - - afterEach(() => { - setMockFiles(); - }); - - describe('readControlGroups', () => { - it('parses the file', async () => { - setMockFiles({ '/proc/self/cgroup': fsStub.cGroupContents }); - const cGroup = await readControlGroups(); - - expect(cGroup).toEqual({ - freezer: '/', - net_cls: '/', - net_prio: '/', - pids: '/', - blkio: '/', - memory: '/', - devices: '/user.slice', - hugetlb: '/', - perf_event: '/', - cpu: `/${fsStub.hierarchy}`, - cpuacct: `/${fsStub.hierarchy}`, - cpuset: `/${fsStub.hierarchy}`, - 'name=systemd': '/user.slice/user-1000.slice/session-2359.scope', - }); - }); - }); - - describe('readCPUStat', () => { - it('parses the file', async () => { - setMockFiles({ '/sys/fs/cgroup/cpu/fakeGroup/cpu.stat': fsStub.cpuStatContents }); - const cpuStat = await readCPUStat('fakeGroup'); - - expect(cpuStat).toEqual({ - number_of_elapsed_periods: 0, - number_of_times_throttled: 10, - time_throttled_nanos: 20, - }); - }); - - it('returns default stats for missing file', async () => { - setMockFiles(); - const cpuStat = await readCPUStat('fakeGroup'); - - expect(cpuStat).toEqual({ - number_of_elapsed_periods: -1, - number_of_times_throttled: -1, - time_throttled_nanos: -1, - }); - }); - }); - - describe('getAllStats', () => { - it('can override the cpu group path', async () => { - setMockFiles({ - '/proc/self/cgroup': fsStub.cGroupContents, - [`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408', - '/sys/fs/cgroup/cpu/docker/cpu.cfs_period_us': '100000', - '/sys/fs/cgroup/cpu/docker/cpu.cfs_quota_us': '5000', - '/sys/fs/cgroup/cpu/docker/cpu.stat': fsStub.cpuStatContents, - }); - - const stats = await getAllStats({ cpuPath: '/docker' }); - - expect(stats).toEqual({ - cpuacct: { - control_group: `/${fsStub.hierarchy}`, - usage_nanos: 357753491408, - }, - cpu: { - control_group: '/docker', - cfs_period_micros: 100000, - cfs_quota_micros: 5000, - stat: { - number_of_elapsed_periods: 0, - number_of_times_throttled: 10, - time_throttled_nanos: 20, - }, - }, - }); - }); - - it('handles an undefined control group', async () => { - setMockFiles({ - '/proc/self/cgroup': '', - [`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408', - [`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents, - [`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000', - [`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000', - }); - - const stats = await getAllStats(); - - expect(stats).toBe(null); - }); - - it('can override the cpuacct group path', async () => { - setMockFiles({ - '/proc/self/cgroup': fsStub.cGroupContents, - '/sys/fs/cgroup/cpuacct/docker/cpuacct.usage': '357753491408', - [`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000', - [`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000', - [`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents, - }); - - const stats = await getAllStats({ cpuAcctPath: '/docker' }); - - expect(stats).toEqual({ - cpuacct: { - control_group: '/docker', - usage_nanos: 357753491408, - }, - cpu: { - control_group: `/${fsStub.hierarchy}`, - cfs_period_micros: 100000, - cfs_quota_micros: 5000, - stat: { - number_of_elapsed_periods: 0, - number_of_times_throttled: 10, - time_throttled_nanos: 20, - }, - }, - }); - }); - - it('extracts control group stats', async () => { - setMockFiles(fsStub.files); - const stats = await getAllStats(); - - expect(stats).toEqual({ - cpuacct: { - control_group: `/${fsStub.hierarchy}`, - usage_nanos: 357753491408, - }, - cpu: { - control_group: `/${fsStub.hierarchy}`, - cfs_period_micros: 100000, - cfs_quota_micros: 5000, - stat: { - number_of_elapsed_periods: 0, - number_of_times_throttled: 10, - time_throttled_nanos: 20, - }, - }, - }); - }); - - it('returns null when all files are missing', async () => { - setMockFiles(); - const stats = await getAllStats(); - expect(stats).toBeNull(); - }); - - it('returns null if CPU accounting files are missing', async () => { - setMockFiles({ - '/proc/self/cgroup': fsStub.cGroupContents, - [`${fsStub.cpuDir}/cpu.stat`]: fsStub.cpuStatContents, - }); - const stats = await getAllStats(); - - expect(stats).toBeNull(); - }); - - it('returns -1 stat values if cpuStat file is missing', async () => { - setMockFiles({ - '/proc/self/cgroup': fsStub.cGroupContents, - [`${fsStub.cpuAcctDir}/cpuacct.usage`]: '357753491408', - [`${fsStub.cpuDir}/cpu.cfs_period_us`]: '100000', - [`${fsStub.cpuDir}/cpu.cfs_quota_us`]: '5000', - }); - const stats = await getAllStats(); - - expect(stats).toEqual({ - cpu: { - cfs_period_micros: 100000, - cfs_quota_micros: 5000, - control_group: `/${fsStub.hierarchy}`, - stat: { - number_of_elapsed_periods: -1, - number_of_times_throttled: -1, - time_throttled_nanos: -1, - }, - }, - cpuacct: { - control_group: `/${fsStub.hierarchy}`, - usage_nanos: 357753491408, - }, - }); - }); - }); -}); diff --git a/src/legacy/server/status/lib/get_kibana_info_for_stats.js b/src/legacy/server/status/lib/get_kibana_info_for_stats.js deleted file mode 100644 index 62628a2c40ff9..0000000000000 --- a/src/legacy/server/status/lib/get_kibana_info_for_stats.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { get } from 'lodash'; - -const snapshotRegex = /-snapshot/i; - -/** - * This provides a meta data attribute along with Kibana stats. - * - * @param {Object} kbnServer manager of Kibana services - see `src/legacy/server/kbn_server` in Kibana core - * @param {Object} config Server config - * @param {String} host Kibana host - * @return {Object} The object containing a "kibana" field and source instance details. - */ -export function getKibanaInfoForStats(server, kbnServer) { - const config = server.config(); - const status = kbnServer.status.toJSON(); - - return { - uuid: config.get('server.uuid'), - name: config.get('server.name'), - index: config.get('kibana.index'), - host: config.get('server.host'), - locale: config.get('i18n.locale'), - transport_address: `${config.get('server.host')}:${config.get('server.port')}`, - version: kbnServer.version.replace(snapshotRegex, ''), - snapshot: snapshotRegex.test(kbnServer.version), - status: get(status, 'overall.state'), - }; -} diff --git a/src/legacy/server/status/lib/get_os_info.js b/src/legacy/server/status/lib/get_os_info.js deleted file mode 100644 index e3835fec34c88..0000000000000 --- a/src/legacy/server/status/lib/get_os_info.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import os from 'os'; -import getos from 'getos'; -import { promisify } from 'util'; - -/** - * Returns an object of OS information/ - */ -export async function getOSInfo() { - const osInfo = { - platform: os.platform(), - // Include the platform name in the release to avoid grouping unrelated platforms together. - // release 1.0 across windows, linux, and darwin don't mean anything useful. - platformRelease: `${os.platform()}-${os.release()}`, - }; - - // Get distribution information for linux - if (os.platform() === 'linux') { - try { - const distro = await promisify(getos)(); - osInfo.distro = distro.dist; - // Include distro name in release for same reason as above. - osInfo.distroRelease = `${distro.dist}-${distro.release}`; - } catch (e) { - // ignore errors - } - } - - return osInfo; -} diff --git a/src/legacy/server/status/lib/get_os_info.test.js b/src/legacy/server/status/lib/get_os_info.test.js deleted file mode 100644 index 11af7e1588090..0000000000000 --- a/src/legacy/server/status/lib/get_os_info.test.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('os', () => ({ - platform: jest.fn(), - release: jest.fn(), -})); -jest.mock('getos'); - -import os from 'os'; -import getos from 'getos'; - -import { getOSInfo } from './get_os_info'; - -describe('getOSInfo', () => { - it('returns basic OS info on non-linux', async () => { - os.platform.mockImplementation(() => 'darwin'); - os.release.mockImplementation(() => '1.0.0'); - - const osInfo = await getOSInfo(); - - expect(osInfo).toEqual({ - platform: 'darwin', - platformRelease: 'darwin-1.0.0', - }); - }); - - it('returns basic OS info and distro info on linux', async () => { - os.platform.mockImplementation(() => 'linux'); - os.release.mockImplementation(() => '4.9.93-linuxkit-aufs'); - - // Mock getos response - getos.mockImplementation((cb) => - cb(null, { - os: 'linux', - dist: 'Ubuntu Linux', - codename: 'precise', - release: '12.04', - }) - ); - - const osInfo = await getOSInfo(); - - expect(osInfo).toEqual({ - platform: 'linux', - platformRelease: 'linux-4.9.93-linuxkit-aufs', - // linux distro info - distro: 'Ubuntu Linux', - distroRelease: 'Ubuntu Linux-12.04', - }); - }); -}); diff --git a/src/legacy/server/status/lib/metrics.js b/src/legacy/server/status/lib/metrics.js deleted file mode 100644 index 478bf0829b1aa..0000000000000 --- a/src/legacy/server/status/lib/metrics.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import os from 'os'; -import v8 from 'v8'; -import { get, isObject, merge } from 'lodash'; -import { keysToSnakeCaseShallow } from './case_conversion'; -import { getAllStats as cGroupStats } from './cgroup'; -import { getOSInfo } from './get_os_info'; - -const requestDefaults = { - disconnects: 0, - statusCodes: {}, - total: 0, -}; - -export class Metrics { - constructor(config, server) { - this.config = config; - this.server = server; - this.checkCGroupStats = true; - } - - static getStubMetrics() { - return { - process: { - memory: { - heap: {}, - }, - }, - os: { - cpu: {}, - memory: {}, - }, - response_times: {}, - requests: {}, - }; - } - - async capture(hapiEvent) { - const timestamp = new Date().toISOString(); - const event = await this.captureEvent(hapiEvent); - const cgroup = await this.captureCGroupsIfAvailable(); - - const metrics = { - last_updated: timestamp, - collection_interval_in_millis: this.config.get('ops.interval'), - }; - - return merge(metrics, event, cgroup); - } - - async captureEvent(hapiEvent) { - const heapStats = v8.getHeapStatistics(); - const port = this.config.get('server.port'); - const avgInMillis = get(hapiEvent, ['responseTimes', port, 'avg']); // sadly, it's possible for this to be NaN - const maxInMillis = get(hapiEvent, ['responseTimes', port, 'max']); - - return { - process: { - memory: { - heap: { - // https://nodejs.org/docs/latest-v8.x/api/process.html#process_process_memoryusage - total_in_bytes: get(hapiEvent, 'psmem.heapTotal'), - used_in_bytes: get(hapiEvent, 'psmem.heapUsed'), - size_limit: heapStats.heap_size_limit, - }, - resident_set_size_in_bytes: get(hapiEvent, 'psmem.rss'), - }, - event_loop_delay: get(hapiEvent, 'psdelay'), - pid: process.pid, - uptime_in_millis: process.uptime() * 1000, - }, - os: { - load: { - '1m': get(hapiEvent, 'osload.0'), - '5m': get(hapiEvent, 'osload.1'), - '15m': get(hapiEvent, 'osload.2'), - }, - memory: { - total_in_bytes: os.totalmem(), - free_in_bytes: os.freemem(), - used_in_bytes: get(hapiEvent, 'osmem.total') - get(hapiEvent, 'osmem.free'), - }, - uptime_in_millis: os.uptime() * 1000, - ...(await getOSInfo()), - }, - response_times: { - avg_in_millis: isNaN(avgInMillis) ? undefined : avgInMillis, // convert NaN to undefined - max_in_millis: maxInMillis, - }, - requests: { - ...requestDefaults, - ...keysToSnakeCaseShallow(get(hapiEvent, ['requests', port])), - }, - concurrent_connections: hapiEvent.concurrent_connections, - }; - } - - async captureCGroups() { - try { - const cgroup = await cGroupStats({ - cpuPath: this.config.get('ops.cGroupOverrides.cpuPath'), - cpuAcctPath: this.config.get('ops.cGroupOverrides.cpuAcctPath'), - }); - - if (isObject(cgroup)) { - return { - os: { - cgroup, - }, - }; - } - } catch (e) { - this.server.log(['error', 'metrics', 'cgroup'], e); - } - } - - async captureCGroupsIfAvailable() { - if (this.checkCGroupStats === true) { - const cgroup = await this.captureCGroups(); - - if (isObject(cgroup)) { - return cgroup; - } - - this.checkCGroupStats = false; - } - } -} diff --git a/src/legacy/server/status/lib/metrics.test.js b/src/legacy/server/status/lib/metrics.test.js deleted file mode 100644 index cc9c2607a2b59..0000000000000 --- a/src/legacy/server/status/lib/metrics.test.js +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('fs', () => ({ - readFile: jest.fn(), -})); - -jest.mock('os', () => ({ - freemem: jest.fn(), - totalmem: jest.fn(), - uptime: jest.fn(), - platform: jest.fn(), - release: jest.fn(), -})); - -jest.mock('process', () => ({ - uptime: jest.fn(), -})); - -import fs from 'fs'; -import os from 'os'; -import _ from 'lodash'; -import sinon from 'sinon'; -import { cGroups as cGroupsFsStub, setMockFiles, readFileMock } from './__mocks__/_fs_stubs'; -import { Metrics } from './metrics'; - -describe('Metrics', function () { - fs.readFile.mockImplementation(readFileMock); - - const sampleConfig = { - ops: { - interval: 5000, - }, - server: { - port: 5603, - }, - }; - const config = { get: (path) => _.get(sampleConfig, path) }; - - let metrics; - - beforeEach(() => { - const server = { log: sinon.mock() }; - - metrics = new Metrics(config, server); - }); - - afterEach(() => { - setMockFiles(); - }); - - describe('capture', () => { - it('merges all metrics', async () => { - setMockFiles(); - sinon - .stub(metrics, 'captureEvent') - .returns({ a: [{ b: 2 }, { d: 4 }], process: { uptime_ms: 1980 } }); - sinon.stub(metrics, 'captureCGroupsIfAvailable').returns({ a: [{ c: 3 }, { e: 5 }] }); - sinon.stub(Date.prototype, 'toISOString').returns('2017-04-14T18:35:41.534Z'); - - const capturedMetrics = await metrics.capture(); - expect(capturedMetrics).toMatchObject({ - last_updated: '2017-04-14T18:35:41.534Z', - collection_interval_in_millis: 5000, - a: [ - { b: 2, c: 3 }, - { d: 4, e: 5 }, - ], - process: { uptime_ms: 1980 }, - }); - }); - }); - - describe('captureEvent', () => { - it('parses the hapi event', async () => { - sinon.stub(os, 'uptime').returns(12000); - sinon.stub(process, 'uptime').returns(5000); - - os.freemem.mockImplementation(() => 12); - os.totalmem.mockImplementation(() => 24); - - const pidMock = jest.fn(); - pidMock.mockReturnValue(8675309); - Object.defineProperty(process, 'pid', { get: pidMock }); // - - const hapiEvent = { - requests: { 5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } } }, - responseTimes: { 5603: { avg: 1.8636363636363635, max: 4 } }, - osload: [2.20751953125, 2.02294921875, 1.89794921875], - osmem: { total: 17179869184, free: 102318080 }, - osup: 1008991, - psup: 7.168, - psmem: { rss: 193716224, heapTotal: 168194048, heapUsed: 130553400, external: 1779619 }, - concurrent_connections: 0, - psdelay: 1.6091690063476562, - host: 'blahblah.local', - }; - - expect(await metrics.captureEvent(hapiEvent)).toMatchObject({ - concurrent_connections: 0, - os: { - load: { - '15m': 1.89794921875, - '1m': 2.20751953125, - '5m': 2.02294921875, - }, - memory: { - free_in_bytes: 12, - total_in_bytes: 24, - }, - uptime_in_millis: 12000000, - }, - process: { - memory: { - heap: { - total_in_bytes: 168194048, - used_in_bytes: 130553400, - }, - resident_set_size_in_bytes: 193716224, - }, - pid: 8675309, - }, - requests: { - disconnects: 0, - total: 22, - }, - response_times: { - avg_in_millis: 1.8636363636363635, - max_in_millis: 4, - }, - }); - }); - - it('parses event with missing fields / NaN for responseTimes.avg', async () => { - const hapiEvent = { - requests: { - 5603: { total: 22, disconnects: 0, statusCodes: { 200: 22 } }, - }, - responseTimes: { 5603: { avg: NaN, max: 4 } }, - host: 'blahblah.local', - }; - - expect(await metrics.captureEvent(hapiEvent)).toMatchObject({ - process: { memory: { heap: {} }, pid: 8675309, uptime_in_millis: 5000000 }, - os: { - load: {}, - memory: { free_in_bytes: 12, total_in_bytes: 24 }, - }, - response_times: { max_in_millis: 4 }, - requests: { total: 22, disconnects: 0 }, - }); - }); - }); - - describe('captureCGroups', () => { - afterEach(() => { - setMockFiles(); - }); - - it('returns undefined if cgroups do not exist', async () => { - setMockFiles(); - - const stats = await metrics.captureCGroups(); - - expect(stats).toBe(undefined); - }); - - it('returns cgroups', async () => { - const fsStub = cGroupsFsStub(); - setMockFiles(fsStub.files); - - const capturedMetrics = await metrics.captureCGroups(); - - expect(capturedMetrics).toMatchObject({ - os: { - cgroup: { - cpuacct: { - control_group: `/${fsStub.hierarchy}`, - usage_nanos: 357753491408, - }, - cpu: { - control_group: `/${fsStub.hierarchy}`, - cfs_period_micros: 100000, - cfs_quota_micros: 5000, - stat: { - number_of_elapsed_periods: 0, - number_of_times_throttled: 10, - time_throttled_nanos: 20, - }, - }, - }, - }, - }); - }); - }); - - describe('captureCGroupsIfAvailable', () => { - afterEach(() => { - setMockFiles(); - }); - - it('marks cgroups as unavailable and prevents subsequent calls', async () => { - setMockFiles(); - sinon.spy(metrics, 'captureCGroups'); - - expect(metrics.checkCGroupStats).toBe(true); - - await metrics.captureCGroupsIfAvailable(); - expect(metrics.checkCGroupStats).toBe(false); - - await metrics.captureCGroupsIfAvailable(); - sinon.assert.calledOnce(metrics.captureCGroups); - }); - - it('allows subsequent calls if cgroups are available', async () => { - const fsStub = cGroupsFsStub(); - setMockFiles(fsStub.files); - sinon.spy(metrics, 'captureCGroups'); - - expect(metrics.checkCGroupStats).toBe(true); - - await metrics.captureCGroupsIfAvailable(); - expect(metrics.checkCGroupStats).toBe(true); - - await metrics.captureCGroupsIfAvailable(); - sinon.assert.calledTwice(metrics.captureCGroups); - }); - }); -}); diff --git a/src/legacy/server/status/routes/api/register_stats.js b/src/legacy/server/status/routes/api/register_stats.js deleted file mode 100644 index 2cd780d21f681..0000000000000 --- a/src/legacy/server/status/routes/api/register_stats.js +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import Joi from 'joi'; -import boom from 'boom'; -import { defaultsDeep } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { wrapAuthConfig } from '../../wrap_auth_config'; -import { getKibanaInfoForStats } from '../../lib'; - -const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { - defaultMessage: 'Stats are not ready yet. Please try again later.', -}); - -/* - * API for Kibana meta info and accumulated operations stats - * Including ?extended in the query string fetches Elasticsearch cluster_uuid and usageCollection data - * - Requests to set isExtended = true - * GET /api/stats?extended=true - * GET /api/stats?extended - * - No value or 'false' is isExtended = false - * - Any other value causes a statusCode 400 response (Bad Request) - * Including ?exclude_usage in the query string excludes the usage stats from the response. Same value semantics as ?extended - */ -export function registerStatsApi(usageCollection, server, config, kbnServer) { - const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous')); - - const getClusterUuid = async (callCluster) => { - const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' }); - return uuid; - }; - - const getUsage = async (callCluster) => { - const usage = await usageCollection.bulkFetchUsage(callCluster); - return usageCollection.toObject(usage); - }; - - let lastMetrics = null; - /* kibana_stats gets singled out from the collector set as it is used - * for health-checking Kibana and fetch does not rely on fetching data - * from ES */ - server.newPlatform.start.core.metrics.getOpsMetrics$().subscribe((metrics) => { - lastMetrics = { - ...metrics, - timestamp: new Date().toISOString(), - }; - }); - - server.route( - wrapAuth({ - method: 'GET', - path: '/api/stats', - config: { - validate: { - query: Joi.object({ - extended: Joi.string().valid('', 'true', 'false'), - legacy: Joi.string().valid('', 'true', 'false'), - exclude_usage: Joi.string().valid('', 'true', 'false'), - }), - }, - tags: ['api'], - }, - async handler(req) { - const isExtended = req.query.extended !== undefined && req.query.extended !== 'false'; - const isLegacy = req.query.legacy !== undefined && req.query.legacy !== 'false'; - const shouldGetUsage = - req.query.exclude_usage === undefined || req.query.exclude_usage === 'false'; - - let extended; - if (isExtended) { - const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('admin'); - const callCluster = (...args) => callWithRequest(req, ...args); - const collectorsReady = await usageCollection.areAllCollectorsReady(); - - if (shouldGetUsage && !collectorsReady) { - return boom.serverUnavailable(STATS_NOT_READY_MESSAGE); - } - - const usagePromise = shouldGetUsage ? getUsage(callCluster) : Promise.resolve({}); - try { - const [usage, clusterUuid] = await Promise.all([ - usagePromise, - getClusterUuid(callCluster), - ]); - - let modifiedUsage = usage; - if (isLegacy) { - // In an effort to make telemetry more easily augmented, we need to ensure - // we can passthrough the data without every part of the process needing - // to know about the change; however, to support legacy use cases where this - // wasn't true, we need to be backwards compatible with how the legacy data - // looked and support those use cases here. - modifiedUsage = Object.keys(usage).reduce((accum, usageKey) => { - if (usageKey === 'kibana') { - accum = { - ...accum, - ...usage[usageKey], - }; - } else if (usageKey === 'reporting') { - accum = { - ...accum, - xpack: { - ...accum.xpack, - reporting: usage[usageKey], - }, - }; - } else { - // I don't think we need to it this for the above conditions, but do it for most as it will - // match the behavior done in monitoring/bulk_uploader - defaultsDeep(accum, { [usageKey]: usage[usageKey] }); - } - - return accum; - }, {}); - - extended = { - usage: modifiedUsage, - clusterUuid, - }; - } else { - extended = usageCollection.toApiFieldNames({ - usage: modifiedUsage, - clusterUuid, - }); - } - } catch (e) { - throw boom.boomify(e); - } - } - - if (!lastMetrics) { - return boom.serverUnavailable(STATS_NOT_READY_MESSAGE); - } - const kibanaStats = usageCollection.toApiFieldNames({ - ...lastMetrics, - kibana: getKibanaInfoForStats(server, kbnServer), - last_updated: new Date().toISOString(), - collection_interval_in_millis: config.get('ops.interval'), - }); - - return { - ...kibanaStats, - ...extended, - }; - }, - }) - ); -} diff --git a/src/legacy/server/status/routes/api/register_status.js b/src/legacy/server/status/routes/api/register_status.js deleted file mode 100644 index 259a00667810f..0000000000000 --- a/src/legacy/server/status/routes/api/register_status.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { wrapAuthConfig } from '../../wrap_auth_config'; - -const matchSnapshot = /-SNAPSHOT$/; - -export function registerStatusApi(kbnServer, server, config) { - const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous')); - - server.route( - wrapAuth({ - method: 'GET', - path: '/api/status', - config: { - tags: ['api'], - }, - async handler() { - return { - name: config.get('server.name'), - uuid: config.get('server.uuid'), - version: { - number: config.get('pkg.version').replace(matchSnapshot, ''), - build_hash: config.get('pkg.buildSha'), - build_number: config.get('pkg.buildNum'), - build_snapshot: matchSnapshot.test(config.get('pkg.version')), - }, - status: kbnServer.status.toJSON(), - metrics: kbnServer.metrics, - }; - }, - }) - ); -} diff --git a/src/legacy/server/status/samples.js b/src/legacy/server/status/samples.js deleted file mode 100644 index 9c41e29945a77..0000000000000 --- a/src/legacy/server/status/samples.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -function Samples(max) { - this.vals = {}; - this.max = max || Infinity; - this.length = 0; -} - -Samples.prototype.add = function (sample) { - const vals = this.vals; - const length = (this.length = Math.min(this.length + 1, this.max)); - - _.forOwn(sample, function (val, name) { - if (val == null) val = null; - - if (!vals[name]) vals[name] = new Array(length); - vals[name].unshift([Date.now(), val]); - vals[name].length = length; - }); -}; - -Samples.prototype.toJSON = function () { - return this.vals; -}; - -export default Samples; diff --git a/src/legacy/server/status/server_status.js b/src/legacy/server/status/server_status.js deleted file mode 100644 index 81d07de55faaf..0000000000000 --- a/src/legacy/server/status/server_status.js +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -import * as states from './states'; -import Status from './status'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { pkg } from '../../../core/server/utils'; - -export default class ServerStatus { - constructor(server) { - this.server = server; - this._created = {}; - } - - create(id) { - const status = new Status(id, this.server); - this._created[status.id] = status; - return status; - } - - createForPlugin(plugin) { - if (plugin.version === 'kibana') plugin.version = pkg.version; - const status = this.create(`plugin:${plugin.id}@${plugin.version}`); - status.plugin = plugin; - return status; - } - - each(fn) { - const self = this; - _.forOwn(self._created, function (status, i, list) { - if (status.state !== 'disabled') { - fn.call(self, status, i, list); - } - }); - } - - get(id) { - return this._created[id]; - } - - getForPluginId(pluginId) { - return _.find(this._created, (s) => s.plugin && s.plugin.id === pluginId); - } - - getState(id) { - const status = this.get(id); - if (!status) return undefined; - return status.state || 'uninitialized'; - } - - getStateForPluginId(pluginId) { - const status = this.getForPluginId(pluginId); - if (!status) return undefined; - return status.state || 'uninitialized'; - } - - overall() { - const state = Object - // take all created status objects - .values(this._created) - // get the state descriptor for each status - .map((status) => states.get(status.state)) - // reduce to the state with the highest severity, defaulting to green - .reduce((a, b) => (a.severity > b.severity ? a : b), states.get('green')); - - const statuses = _.filter(this._created, { state: state.id }); - const since = _.get(_.sortBy(statuses, 'since'), [0, 'since']); - - return { - state: state.id, - title: state.title, - nickname: _.sample(state.nicknames), - icon: state.icon, - uiColor: states.get(state.id).uiColor, - since: since, - }; - } - - isGreen() { - return this.overall().state === 'green'; - } - - notGreen() { - return !this.isGreen(); - } - - toString() { - const overall = this.overall(); - return `${overall.title} – ${overall.nickname}`; - } - - toJSON() { - return { - overall: this.overall(), - statuses: _.values(this._created), - }; - } -} diff --git a/src/legacy/server/status/server_status.test.js b/src/legacy/server/status/server_status.test.js deleted file mode 100644 index bf94d693b1310..0000000000000 --- a/src/legacy/server/status/server_status.test.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { find } from 'lodash'; -import sinon from 'sinon'; - -import * as states from './states'; -import Status from './status'; -import ServerStatus from './server_status'; - -describe('ServerStatus class', function () { - const plugin = { id: 'name', version: '1.2.3' }; - - let server; - let serverStatus; - - beforeEach(function () { - server = { expose: sinon.stub(), logWithMetadata: sinon.stub() }; - serverStatus = new ServerStatus(server); - }); - - describe('#create(id)', () => { - it('should create a new plugin with an id', () => { - const status = serverStatus.create('someid'); - expect(status).toBeInstanceOf(Status); - }); - }); - - describe('#createForPlugin(plugin)', function () { - it('should create a new status by plugin', function () { - const status = serverStatus.createForPlugin(plugin); - expect(status).toBeInstanceOf(Status); - }); - }); - - describe('#get(id)', () => { - it('exposes statuses by their id', () => { - const status = serverStatus.create('statusid'); - expect(serverStatus.get('statusid')).toBe(status); - }); - - it('does not get the status for a plugin', () => { - serverStatus.createForPlugin(plugin); - expect(serverStatus.get(plugin)).toBe(undefined); - }); - }); - - describe('#getForPluginId(plugin)', function () { - it('exposes plugin status for the plugin', function () { - const status = serverStatus.createForPlugin(plugin); - expect(serverStatus.getForPluginId(plugin.id)).toBe(status); - }); - - it('does not get plain statuses by their id', function () { - serverStatus.create('someid'); - expect(serverStatus.getForPluginId('someid')).toBe(undefined); - }); - }); - - describe('#getState(id)', function () { - it('should expose the state of a status by id', function () { - const status = serverStatus.create('someid'); - status.green(); - expect(serverStatus.getState('someid')).toBe('green'); - }); - }); - - describe('#getStateForPluginId(plugin)', function () { - it('should expose the state of a plugin by id', function () { - const status = serverStatus.createForPlugin(plugin); - status.green(); - expect(serverStatus.getStateForPluginId(plugin.id)).toBe('green'); - }); - }); - - describe('#overall()', function () { - it('considers each status to produce a summary', function () { - const status = serverStatus.createForPlugin(plugin); - - expect(serverStatus.overall().state).toBe('uninitialized'); - - const match = function (overall, state) { - expect(overall).toHaveProperty('state', state.id); - expect(overall).toHaveProperty('title', state.title); - expect(overall).toHaveProperty('icon', state.icon); - expect(overall).toHaveProperty('uiColor', state.uiColor); - expect(state.nicknames).toContain(overall.nickname); - }; - - status.green(); - match(serverStatus.overall(), states.get('green')); - - status.yellow(); - match(serverStatus.overall(), states.get('yellow')); - - status.red(); - match(serverStatus.overall(), states.get('red')); - }); - }); - - describe('#toJSON()', function () { - it('serializes to overall status and individuals', function () { - const pluginOne = { id: 'one', version: '1.0.0' }; - const pluginTwo = { id: 'two', version: '2.0.0' }; - const pluginThree = { id: 'three', version: 'kibana' }; - - const service = serverStatus.create('some service'); - const p1 = serverStatus.createForPlugin(pluginOne); - const p2 = serverStatus.createForPlugin(pluginTwo); - const p3 = serverStatus.createForPlugin(pluginThree); - - service.green(); - p1.yellow(); - p2.red(); - - const json = JSON.parse(JSON.stringify(serverStatus)); - expect(json).toHaveProperty('overall'); - expect(json.overall.state).toEqual(serverStatus.overall().state); - expect(json.statuses).toHaveLength(4); - - const out = (status) => find(json.statuses, { id: status.id }); - expect(out(service)).toHaveProperty('state', 'green'); - expect(out(p1)).toHaveProperty('state', 'yellow'); - expect(out(p2)).toHaveProperty('state', 'red'); - expect(out(p3)).toHaveProperty('id'); - expect(out(p3).id).not.toContain('undefined'); - }); - }); -}); diff --git a/src/legacy/server/status/states.js b/src/legacy/server/status/states.js deleted file mode 100644 index 4a34684571c3c..0000000000000 --- a/src/legacy/server/status/states.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; - -export const getAll = () => [ - { - id: 'red', - title: i18n.translate('server.status.redTitle', { - defaultMessage: 'Red', - }), - icon: 'danger', - uiColor: 'danger', - severity: 1000, - nicknames: ['Danger Will Robinson! Danger!'], - }, - { - id: 'uninitialized', - title: i18n.translate('server.status.uninitializedTitle', { - defaultMessage: 'Uninitialized', - }), - icon: 'spinner', - uiColor: 'default', - severity: 900, - nicknames: ['Initializing'], - }, - { - id: 'yellow', - title: i18n.translate('server.status.yellowTitle', { - defaultMessage: 'Yellow', - }), - icon: 'warning', - uiColor: 'warning', - severity: 800, - nicknames: ['S.N.A.F.U', "I'll be back", 'brb'], - }, - { - id: 'green', - title: i18n.translate('server.status.greenTitle', { - defaultMessage: 'Green', - }), - icon: 'success', - uiColor: 'secondary', - severity: 0, - nicknames: ['Looking good'], - }, - { - id: 'disabled', - title: i18n.translate('server.status.disabledTitle', { - defaultMessage: 'Disabled', - }), - severity: -1, - icon: 'toggle-off', - uiColor: 'default', - nicknames: ['Am I even a thing?'], - }, -]; - -export const getAllById = () => _.keyBy(exports.getAll(), 'id'); - -export const defaults = { - icon: 'question', - severity: Infinity, -}; - -export function get(id) { - return exports.getAllById()[id] || _.defaults({ id: id }, exports.defaults); -} diff --git a/src/legacy/server/status/status.js b/src/legacy/server/status/status.js deleted file mode 100644 index 10e94da3ac352..0000000000000 --- a/src/legacy/server/status/status.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as states from './states'; -import { EventEmitter } from 'events'; - -export default class Status extends EventEmitter { - constructor(id, server) { - super(); - - if (!id || typeof id !== 'string') { - throw new TypeError('Status constructor requires an `id` string'); - } - - this.id = id; - this.since = new Date(); - this.state = 'uninitialized'; - this.message = 'uninitialized'; - - this.on('change', function (previous, previousMsg) { - this.since = new Date(); - - const tags = ['status', this.id, this.state === 'red' ? 'error' : 'info']; - - server.logWithMetadata( - tags, - `Status changed from ${previous} to ${this.state}${ - this.message ? ' - ' + this.message : '' - }`, - { - state: this.state, - message: this.message, - prevState: previous, - prevMsg: previousMsg, - } - ); - }); - } - - toJSON() { - return { - id: this.id, - state: this.state, - icon: states.get(this.state).icon, - message: this.message, - uiColor: states.get(this.state).uiColor, - since: this.since, - }; - } - - on(eventName, handler) { - super.on(eventName, handler); - - if (eventName === this.state) { - setImmediate(() => handler(this.state, this.message)); - } - } - - once(eventName, handler) { - if (eventName === this.state) { - setImmediate(() => handler(this.state, this.message)); - } else { - super.once(eventName, handler); - } - } -} - -states.getAll().forEach(function (state) { - Status.prototype[state.id] = function (message) { - if (this.state === 'disabled') return; - - const previous = this.state; - const previousMsg = this.message; - - this.error = null; - this.message = message || state.title; - this.state = state.id; - - if (message instanceof Error) { - this.error = message; - this.message = message.message; - } - - if (previous === this.state && previousMsg === this.message) { - // noop - return; - } - - this.emit(state.id, previous, previousMsg, this.state, this.message); - this.emit('change', previous, previousMsg, this.state, this.message); - }; -}); diff --git a/src/legacy/server/status/status.test.js b/src/legacy/server/status/status.test.js deleted file mode 100644 index def7b5a2182e1..0000000000000 --- a/src/legacy/server/status/status.test.js +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import ServerStatus from './server_status'; - -describe('Status class', function () { - const plugin = { id: 'test', version: '1.2.3' }; - - let server; - let serverStatus; - - beforeEach(function () { - server = { expose: sinon.stub(), logWithMetadata: sinon.stub() }; - serverStatus = new ServerStatus(server); - }); - - it('should have an "uninitialized" state initially', () => { - expect(serverStatus.createForPlugin(plugin)).toHaveProperty('state', 'uninitialized'); - }); - - it('emits change when the status is set', function (done) { - const status = serverStatus.createForPlugin(plugin); - - status.once('change', function (prevState, prevMsg, newState, newMsg) { - expect(newState).toBe('green'); - expect(newMsg).toBe('GREEN'); - expect(prevState).toBe('uninitialized'); - - status.once('change', function (prevState, prevMsg, newState, newMsg) { - expect(newState).toBe('red'); - expect(newMsg).toBe('RED'); - expect(prevState).toBe('green'); - expect(prevMsg).toBe('GREEN'); - - done(); - }); - - status.red('RED'); - }); - - status.green('GREEN'); - }); - - it('should only trigger the change listener when something changes', function () { - const status = serverStatus.createForPlugin(plugin); - const stub = sinon.stub(); - status.on('change', stub); - status.green('Ready'); - status.green('Ready'); - status.red('Not Ready'); - sinon.assert.calledTwice(stub); - }); - - it('should create a JSON representation of the status', function () { - const status = serverStatus.createForPlugin(plugin); - status.green('Ready'); - - const json = status.toJSON(); - expect(json.id).toEqual(status.id); - expect(json.state).toEqual('green'); - expect(json.message).toEqual('Ready'); - }); - - it('should call on handler if status is already matched', function (done) { - const status = serverStatus.createForPlugin(plugin); - const msg = 'Test Ready'; - status.green(msg); - - status.on('green', function (prev, prevMsg) { - expect(arguments.length).toBe(2); - expect(prev).toBe('green'); - expect(prevMsg).toBe(msg); - expect(status.message).toBe(msg); - done(); - }); - }); - - it('should call once handler if status is already matched', function (done) { - const status = serverStatus.createForPlugin(plugin); - const msg = 'Test Ready'; - status.green(msg); - - status.once('green', function (prev, prevMsg) { - expect(arguments.length).toBe(2); - expect(prev).toBe('green'); - expect(prevMsg).toBe(msg); - expect(status.message).toBe(msg); - done(); - }); - }); - - function testState(color) { - it(`should change the state to ${color} when #${color}() is called`, function () { - const status = serverStatus.createForPlugin(plugin); - const message = 'testing ' + color; - status[color](message); - expect(status).toHaveProperty('state', color); - expect(status).toHaveProperty('message', message); - }); - - it(`should trigger the "change" listener when #${color}() is called`, function (done) { - const status = serverStatus.createForPlugin(plugin); - const message = 'testing ' + color; - status.on('change', function (prev, prevMsg) { - expect(status.state).toBe(color); - expect(status.message).toBe(message); - - expect(prev).toBe('uninitialized'); - expect(prevMsg).toBe('uninitialized'); - done(); - }); - status[color](message); - }); - - it(`should trigger the "${color}" listener when #${color}() is called`, function (done) { - const status = serverStatus.createForPlugin(plugin); - const message = 'testing ' + color; - status.on(color, function () { - expect(status.state).toBe(color); - expect(status.message).toBe(message); - done(); - }); - status[color](message); - }); - } - - testState('green'); - testState('yellow'); - testState('red'); -}); diff --git a/src/legacy/server/status/wrap_auth_config.test.js b/src/legacy/server/status/wrap_auth_config.test.js deleted file mode 100644 index fa0230a96a587..0000000000000 --- a/src/legacy/server/status/wrap_auth_config.test.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { wrapAuthConfig } from './wrap_auth_config'; - -describe('Status wrapAuthConfig', () => { - let options; - - beforeEach(() => { - options = { - method: 'GET', - path: '/status', - handler: function (request, h) { - return h.response(); - }, - }; - }); - - it('should return a function', () => { - expect(typeof wrapAuthConfig()).toBe('function'); - expect(typeof wrapAuthConfig(true)).toBe('function'); - expect(typeof wrapAuthConfig(false)).toBe('function'); - }); - - it('should not add auth config by default', () => { - const wrapAuth = wrapAuthConfig(); - const wrapped = wrapAuth(options); - expect(wrapped).not.toHaveProperty('config'); - }); - - it('should not add auth config if allowAnonymous is false', () => { - const wrapAuth = wrapAuthConfig(false); - const wrapped = wrapAuth(options); - expect(wrapped).not.toHaveProperty('config'); - }); - - it('should add auth config if allowAnonymous is true', () => { - const wrapAuth = wrapAuthConfig(true); - const wrapped = wrapAuth(options); - expect(wrapped).toHaveProperty('config'); - expect(wrapped.config).toHaveProperty('auth'); - expect(wrapped.config.auth).toBe(false); - }); -}); diff --git a/src/plugins/charts/kibana.json b/src/plugins/charts/kibana.json index c4643d541c31c..8967e931a0b10 100644 --- a/src/plugins/charts/kibana.json +++ b/src/plugins/charts/kibana.json @@ -3,5 +3,5 @@ "version": "kibana", "server": true, "ui": true, - "requiredBundles": ["kibanaUtils", "kibanaReact", "data"] + "requiredBundles": ["visDefaultEditor"] } diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index fc88b31711b23..abef8afcc3985 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -182,6 +182,9 @@ function EditorUI({ initialTextValue }: EditorProps) { unsubscribeResizer(); clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); + if (editorInstanceRef.current) { + editorInstanceRef.current.getCoreEditor().destroy(); + } }; }, [saveCurrentTextObject, initialTextValue, history, setInputEditor, settingsService]); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 469ef6d79fae5..393b7eee346f5 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -408,4 +408,8 @@ export class LegacyCoreEditor implements CoreEditor { }, ]); } + + destroy() { + this.editor.destroy(); + } } diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index b71f4fff44ca5..d88d8f86b874c 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -268,4 +268,9 @@ export interface CoreEditor { * detects a change */ registerAutocompleter(autocompleter: AutoCompleterFunction): void; + + /** + * Release any resources in use by the editor. + */ + destroy(): void; } diff --git a/src/plugins/console/server/lib/spec_definitions/js/mappings.ts b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts index aa09278d07553..e6b3d4d5fcb6e 100644 --- a/src/plugins/console/server/lib/spec_definitions/js/mappings.ts +++ b/src/plugins/console/server/lib/spec_definitions/js/mappings.ts @@ -157,58 +157,65 @@ export const mappings = (specService: SpecDefinitionsService) => { // dates format: { + // outer array required to for an array of string values __one_of: [ - ...[ - 'date', - 'date_time', - 'date_time_no_millis', - 'ordinal_date', - 'ordinal_date_time', - 'ordinal_date_time_no_millis', - 'time', - 'time_no_millis', - 't_time', - 't_time_no_millis', - 'week_date', - 'week_date_time', - 'week_date_time_no_millis', - ].map(function (s) { - return ['basic_' + s, 'strict_' + s]; - }), - ...[ - 'date', - 'date_hour', - 'date_hour_minute', - 'date_hour_minute_second', - 'date_hour_minute_second_fraction', - 'date_hour_minute_second_millis', - 'date_optional_time', - 'date_time', - 'date_time_no_millis', - 'hour', - 'hour_minute', - 'hour_minute_second', - 'hour_minute_second_fraction', - 'hour_minute_second_millis', - 'ordinal_date', - 'ordinal_date_time', - 'ordinal_date_time_no_millis', - 'time', - 'time_no_millis', - 't_time', - 't_time_no_millis', - 'week_date', - 'week_date_time', - 'weekDateTimeNoMillis', - 'week_year', - 'weekyearWeek', - 'weekyearWeekDay', - 'year', - 'year_month', - 'year_month_day', - 'epoch_millis', - 'epoch_second', - ], + [ + ...[ + 'date', + 'date_time', + 'date_time_no_millis', + 'ordinal_date', + 'ordinal_date_time', + 'ordinal_date_time_no_millis', + 'time', + 'time_no_millis', + 't_time', + 't_time_no_millis', + 'week_date', + 'week_date_time', + 'week_date_time_no_millis', + ].flatMap(function (s) { + return ['basic_' + s, 'strict_' + s]; + }), + ...[ + 'date', + 'date_hour', + 'date_hour_minute', + 'date_hour_minute_second', + 'date_hour_minute_second_fraction', + 'date_hour_minute_second_millis', + 'date_optional_time', + 'date_time', + 'date_time_no_millis', + 'hour', + 'hour_minute', + 'hour_minute_second', + 'hour_minute_second_fraction', + 'hour_minute_second_millis', + 'ordinal_date', + 'ordinal_date_time', + 'ordinal_date_time_no_millis', + 'time', + 'time_no_millis', + 't_time', + 't_time_no_millis', + 'week_date', + 'week_date_time', + 'weekDateTimeNoMillis', + 'weekyear', + 'strict_weekyear', + 'weekyear_week', + 'strict_weekyear_week', + 'strict_date_optional_time_nanos', + 'weekyear_week_day', + 'strict_weekyear_week_day', + 'year', + 'year_month', + 'year_month_day', + 'epoch_millis', + 'epoch_second', + ], + ].sort(), ], }, diff --git a/src/plugins/data/common/es_query/filters/get_display_value.ts b/src/plugins/data/common/es_query/filters/get_display_value.ts index 28ba0ab629e8f..317d0f0140293 100644 --- a/src/plugins/data/common/es_query/filters/get_display_value.ts +++ b/src/plugins/data/common/es_query/filters/get_display_value.ts @@ -43,7 +43,7 @@ export function getDisplayValueFromFilter(filter: Filter, indexPatterns: IIndexP if (typeof filter.meta.value === 'function') { const indexPattern = getIndexPatternFromFilter(filter, indexPatterns); const valueFormatter: any = getValueFormatter(indexPattern, filter.meta.key); - return filter.meta.value(valueFormatter); + return (filter.meta.value as any)(valueFormatter); } else { return filter.meta.value || ''; } diff --git a/src/plugins/data/common/es_query/filters/meta_filter.ts b/src/plugins/data/common/es_query/filters/meta_filter.ts index e3099ae6a4026..1e892d452f401 100644 --- a/src/plugins/data/common/es_query/filters/meta_filter.ts +++ b/src/plugins/data/common/es_query/filters/meta_filter.ts @@ -22,9 +22,10 @@ export enum FilterStateStore { GLOBAL_STATE = 'globalState', } -export interface FilterState { +// eslint-disable-next-line +export type FilterState = { store: FilterStateStore; -} +}; type FilterFormatterFunction = (value: any) => string; export interface FilterValueFormatter { @@ -32,7 +33,8 @@ export interface FilterValueFormatter { getConverterFor: (type: string) => FilterFormatterFunction; } -export interface FilterMeta { +// eslint-disable-next-line +export type FilterMeta = { alias: string | null; disabled: boolean; negate: boolean; @@ -43,14 +45,15 @@ export interface FilterMeta { type?: string; key?: string; params?: any; - value?: string | ((formatter?: FilterValueFormatter) => string); -} + value?: string; +}; -export interface Filter { +// eslint-disable-next-line +export type Filter = { $state?: FilterState; meta: FilterMeta; query?: any; -} +}; export interface LatLon { lat: number; diff --git a/src/plugins/data/common/query/timefilter/types.ts b/src/plugins/data/common/query/timefilter/types.ts index 60008ce6054e1..82b1ae69cc73b 100644 --- a/src/plugins/data/common/query/timefilter/types.ts +++ b/src/plugins/data/common/query/timefilter/types.ts @@ -24,11 +24,12 @@ export interface RefreshInterval { value: number; } -export interface TimeRange { +// eslint-disable-next-line +export type TimeRange = { from: string; to: string; mode?: 'absolute' | 'relative'; -} +}; export interface TimeRangeBounds { min: Moment | undefined; diff --git a/src/plugins/data/common/query/types.ts b/src/plugins/data/common/query/types.ts index 6b34a1baf293b..c1a98eac5350e 100644 --- a/src/plugins/data/common/query/types.ts +++ b/src/plugins/data/common/query/types.ts @@ -19,7 +19,8 @@ export * from './timefilter/types'; -export interface Query { +// eslint-disable-next-line +export type Query = { query: string | { [key: string]: any }; language: string; -} +}; diff --git a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts index fd788d3339295..d3a95b32cd425 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.test.ts @@ -139,6 +139,19 @@ describe('calculateHistogramInterval', () => { }) ).toEqual(0.02); }); + + test('should correctly fallback to the default value for empty string', () => { + expect( + calculateHistogramInterval({ + ...params, + maxBucketsUserInput: '', + values: { + min: 0.1, + max: 0.9, + }, + }) + ).toBe(0.01); + }); }); }); }); diff --git a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts index f4e42fa8881ef..378340e876296 100644 --- a/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts +++ b/src/plugins/data/common/search/aggs/buckets/lib/histogram_calculate_interval.ts @@ -27,7 +27,7 @@ interface IntervalValuesRange { export interface CalculateHistogramIntervalParams { interval: string; maxBucketsUiSettings: number; - maxBucketsUserInput?: number; + maxBucketsUserInput?: number | ''; intervalBase?: number; values?: IntervalValuesRange; } @@ -77,12 +77,7 @@ const calculateForGivenInterval = ( - The lower power of 10, times 2 - The lower power of 10, times 5 **/ -const calculateAutoInterval = ( - diff: number, - maxBucketsUiSettings: CalculateHistogramIntervalParams['maxBucketsUiSettings'], - maxBucketsUserInput: CalculateHistogramIntervalParams['maxBucketsUserInput'] -) => { - const maxBars = Math.min(maxBucketsUiSettings, maxBucketsUserInput ?? maxBucketsUiSettings); +const calculateAutoInterval = (diff: number, maxBars: number) => { const exactInterval = diff / maxBars; const lowerPower = Math.pow(10, Math.floor(Math.log10(exactInterval))); @@ -122,7 +117,11 @@ export const calculateHistogramInterval = ({ if (diff) { calculatedInterval = isAuto - ? calculateAutoInterval(diff, maxBucketsUiSettings, maxBucketsUserInput) + ? calculateAutoInterval( + diff, + // Mind maxBucketsUserInput can be an empty string, hence we need to ensure it here + Math.min(maxBucketsUiSettings, maxBucketsUserInput || maxBucketsUiSettings) + ) : calculateForGivenInterval(diff, calculatedInterval, maxBucketsUiSettings); } } diff --git a/src/plugins/data/common/search/index.ts b/src/plugins/data/common/search/index.ts index 061974d860246..2ee0db384cf06 100644 --- a/src/plugins/data/common/search/index.ts +++ b/src/plugins/data/common/search/index.ts @@ -20,6 +20,6 @@ export * from './aggs'; export * from './es_search'; export * from './expressions'; +export * from './search_source'; export * from './tabify'; export * from './types'; -export * from './es_search'; diff --git a/src/plugins/data/public/search/search_source/create_search_source.test.ts b/src/plugins/data/common/search/search_source/create_search_source.test.ts similarity index 96% rename from src/plugins/data/public/search/search_source/create_search_source.test.ts rename to src/plugins/data/common/search/search_source/create_search_source.test.ts index 6b6cfb0c9b1ca..dde5983fe73fb 100644 --- a/src/plugins/data/public/search/search_source/create_search_source.test.ts +++ b/src/plugins/data/common/search/search_source/create_search_source.test.ts @@ -19,9 +19,9 @@ import { createSearchSource as createSearchSourceFactory } from './create_search_source'; import { SearchSourceDependencies } from './search_source'; -import { IIndexPattern } from '../../../common/index_patterns'; +import { IIndexPattern } from '../../index_patterns'; import { IndexPatternsContract } from '../../index_patterns/index_patterns'; -import { Filter } from '../../../common/es_query/filters'; +import { Filter } from '../../es_query/filters'; import { BehaviorSubject } from 'rxjs'; describe('createSearchSource', () => { diff --git a/src/plugins/data/public/search/search_source/create_search_source.ts b/src/plugins/data/common/search/search_source/create_search_source.ts similarity index 100% rename from src/plugins/data/public/search/search_source/create_search_source.ts rename to src/plugins/data/common/search/search_source/create_search_source.ts diff --git a/src/plugins/data/public/search/search_source/extract_references.ts b/src/plugins/data/common/search/search_source/extract_references.ts similarity index 94% rename from src/plugins/data/public/search/search_source/extract_references.ts rename to src/plugins/data/common/search/search_source/extract_references.ts index f9987767a9688..72d93e41305d1 100644 --- a/src/plugins/data/public/search/search_source/extract_references.ts +++ b/src/plugins/data/common/search/search_source/extract_references.ts @@ -17,8 +17,8 @@ * under the License. */ -import { SavedObjectReference } from '../../../../../core/types'; -import { Filter } from '../../../common/es_query/filters'; +import { SavedObjectReference } from 'src/core/types'; +import { Filter } from '../../es_query/filters'; import { SearchSourceFields } from './types'; export const extractReferences = ( diff --git a/src/plugins/data/public/search/fetch/get_search_params.test.ts b/src/plugins/data/common/search/search_source/fetch/get_search_params.test.ts similarity index 93% rename from src/plugins/data/public/search/fetch/get_search_params.test.ts rename to src/plugins/data/common/search/search_source/fetch/get_search_params.test.ts index 5e83e1f57bb6d..8778eb4fd559d 100644 --- a/src/plugins/data/public/search/fetch/get_search_params.test.ts +++ b/src/plugins/data/common/search/search_source/fetch/get_search_params.test.ts @@ -17,8 +17,9 @@ * under the License. */ +import { UI_SETTINGS } from '../../../constants'; +import { GetConfigFn } from '../../../types'; import { getSearchParams } from './get_search_params'; -import { GetConfigFn, UI_SETTINGS } from '../../../common'; function getConfigStub(config: any = {}): GetConfigFn { return (key) => config[key]; diff --git a/src/plugins/data/public/search/fetch/get_search_params.ts b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts similarity index 92% rename from src/plugins/data/public/search/fetch/get_search_params.ts rename to src/plugins/data/common/search/search_source/fetch/get_search_params.ts index ed87c4813951c..556fb4924da56 100644 --- a/src/plugins/data/public/search/fetch/get_search_params.ts +++ b/src/plugins/data/common/search/search_source/fetch/get_search_params.ts @@ -17,7 +17,9 @@ * under the License. */ -import { UI_SETTINGS, ISearchRequestParams, GetConfigFn } from '../../../common'; +import { UI_SETTINGS } from '../../../constants'; +import { GetConfigFn } from '../../../types'; +import { ISearchRequestParams } from '../../index'; import { SearchRequest } from './types'; const sessionId = Date.now(); diff --git a/src/legacy/server/status/lib/case_conversion.ts b/src/plugins/data/common/search/search_source/fetch/index.ts similarity index 81% rename from src/legacy/server/status/lib/case_conversion.ts rename to src/plugins/data/common/search/search_source/fetch/index.ts index a3ae15028daeb..1b9a9677e4a99 100644 --- a/src/legacy/server/status/lib/case_conversion.ts +++ b/src/plugins/data/common/search/search_source/fetch/index.ts @@ -17,8 +17,6 @@ * under the License. */ -import { mapKeys, snakeCase } from 'lodash'; - -export function keysToSnakeCaseShallow(object: Record) { - return mapKeys(object, (value, key) => snakeCase(key)); -} +export { getSearchParams, getSearchParamsFromRequest, getPreference } from './get_search_params'; +export { RequestFailure } from './request_error'; +export * from './types'; diff --git a/src/plugins/data/public/search/fetch/request_error.ts b/src/plugins/data/common/search/search_source/fetch/request_error.ts similarity index 95% rename from src/plugins/data/public/search/fetch/request_error.ts rename to src/plugins/data/common/search/search_source/fetch/request_error.ts index efaaafadf404e..ba5eb6f2897a9 100644 --- a/src/plugins/data/public/search/fetch/request_error.ts +++ b/src/plugins/data/common/search/search_source/fetch/request_error.ts @@ -18,7 +18,7 @@ */ import { SearchResponse } from 'elasticsearch'; -import { KbnError } from '../../../../kibana_utils/common'; +import { KbnError } from '../../../../../kibana_utils/common'; import { SearchError } from './types'; /** diff --git a/src/plugins/data/public/search/fetch/types.ts b/src/plugins/data/common/search/search_source/fetch/types.ts similarity index 97% rename from src/plugins/data/public/search/fetch/types.ts rename to src/plugins/data/common/search/search_source/fetch/types.ts index cdf10d8f1a1b0..30055f88012f2 100644 --- a/src/plugins/data/public/search/fetch/types.ts +++ b/src/plugins/data/common/search/search_source/fetch/types.ts @@ -18,8 +18,8 @@ */ import { SearchResponse } from 'elasticsearch'; -import { GetConfigFn } from '../../../common'; import { LegacyFetchHandlers } from '../legacy/types'; +import { GetConfigFn } from '../../../types'; /** * @internal diff --git a/src/plugins/data/public/search/search_source/filter_docvalue_fields.test.ts b/src/plugins/data/common/search/search_source/filter_docvalue_fields.test.ts similarity index 100% rename from src/plugins/data/public/search/search_source/filter_docvalue_fields.test.ts rename to src/plugins/data/common/search/search_source/filter_docvalue_fields.test.ts diff --git a/src/plugins/data/public/search/search_source/filter_docvalue_fields.ts b/src/plugins/data/common/search/search_source/filter_docvalue_fields.ts similarity index 100% rename from src/plugins/data/public/search/search_source/filter_docvalue_fields.ts rename to src/plugins/data/common/search/search_source/filter_docvalue_fields.ts diff --git a/src/plugins/data/public/search/search_source/index.ts b/src/plugins/data/common/search/search_source/index.ts similarity index 95% rename from src/plugins/data/public/search/search_source/index.ts rename to src/plugins/data/common/search/search_source/index.ts index 48c0338f7e981..70c9cfcee2348 100644 --- a/src/plugins/data/public/search/search_source/index.ts +++ b/src/plugins/data/common/search/search_source/index.ts @@ -23,3 +23,5 @@ export { SortDirection, EsQuerySortValue, SearchSourceFields } from './types'; export { injectReferences } from './inject_references'; export { extractReferences } from './extract_references'; export { parseSearchSourceJSON } from './parse_json'; +export * from './fetch'; +export * from './legacy'; diff --git a/src/plugins/data/public/search/search_source/inject_references.ts b/src/plugins/data/common/search/search_source/inject_references.ts similarity index 96% rename from src/plugins/data/public/search/search_source/inject_references.ts rename to src/plugins/data/common/search/search_source/inject_references.ts index 07f37c3c11275..81fafc6dcae06 100644 --- a/src/plugins/data/public/search/search_source/inject_references.ts +++ b/src/plugins/data/common/search/search_source/inject_references.ts @@ -17,8 +17,8 @@ * under the License. */ +import { SavedObjectReference } from 'src/core/types'; import { SearchSourceFields } from './types'; -import { SavedObjectReference } from '../../../../../core/types'; export const injectReferences = ( searchSourceFields: SearchSourceFields & { indexRefName: string }, diff --git a/src/plugins/data/public/search/legacy/call_client.test.ts b/src/plugins/data/common/search/search_source/legacy/call_client.test.ts similarity index 100% rename from src/plugins/data/public/search/legacy/call_client.test.ts rename to src/plugins/data/common/search/search_source/legacy/call_client.test.ts diff --git a/src/plugins/data/public/search/legacy/call_client.ts b/src/plugins/data/common/search/search_source/legacy/call_client.ts similarity index 93% rename from src/plugins/data/public/search/legacy/call_client.ts rename to src/plugins/data/common/search/search_source/legacy/call_client.ts index b87affdd59c54..cb6295dd701ee 100644 --- a/src/plugins/data/public/search/legacy/call_client.ts +++ b/src/plugins/data/common/search/search_source/legacy/call_client.ts @@ -18,10 +18,9 @@ */ import { SearchResponse } from 'elasticsearch'; -import { ISearchOptions } from 'src/plugins/data/common'; -import { FetchHandlers } from '../fetch'; +import { FetchHandlers, SearchRequest } from '../fetch'; import { defaultSearchStrategy } from './default_search_strategy'; -import { SearchRequest } from '../index'; +import { ISearchOptions } from '../../index'; export function callClient( searchRequests: SearchRequest[], diff --git a/src/plugins/data/public/search/legacy/default_search_strategy.test.ts b/src/plugins/data/common/search/search_source/legacy/default_search_strategy.test.ts similarity index 67% rename from src/plugins/data/public/search/legacy/default_search_strategy.test.ts rename to src/plugins/data/common/search/search_source/legacy/default_search_strategy.test.ts index ad59e5c6c9625..3badd456bd72a 100644 --- a/src/plugins/data/public/search/legacy/default_search_strategy.test.ts +++ b/src/plugins/data/common/search/search_source/legacy/default_search_strategy.test.ts @@ -17,51 +17,50 @@ * under the License. */ -import { HttpStart } from 'src/core/public'; -import { coreMock } from '../../../../../core/public/mocks'; -import { getCallMsearch } from './call_msearch'; import { defaultSearchStrategy } from './default_search_strategy'; import { LegacyFetchHandlers, SearchStrategySearchParams } from './types'; import { BehaviorSubject } from 'rxjs'; const { search } = defaultSearchStrategy; -const msearchMock = jest.fn().mockResolvedValue({ body: { responses: [] } }); - -describe('defaultSearchStrategy', function () { - describe('search', function () { +describe('defaultSearchStrategy', () => { + describe('search', () => { let searchArgs: MockedKeys; - let http: jest.Mocked; beforeEach(() => { - msearchMock.mockClear(); - - http = coreMock.createStart().http; - http.post.mockResolvedValue(msearchMock); - searchArgs = { searchRequests: [ { index: { title: 'foo' }, + body: {}, }, ], getConfig: jest.fn(), onResponse: (req, res) => res, legacy: { - callMsearch: getCallMsearch({ http }), + callMsearch: jest.fn().mockResolvedValue(undefined), loadingCount$: new BehaviorSubject(0) as any, } as jest.Mocked, }; }); - test('calls http.post with the correct arguments', async () => { + test('calls callMsearch with the correct arguments', async () => { await search({ ...searchArgs }); - expect(http.post.mock.calls).toMatchInlineSnapshot(` + expect(searchArgs.legacy.callMsearch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/internal/_msearch", Object { - "body": "{\\"searches\\":[{\\"header\\":{\\"index\\":\\"foo\\"}}]}", + "body": Object { + "searches": Array [ + Object { + "body": Object {}, + "header": Object { + "index": "foo", + "preference": undefined, + }, + }, + ], + }, "signal": AbortSignal {}, }, ], diff --git a/src/plugins/data/public/search/legacy/default_search_strategy.ts b/src/plugins/data/common/search/search_source/legacy/default_search_strategy.ts similarity index 100% rename from src/plugins/data/public/search/legacy/default_search_strategy.ts rename to src/plugins/data/common/search/search_source/legacy/default_search_strategy.ts diff --git a/src/plugins/data/public/search/legacy/fetch_soon.test.ts b/src/plugins/data/common/search/search_source/legacy/fetch_soon.test.ts similarity index 96% rename from src/plugins/data/public/search/legacy/fetch_soon.test.ts rename to src/plugins/data/common/search/search_source/legacy/fetch_soon.test.ts index 7243ab158009a..81117513917c0 100644 --- a/src/plugins/data/public/search/legacy/fetch_soon.test.ts +++ b/src/plugins/data/common/search/search_source/legacy/fetch_soon.test.ts @@ -17,12 +17,13 @@ * under the License. */ -import { fetchSoon } from './fetch_soon'; -import { callClient } from './call_client'; -import { FetchHandlers } from '../fetch/types'; -import { SearchRequest } from '../index'; import { SearchResponse } from 'elasticsearch'; -import { GetConfigFn, UI_SETTINGS, ISearchOptions } from '../../../common'; +import { UI_SETTINGS } from '../../../constants'; +import { GetConfigFn } from '../../../types'; +import { FetchHandlers, SearchRequest } from '../fetch'; +import { ISearchOptions } from '../../index'; +import { callClient } from './call_client'; +import { fetchSoon } from './fetch_soon'; function getConfigStub(config: any = {}): GetConfigFn { return (key) => config[key]; diff --git a/src/plugins/data/public/search/legacy/fetch_soon.ts b/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts similarity index 95% rename from src/plugins/data/public/search/legacy/fetch_soon.ts rename to src/plugins/data/common/search/search_source/legacy/fetch_soon.ts index 1c0573aa895d7..01ffc3876f6af 100644 --- a/src/plugins/data/public/search/legacy/fetch_soon.ts +++ b/src/plugins/data/common/search/search_source/legacy/fetch_soon.ts @@ -18,10 +18,10 @@ */ import { SearchResponse } from 'elasticsearch'; +import { UI_SETTINGS } from '../../../constants'; +import { FetchHandlers, SearchRequest } from '../fetch'; +import { ISearchOptions } from '../../index'; import { callClient } from './call_client'; -import { FetchHandlers } from '../fetch/types'; -import { SearchRequest } from '../index'; -import { UI_SETTINGS, ISearchOptions } from '../../../common'; /** * This function introduces a slight delay in the request process to allow multiple requests to queue diff --git a/src/legacy/server/status/wrap_auth_config.js b/src/plugins/data/common/search/search_source/legacy/index.ts similarity index 78% rename from src/legacy/server/status/wrap_auth_config.js rename to src/plugins/data/common/search/search_source/legacy/index.ts index 04e71a02d30de..26587b09ffd9e 100644 --- a/src/legacy/server/status/wrap_auth_config.js +++ b/src/plugins/data/common/search/search_source/legacy/index.ts @@ -17,11 +17,5 @@ * under the License. */ -import { assign, identity } from 'lodash'; - -export const wrapAuthConfig = (allowAnonymous) => { - if (allowAnonymous) { - return (options) => assign(options, { config: { auth: false } }); - } - return identity; -}; +export { fetchSoon } from './fetch_soon'; +export * from './types'; diff --git a/src/plugins/data/public/search/legacy/types.ts b/src/plugins/data/common/search/search_source/legacy/types.ts similarity index 95% rename from src/plugins/data/public/search/legacy/types.ts rename to src/plugins/data/common/search/search_source/legacy/types.ts index 740bc22a7485c..1a0a96a76a703 100644 --- a/src/plugins/data/public/search/legacy/types.ts +++ b/src/plugins/data/common/search/search_source/legacy/types.ts @@ -19,8 +19,7 @@ import { BehaviorSubject } from 'rxjs'; import { SearchResponse } from 'elasticsearch'; -import { FetchHandlers } from '../fetch'; -import { SearchRequest } from '..'; +import { FetchHandlers, SearchRequest } from '../fetch'; // @internal export interface LegacyFetchHandlers { diff --git a/src/plugins/data/public/search/search_source/migrate_legacy_query.ts b/src/plugins/data/common/search/search_source/migrate_legacy_query.ts similarity index 96% rename from src/plugins/data/public/search/search_source/migrate_legacy_query.ts rename to src/plugins/data/common/search/search_source/migrate_legacy_query.ts index 8d9b50d5a66b2..f271280170166 100644 --- a/src/plugins/data/public/search/search_source/migrate_legacy_query.ts +++ b/src/plugins/data/common/search/search_source/migrate_legacy_query.ts @@ -18,7 +18,7 @@ */ import { has } from 'lodash'; -import { Query } from 'src/plugins/data/public'; +import { Query } from '../../query/types'; /** * Creates a standardized query object from old queries that were either strings or pure ES query DSL diff --git a/src/plugins/data/public/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts similarity index 100% rename from src/plugins/data/public/search/search_source/mocks.ts rename to src/plugins/data/common/search/search_source/mocks.ts diff --git a/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts b/src/plugins/data/common/search/search_source/normalize_sort_request.test.ts similarity index 98% rename from src/plugins/data/public/search/search_source/normalize_sort_request.test.ts rename to src/plugins/data/common/search/search_source/normalize_sort_request.test.ts index 10004b87ca690..1899efbf3598d 100644 --- a/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts +++ b/src/plugins/data/common/search/search_source/normalize_sort_request.test.ts @@ -19,7 +19,7 @@ import { normalizeSortRequest } from './normalize_sort_request'; import { SortDirection } from './types'; -import { IIndexPattern } from '../..'; +import { IIndexPattern } from '../../index_patterns'; describe('SearchSource#normalizeSortRequest', function () { const scriptedField = { diff --git a/src/plugins/data/public/search/search_source/normalize_sort_request.ts b/src/plugins/data/common/search/search_source/normalize_sort_request.ts similarity index 98% rename from src/plugins/data/public/search/search_source/normalize_sort_request.ts rename to src/plugins/data/common/search/search_source/normalize_sort_request.ts index 3ec0a13282d3e..e41c4482df9c9 100644 --- a/src/plugins/data/public/search/search_source/normalize_sort_request.ts +++ b/src/plugins/data/common/search/search_source/normalize_sort_request.ts @@ -17,7 +17,7 @@ * under the License. */ -import { IIndexPattern } from '../..'; +import { IIndexPattern } from '../../index_patterns'; import { EsQuerySortValue, SortOptions } from './types'; export function normalizeSortRequest( diff --git a/src/plugins/data/public/search/search_source/parse_json.ts b/src/plugins/data/common/search/search_source/parse_json.ts similarity index 100% rename from src/plugins/data/public/search/search_source/parse_json.ts rename to src/plugins/data/common/search/search_source/parse_json.ts diff --git a/src/plugins/data/public/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts similarity index 97% rename from src/plugins/data/public/search/search_source/search_source.test.ts rename to src/plugins/data/common/search/search_source/search_source.test.ts index d9a9fb2f4fef3..74abd9238bc2b 100644 --- a/src/plugins/data/public/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -18,12 +18,12 @@ */ import { Observable, BehaviorSubject } from 'rxjs'; -import { GetConfigFn } from 'src/plugins/data/common'; -import { SearchSource, SearchSourceDependencies } from './search_source'; -import { IndexPattern, SortDirection } from '../..'; -import { fetchSoon } from '../legacy'; +import { IndexPattern } from '../../index_patterns'; +import { GetConfigFn } from '../../types'; +import { fetchSoon } from './legacy'; +import { SearchSource, SearchSourceDependencies, SortDirection } from './'; -jest.mock('../legacy', () => ({ +jest.mock('./legacy', () => ({ fetchSoon: jest.fn().mockResolvedValue({}), })); diff --git a/src/plugins/data/public/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts similarity index 99% rename from src/plugins/data/public/search/search_source/search_source.ts rename to src/plugins/data/common/search/search_source/search_source.ts index 4afee223454e4..d8a036ce970dd 100644 --- a/src/plugins/data/public/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -75,9 +75,10 @@ import { map } from 'rxjs/operators'; import { normalizeSortRequest } from './normalize_sort_request'; import { filterDocvalueFields } from './filter_docvalue_fields'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; -import { IIndexPattern, ISearchGeneric } from '../..'; +import { IIndexPattern } from '../../index_patterns'; +import { ISearchGeneric } from '../..'; import { SearchSourceOptions, SearchSourceFields } from './types'; -import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from '../fetch'; +import { FetchHandlers, RequestFailure, getSearchParamsFromRequest, SearchRequest } from './fetch'; import { getEsQueryConfig, @@ -87,7 +88,7 @@ import { ISearchOptions, } from '../../../common'; import { getHighlightRequest } from '../../../common/field_formats'; -import { fetchSoon } from '../legacy'; +import { fetchSoon } from './legacy'; import { extractReferences } from './extract_references'; /** @internal */ diff --git a/src/plugins/data/public/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts similarity index 100% rename from src/plugins/data/public/search/search_source/types.ts rename to src/plugins/data/common/search/search_source/types.ts diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 7600bd9db6094..0a299b57275f8 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -17,6 +17,22 @@ * under the License. */ +import { Observable } from 'rxjs'; +import { IEsSearchRequest, IEsSearchResponse, ISearchOptions } from '../../common/search'; + +export type ISearch = ( + request: IKibanaSearchRequest, + options?: ISearchOptions +) => Observable; + +export type ISearchGeneric = < + SearchStrategyRequest extends IEsSearchRequest = IEsSearchRequest, + SearchStrategyResponse extends IEsSearchResponse = IEsSearchResponse +>( + request: SearchStrategyRequest, + options?: ISearchOptions +) => Observable; + export interface IKibanaSearchResponse { /** * Some responses may contain a unique id to identify the request this response came from. diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 7ce53a219fb44..db8d9dba4e0c7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -64,6 +64,7 @@ import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; import { SavedObject } from 'src/core/server'; import { SavedObject as SavedObject_3 } from 'src/core/public'; +import { SavedObjectReference as SavedObjectReference_2 } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { Search } from '@elastic/elasticsearch/api/requestParams'; import { SearchResponse } from 'elasticsearch'; @@ -565,7 +566,7 @@ export const esFilters: { type?: string | undefined; key?: string | undefined; params?: any; - value?: string | ((formatter?: import("../common").FilterValueFormatter | undefined) => string) | undefined; + value?: string | undefined; }; $state?: import("../common").FilterState | undefined; query?: any; @@ -651,13 +652,12 @@ export type ExistsFilter = Filter & { // @public (undocumented) export const expandShorthand: (sh: Record) => MappingObject; -// Warning: (ae-forgotten-export) The symbol "SavedObjectReference" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "extractReferences" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export const extractSearchSourceReferences: (state: SearchSourceFields) => [SearchSourceFields & { indexRefName?: string; -}, SavedObjectReference[]]; +}, SavedObjectReference_2[]]; // Warning: (ae-missing-release-tag) "FieldFormat" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -791,18 +791,11 @@ export interface FieldMappingSpec { // Warning: (ae-missing-release-tag) "Filter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface Filter { - // Warning: (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts - // - // (undocumented) +export type Filter = { $state?: FilterState; - // Warning: (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts - // - // (undocumented) meta: FilterMeta; - // (undocumented) query?: any; -} +}; // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "FilterBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1378,7 +1371,7 @@ export interface IndexPatternTypeMeta { // @public (undocumented) export const injectSearchSourceReferences: (searchSourceFields: SearchSourceFields & { indexRefName: string; -}, references: SavedObjectReference[]) => SearchSourceFields; +}, references: SavedObjectReference_2[]) => SearchSourceFields; // Warning: (ae-missing-release-tag) "InputTimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1645,14 +1638,12 @@ export function plugin(initializerContext: PluginInitializerContext; // Warning: (ae-missing-release-tag) "TimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface TimeRange { - // (undocumented) +export type TimeRange = { from: string; - // (undocumented) - mode?: 'absolute' | 'relative'; - // (undocumented) to: string; -} + mode?: 'absolute' | 'relative'; +}; // Warning: (ae-missing-release-tag) "UI_SETTINGS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -2197,6 +2185,8 @@ export const UI_SETTINGS: { // src/plugins/data/common/es_query/filters/exists_filter.ts:30:3 - (ae-forgotten-export) The symbol "ExistsFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/exists_filter.ts:31:3 - (ae-forgotten-export) The symbol "FilterExistsProperty" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/match_all_filter.ts:28:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/meta_filter.ts:53:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:98:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/filter_manager/lib/map_and_flatten_filters.test.ts b/src/plugins/data/public/query/filter_manager/lib/map_and_flatten_filters.test.ts index 1b2d476570902..996a7aaa27c31 100644 --- a/src/plugins/data/public/query/filter_manager/lib/map_and_flatten_filters.test.ts +++ b/src/plugins/data/public/query/filter_manager/lib/map_and_flatten_filters.test.ts @@ -25,7 +25,9 @@ describe('filter manager utilities', () => { let filters: unknown; function getDisplayName(filter: Filter) { - return typeof filter.meta.value === 'function' ? filter.meta.value() : filter.meta.value; + return typeof filter.meta.value === 'function' + ? (filter.meta.value as any)() + : filter.meta.value; } beforeEach(() => { diff --git a/src/plugins/data/public/query/filter_manager/lib/map_filter.test.ts b/src/plugins/data/public/query/filter_manager/lib/map_filter.test.ts index 35d2f2b7b294e..7b303ca4d5314 100644 --- a/src/plugins/data/public/query/filter_manager/lib/map_filter.test.ts +++ b/src/plugins/data/public/query/filter_manager/lib/map_filter.test.ts @@ -22,7 +22,9 @@ import { Filter } from '../../../../common'; describe('filter manager utilities', () => { function getDisplayName(filter: Filter) { - return typeof filter.meta.value === 'function' ? filter.meta.value() : filter.meta.value; + return typeof filter.meta.value === 'function' + ? (filter.meta.value as any)() + : filter.meta.value; } describe('mapFilter()', () => { diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 50fbb114b39fd..1021ef0f91d52 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -20,7 +20,6 @@ import { get, hasIn } from 'lodash'; import { i18n } from '@kbn/i18n'; import { KibanaDatatable, KibanaDatatableColumn } from 'src/plugins/expressions/public'; -import { calculateObjectHash } from '../../../../../plugins/kibana_utils/public'; import { PersistedState } from '../../../../../plugins/visualizations/public'; import { Adapters } from '../../../../../plugins/inspector/public'; @@ -38,6 +37,7 @@ import { getRequestInspectorStats, getResponseInspectorStats, IAggConfigs, + ISearchSource, tabifyAggResponse, } from '../../../common/search'; @@ -48,7 +48,6 @@ import { getQueryService, getSearchService, } from '../../services'; -import { ISearchSource } from '../search_source'; import { buildTabularInspectorData } from './build_tabular_inspector_data'; import { serializeAggConfig } from './utils'; @@ -60,7 +59,6 @@ export interface RequestHandlerParams { indexPattern?: IIndexPattern; query?: Query; filters?: Filter[]; - forceFetch: boolean; filterManager: FilterManager; uiState?: PersistedState; partialRows?: boolean; @@ -80,7 +78,6 @@ const handleCourierRequest = async ({ indexPattern, query, filters, - forceFetch, partialRows, metricsAtAllLevels, inspectorAdapters, @@ -137,46 +134,35 @@ const handleCourierRequest = async ({ requestSearchSource.setField('filter', filters); requestSearchSource.setField('query', query); - const reqBody = await requestSearchSource.getSearchRequestBody(); - - const queryHash = calculateObjectHash(reqBody); - // We only need to reexecute the query, if forceFetch was true or the hash of the request body has changed - // since the last request - const shouldQuery = forceFetch || (searchSource as any).lastQuery !== queryHash; - - if (shouldQuery) { - inspectorAdapters.requests.reset(); - const request = inspectorAdapters.requests.start( - i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { - defaultMessage: 'Data', + inspectorAdapters.requests.reset(); + const request = inspectorAdapters.requests.start( + i18n.translate('data.functions.esaggs.inspector.dataRequest.title', { + defaultMessage: 'Data', + }), + { + description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the visualization.', }), - { - description: i18n.translate('data.functions.esaggs.inspector.dataRequest.description', { - defaultMessage: - 'This request queries Elasticsearch to fetch the data for the visualization.', - }), - } - ); - request.stats(getRequestInspectorStats(requestSearchSource)); - - try { - const response = await requestSearchSource.fetch({ abortSignal }); - - (searchSource as any).lastQuery = queryHash; - - request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response }); - - (searchSource as any).rawResponse = response; - } catch (e) { - // Log any error during request to the inspector - request.error({ json: e }); - throw e; - } finally { - // Add the request body no matter if things went fine or not - requestSearchSource.getSearchRequestBody().then((req: unknown) => { - request.json(req); - }); } + ); + request.stats(getRequestInspectorStats(requestSearchSource)); + + try { + const response = await requestSearchSource.fetch({ abortSignal }); + + request.stats(getResponseInspectorStats(response, searchSource)).ok({ json: response }); + + (searchSource as any).rawResponse = response; + } catch (e) { + // Log any error during request to the inspector + request.error({ json: e }); + throw e; + } finally { + // Add the request body no matter if things went fine or not + requestSearchSource.getSearchRequestBody().then((req: unknown) => { + request.json(req); + }); } // Note that rawResponse is not deeply cloned here, so downstream applications using courier @@ -207,19 +193,11 @@ const handleCourierRequest = async ({ : undefined, }; - const tabifyCacheHash = calculateObjectHash({ tabifyAggs: aggs, ...tabifyParams }); - // We only need to reexecute tabify, if either we did a new request or some input params to tabify changed - const shouldCalculateNewTabify = - shouldQuery || (searchSource as any).lastTabifyHash !== tabifyCacheHash; - - if (shouldCalculateNewTabify) { - (searchSource as any).lastTabifyHash = tabifyCacheHash; - (searchSource as any).tabifiedResponse = tabifyAggResponse( - aggs, - (searchSource as any).finalResponse, - tabifyParams - ); - } + (searchSource as any).tabifiedResponse = tabifyAggResponse( + aggs, + (searchSource as any).finalResponse, + tabifyParams + ); inspectorAdapters.data.setTabularLoader( () => @@ -294,7 +272,6 @@ export const esaggs = (): EsaggsExpressionFunctionDefinition => ({ query: get(input, 'query', undefined) as any, filters: get(input, 'filters', undefined), timeFields: args.timeFields, - forceFetch: true, metricsAtAllLevels: args.metricsAtAllLevels, partialRows: args.partialRows, inspectorAdapters: inspectorAdapters as Adapters, diff --git a/src/plugins/data/public/search/fetch/index.ts b/src/plugins/data/public/search/fetch/index.ts index 4b8511edfc26f..340a795d37bfb 100644 --- a/src/plugins/data/public/search/fetch/index.ts +++ b/src/plugins/data/public/search/fetch/index.ts @@ -17,8 +17,4 @@ * under the License. */ -export * from './types'; -export { getSearchParams, getSearchParamsFromRequest, getPreference } from './get_search_params'; - -export { RequestFailure } from './request_error'; export { handleResponse } from './handle_response'; diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index c1af9699acbb2..fc3d71936a859 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -19,34 +19,31 @@ export * from './expressions'; +export { ISearchSetup, ISearchStart, ISearchStartSearchSource, SearchEnhancements } from './types'; + export { + ES_SEARCH_STRATEGY, + EsQuerySortValue, + extractReferences as extractSearchSourceReferences, + getSearchParamsFromRequest, + IEsSearchRequest, + IEsSearchResponse, + IKibanaSearchRequest, + IKibanaSearchResponse, + injectReferences as injectSearchSourceReferences, ISearch, ISearchGeneric, - ISearchSetup, - ISearchStart, - ISearchStartSearchSource, - SearchEnhancements, -} from './types'; - -export { IEsSearchResponse, IEsSearchRequest, ES_SEARCH_STRATEGY } from '../../common/search'; - -export { getEsPreference } from './es_search'; - -export { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search'; - -export { SearchError, getSearchParamsFromRequest, SearchRequest } from './fetch'; - -export { ISearchSource, + parseSearchSourceJSON, + SearchError, + SearchRequest, SearchSource, SearchSourceDependencies, SearchSourceFields, - EsQuerySortValue, SortDirection, - extractReferences as extractSearchSourceReferences, - injectReferences as injectSearchSourceReferences, - parseSearchSourceJSON, -} from './search_source'; +} from '../../common/search'; + +export { getEsPreference } from './es_search'; export { SearchInterceptor, SearchInterceptorDeps } from './search_interceptor'; export { RequestTimeoutError } from './request_timeout_error'; diff --git a/src/plugins/data/public/search/legacy/call_msearch.test.ts b/src/plugins/data/public/search/legacy/call_msearch.test.ts new file mode 100644 index 0000000000000..da39bf521fe3d --- /dev/null +++ b/src/plugins/data/public/search/legacy/call_msearch.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { HttpStart } from 'src/core/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { getCallMsearch } from './call_msearch'; + +describe('callMsearch', () => { + const msearchMock = jest.fn().mockResolvedValue({ body: { responses: [] } }); + let http: jest.Mocked; + + beforeEach(() => { + msearchMock.mockClear(); + http = coreMock.createStart().http; + http.post.mockResolvedValue(msearchMock); + }); + + test('calls http.post with the correct arguments', async () => { + const searches = [{ header: { index: 'foo' }, body: {} }]; + const callMsearch = getCallMsearch({ http }); + await callMsearch({ + body: { searches }, + signal: new AbortController().signal, + }); + + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/internal/_msearch", + Object { + "body": "{\\"searches\\":[{\\"header\\":{\\"index\\":\\"foo\\"},\\"body\\":{}}]}", + "signal": AbortSignal {}, + }, + ], + ] + `); + }); +}); diff --git a/src/plugins/data/public/search/legacy/call_msearch.ts b/src/plugins/data/public/search/legacy/call_msearch.ts index fd4f8a07919f8..6b2b9b4da020b 100644 --- a/src/plugins/data/public/search/legacy/call_msearch.ts +++ b/src/plugins/data/public/search/legacy/call_msearch.ts @@ -18,7 +18,7 @@ */ import { HttpStart } from 'src/core/public'; -import { LegacyFetchHandlers } from './types'; +import { LegacyFetchHandlers } from '../../../common/search/search_source'; /** * Wrapper for calling the internal msearch endpoint from the client. diff --git a/src/plugins/data/public/search/legacy/index.ts b/src/plugins/data/public/search/legacy/index.ts index 74e516f407e8c..08e5eab788e76 100644 --- a/src/plugins/data/public/search/legacy/index.ts +++ b/src/plugins/data/public/search/legacy/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { fetchSoon } from './fetch_soon'; +export * from './call_msearch'; diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index f4ed7d8b122b9..fdd6a90013413 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -19,9 +19,9 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { ISearchSetup, ISearchStart } from './types'; -import { searchSourceMock, createSearchSourceMock } from './search_source/mocks'; +import { searchSourceMock, createSearchSourceMock } from '../../common/search/search_source/mocks'; -export * from './search_source/mocks'; +export * from '../../common/search/search_source/mocks'; function createSetupContract(): jest.Mocked { return { diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index c41e1f78ee74e..d8937ed30e401 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -22,12 +22,16 @@ import { BehaviorSubject } from 'rxjs'; import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; import { handleResponse } from './fetch'; -import { getCallMsearch } from './legacy/call_msearch'; -import { createSearchSource, SearchSource, SearchSourceDependencies } from './search_source'; +import { + createSearchSource, + ISearchGeneric, + SearchSource, + SearchSourceDependencies, +} from '../../common/search'; +import { getCallMsearch } from './legacy'; import { AggsService, AggsStartDependencies } from './aggs'; import { IndexPatternsContract } from '../index_patterns/index_patterns'; import { ISearchInterceptor, SearchInterceptor } from './search_interceptor'; -import { ISearchGeneric } from './types'; import { SearchUsageCollector, createUsageCollector } from './collectors'; import { UsageCollectionSetup } from '../../../usage_collection/public'; import { esdsl, esRawResponse } from './expressions'; diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 83a542269046f..6ae5d83499aa6 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,38 +17,18 @@ * under the License. */ -import { Observable } from 'rxjs'; import { PackageInfo } from 'kibana/server'; import { ISearchInterceptor } from './search_interceptor'; -import { ISearchSource, SearchSourceFields } from './search_source'; import { SearchUsageCollector } from './collectors'; import { AggsSetup, AggsSetupDependencies, AggsStartDependencies, AggsStart } from './aggs'; -import { - IKibanaSearchRequest, - IKibanaSearchResponse, - IEsSearchRequest, - IEsSearchResponse, - ISearchOptions, -} from '../../common/search'; +import { ISearchGeneric, ISearchSource, SearchSourceFields } from '../../common/search'; import { IndexPatternsContract } from '../../common/index_patterns/index_patterns'; import { UsageCollectionSetup } from '../../../usage_collection/public'; -export type ISearch = ( - request: IKibanaSearchRequest, - options?: ISearchOptions -) => Observable; - -export type ISearchGeneric = < - SearchStrategyRequest extends IEsSearchRequest = IEsSearchRequest, - SearchStrategyResponse extends IEsSearchResponse = IEsSearchResponse ->( - request: SearchStrategyRequest, - options?: ISearchOptions -) => Observable; - export interface SearchEnhancements { searchInterceptor: ISearchInterceptor; } + /** * The setup contract exposed by the Search plugin exposes the search strategy extension * point. 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 f159cac664a9e..8e1151b387fee 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 @@ -546,13 +546,16 @@ export class QueryStringInputUI extends Component { this.updateSuggestions.cancel(); this.componentIsUnmounting = true; window.removeEventListener('resize', this.handleAutoHeight); - window.removeEventListener('scroll', this.handleListUpdate); + window.removeEventListener('scroll', this.handleListUpdate, { capture: true }); } - handleListUpdate = () => - this.setState({ + handleListUpdate = () => { + if (this.componentIsUnmounting) return; + + return this.setState({ queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(), }); + }; handleAutoHeight = () => { if (this.inputRef !== null && document.activeElement === this.inputRef) { diff --git a/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts index ff9d67152e268..57c636a9e3c69 100644 --- a/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts +++ b/src/plugins/data/server/index_patterns/fetcher/index_patterns_fetcher.ts @@ -55,9 +55,10 @@ export class IndexPatternsFetcher { async getFieldsForWildcard(options: { pattern: string | string[]; metaFields?: string[]; + fieldCapsOptions?: { allowNoIndices: boolean }; }): Promise { - const { pattern, metaFields } = options; - return await getFieldCapabilities(this._callDataCluster, pattern, metaFields); + const { pattern, metaFields, fieldCapsOptions } = options; + return await getFieldCapabilities(this._callDataCluster, pattern, metaFields, fieldCapsOptions); } /** diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts b/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts index 0738a16034d46..27ce14f9a3597 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/es_api.ts @@ -69,15 +69,20 @@ export async function callIndexAliasApi( * * @param {Function} callCluster bound function for accessing an es client * @param {Array|String} indices + * @param {Object} fieldCapsOptions * @return {Promise} */ -export async function callFieldCapsApi(callCluster: LegacyAPICaller, indices: string[] | string) { +export async function callFieldCapsApi( + callCluster: LegacyAPICaller, + indices: string[] | string, + fieldCapsOptions: { allowNoIndices: boolean } = { allowNoIndices: false } +) { try { return (await callCluster('fieldCaps', { index: indices, fields: '*', ignoreUnavailable: true, - allowNoIndices: false, + ...fieldCapsOptions, })) as FieldCapsResponse; } catch (error) { throw convertEsError(indices, error); diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.test.js b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.test.js index a0af7582ac6f3..0e5757b7b782b 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.test.js +++ b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.test.js @@ -61,7 +61,7 @@ describe('index_patterns/field_capabilities/field_capabilities', () => { await getFieldCapabilities(footballs[0], footballs[1]); sinon.assert.calledOnce(callFieldCapsApi); - calledWithExactly(callFieldCapsApi, [footballs[0], footballs[1]]); + calledWithExactly(callFieldCapsApi, [footballs[0], footballs[1], undefined]); }); }); diff --git a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.ts b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.ts index 6b26c82dc95e7..62e77e0adad66 100644 --- a/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.ts +++ b/src/plugins/data/server/index_patterns/fetcher/lib/field_capabilities/field_capabilities.ts @@ -32,14 +32,20 @@ import { FieldDescriptor } from '../../index_patterns_fetcher'; * @param {Function} callCluster bound function for accessing an es client * @param {Array} [indices=[]] the list of indexes to check * @param {Array} [metaFields=[]] the list of internal fields to include + * @param {Object} fieldCapsOptions * @return {Promise>} */ export async function getFieldCapabilities( callCluster: LegacyAPICaller, indices: string | string[] = [], - metaFields: string[] = [] + metaFields: string[] = [], + fieldCapsOptions?: { allowNoIndices: boolean } ) { - const esFieldCaps: FieldCapsResponse = await callFieldCapsApi(callCluster, indices); + const esFieldCaps: FieldCapsResponse = await callFieldCapsApi( + callCluster, + indices, + fieldCapsOptions + ); const fieldsFromFieldCapsByName = keyBy(readFieldCapsResponse(esFieldCaps), 'name'); const allFieldsUnsorted = Object.keys(fieldsFromFieldCapsByName) diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 14f176176f647..2024e9e7f2974 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -440,18 +440,11 @@ export type FieldFormatsGetConfigFn = GetConfigFn; // Warning: (ae-missing-release-tag) "Filter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface Filter { - // Warning: (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts - // - // (undocumented) +export type Filter = { $state?: FilterState; - // Warning: (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts - // - // (undocumented) meta: FilterMeta; - // (undocumented) query?: any; -} +}; // Warning: (ae-forgotten-export) The symbol "IUiSettingsClient" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "getDefaultSearchParams" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -694,6 +687,9 @@ export class IndexPatternsFetcher { getFieldsForWildcard(options: { pattern: string | string[]; metaFields?: string[]; + fieldCapsOptions?: { + allowNoIndices: boolean; + }; }): Promise; } @@ -941,14 +937,12 @@ export interface PluginStart { // Warning: (ae-missing-release-tag) "Query" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface Query { - // (undocumented) - language: string; - // (undocumented) +export type Query = { query: string | { [key: string]: any; }; -} + language: string; +}; // Warning: (ae-missing-release-tag) "RefreshInterval" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1063,14 +1057,11 @@ export interface TabbedTable { // Warning: (ae-missing-release-tag) "TimeRange" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export interface TimeRange { - // (undocumented) +export type TimeRange = { from: string; - // (undocumented) - mode?: 'absolute' | 'relative'; - // (undocumented) to: string; -} + mode?: 'absolute' | 'relative'; +}; // Warning: (ae-missing-release-tag) "toSnakeCase" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1120,6 +1111,8 @@ export function usageProvider(core: CoreSetup_2): SearchUsage; // Warnings were encountered during analysis: // +// src/plugins/data/common/es_query/filters/meta_filter.ts:53:3 - (ae-forgotten-export) The symbol "FilterState" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/es_query/filters/meta_filter.ts:54:3 - (ae-forgotten-export) The symbol "FilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/fields/types.ts:41:25 - (ae-forgotten-export) The symbol "IndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildCustomFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/public/application/_discover.scss b/src/plugins/discover/public/application/_discover.scss index 69df2a75b8d75..bc704439d161b 100644 --- a/src/plugins/discover/public/application/_discover.scss +++ b/src/plugins/discover/public/application/_discover.scss @@ -5,6 +5,11 @@ overflow: hidden; } +.dscAppContainer { + > * { + position: relative; + } +} discover-app { flex-grow: 1; } @@ -17,9 +22,12 @@ discover-app { // SASSTODO: replace the z-index value with a variable .dscWrapper { + padding-left: $euiSizeXL; padding-right: $euiSizeS; - padding-left: 21px; z-index: 1; + @include euiBreakpoint('xs', 's', 'm') { + padding-left: $euiSizeS; + } } @include euiPanel('.dscWrapper__content'); @@ -104,14 +112,51 @@ discover-app { top: $euiSizeXS; } -[fixed-scroll] { +.dscTableFixedScroll { overflow-x: auto; padding-bottom: 0; - + .fixed-scroll-scroller { + + .dscTableFixedScroll__scroller { position: fixed; bottom: 0; overflow-x: auto; overflow-y: hidden; } } + +.dscCollapsibleSidebar { + position: relative; + z-index: $euiZLevel1; + + .dscCollapsibleSidebar__collapseButton { + position: absolute; + top: 0; + right: -$euiSizeXL + 4; + cursor: pointer; + z-index: -1; + min-height: $euiSizeM; + min-width: $euiSizeM; + padding: $euiSizeXS * .5; + } + + &.closed { + width: 0 !important; + border-right-width: 0; + border-left-width: 0; + .dscCollapsibleSidebar__collapseButton { + right: -$euiSizeL + 4; + } + } +} + +@include euiBreakpoint('xs', 's', 'm') { + .dscCollapsibleSidebar { + &.closed { + display: none; + } + + .dscCollapsibleSidebar__collapseButton { + display: none; + } + } +} diff --git a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap index e7aea41e2d08e..e69e10e29e801 100644 --- a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap +++ b/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap @@ -167,132 +167,6 @@ Array [ ] `; -exports[`DiscoverNoResults props shardFailures doesn't render failures list when there are no failures 1`] = ` -Array [ -

, -
-
-
-
- -
-
-
, -] -`; - -exports[`DiscoverNoResults props shardFailures renders failures list when there are failures 1`] = ` -Array [ -
, -
-
-
-
- -
-
-
-

- Address shard failures -

-

- The following shard failures occurred: -

-
-
- - Index ‘A’ - - , shard ‘1’ -
-
-
-
-              
-                {"reason":"Awful error"}
-              
-            
-
-
-
-
-
- - Index ‘B’ - - , shard ‘2’ -
-
-
-
-              
-                {"reason":"Bad error"}
-              
-            
-
-
-
-
-
, -] -`; - exports[`DiscoverNoResults props timeFieldName renders time range feedback 1`] = ` Array [
* { - visibility: hidden; - } - - .kbnCollapsibleSidebar__collapseButton { - visibility: visible; - - .chevron-cont:before { - content: "\F138"; - } - } - } -} - -@include euiBreakpoint('xs', 's', 'm') { - .collapsible-sidebar { - &.closed { - display: none; - } - - .kbnCollapsibleSidebar__collapseButton { - display: none; - } - } -} diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss deleted file mode 100644 index 4bc59001f9931..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_depth.scss +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 1. The local nav contains tooltips which should pop over the filter bar. - * 2. The filter and local nav components should always appear above the dashboard grid items. - * 3. The filter and local nav components should always appear above the discover content. - * 4. The sidebar collapser button should appear above the main Discover content but below the top elements. - * 5. Dragged panels in dashboard should always appear above other panels. - */ -$kbnFilterBarDepth: 4; /* 1 */ -$kbnLocalNavDepth: 5; /* 1 */ -$kbnDashboardGridDepth: 1; /* 2 */ -$kbnDashboardDraggingGridDepth: 2; /* 5 */ -$kbnDiscoverWrapperDepth: 1; /* 3 */ -$kbnDiscoverSidebarDepth: 2; /* 4 */ diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss deleted file mode 100644 index 1409920d11aa7..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import 'depth'; -@import 'collapsible_sidebar'; diff --git a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts b/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts deleted file mode 100644 index 16fbb0af9f3fd..0000000000000 --- a/src/plugins/discover/public/application/angular/directives/collapsible_sidebar/collapsible_sidebar.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; -import $ from 'jquery'; -import { IScope } from 'angular'; - -interface LazyScope extends IScope { - [key: string]: any; -} - -export function CollapsibleSidebarProvider() { - // simply a list of all of all of angulars .col-md-* classes except 12 - const listOfWidthClasses = _.times(11, function (i) { - return 'col-md-' + i; - }); - - return { - restrict: 'C', - link: ($scope: LazyScope, $elem: any) => { - let isCollapsed = false; - const $collapser = $( - `` - ); - // If the collapsable element has an id, also set aria-controls - if ($elem.attr('id')) { - $collapser.attr('aria-controls', $elem.attr('id')); - } - const $icon = $(''); - $collapser.append($icon); - const $siblings = $elem.siblings(); - - const siblingsClass = listOfWidthClasses.reduce((prev: string, className: string) => { - if (prev) return prev; - return $siblings.hasClass(className) && className; - }, ''); - - // If there is are only two elements we can assume the other one will take 100% of the width. - const hasSingleSibling = $siblings.length === 1 && siblingsClass; - - $collapser.on('click', function () { - if (isCollapsed) { - isCollapsed = false; - $elem.removeClass('closed'); - $icon.addClass('fa-chevron-circle-left'); - $icon.removeClass('fa-chevron-circle-right'); - $collapser.attr('aria-expanded', 'true'); - } else { - isCollapsed = true; - $elem.addClass('closed'); - $icon.removeClass('fa-chevron-circle-left'); - $icon.addClass('fa-chevron-circle-right'); - $collapser.attr('aria-expanded', 'false'); - } - - if (hasSingleSibling) { - $siblings.toggleClass(siblingsClass + ' col-md-12'); - } - - if ($scope.toggleSidebar) $scope.toggleSidebar(); - }); - - $collapser.appendTo($elem); - }, - }; -} diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover/public/application/angular/directives/debounce/debounce.js index 586e8ed4fab59..8ce2b042c0efe 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js +++ b/src/plugins/discover/public/application/angular/directives/debounce/debounce.js @@ -21,7 +21,7 @@ import _ from 'lodash'; // Debounce service, angularized version of lodash debounce // borrowed heavily from https://github.com/shahata/angular-debounce -export function DebounceProviderTimeout($timeout) { +export function createDebounceProviderTimeout($timeout) { return function (func, wait, options) { let timeout; let args; @@ -66,7 +66,3 @@ export function DebounceProviderTimeout($timeout) { return debounce; }; } - -export function DebounceProvider(debounce) { - return debounce; -} diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts index ccdee153002e4..0cdc214cf97f5 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts +++ b/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts @@ -24,7 +24,7 @@ import 'angular-sanitize'; import 'angular-route'; // @ts-ignore -import { DebounceProvider } from './index'; +import { createDebounceProviderTimeout } from './debounce'; import { coreMock } from '../../../../../../../core/public/mocks'; import { initializeInnerAngularModule } from '../../../../get_inner_angular'; import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; @@ -33,7 +33,6 @@ import { initAngularBootstrap } from '../../../../../../kibana_legacy/public'; describe('debounce service', function () { let debounce: (fn: () => void, timeout: number, options?: any) => any; - let debounceFromProvider: (fn: () => void, timeout: number, options?: any) => any; let $timeout: ITimeoutService; let spy: SinonSpy; @@ -51,22 +50,17 @@ describe('debounce service', function () { angular.mock.module('app/discover'); - angular.mock.inject( - ($injector: auto.IInjectorService, _$timeout_: ITimeoutService, Private: any) => { - $timeout = _$timeout_; + angular.mock.inject(($injector: auto.IInjectorService, _$timeout_: ITimeoutService) => { + $timeout = _$timeout_; - debounce = $injector.get('debounce'); - debounceFromProvider = Private(DebounceProvider); - } - ); + debounce = createDebounceProviderTimeout($timeout); + }); }); it('should have a cancel method', function () { const bouncer = debounce(() => {}, 100); - const bouncerFromProvider = debounceFromProvider(() => {}, 100); expect(bouncer).toHaveProperty('cancel'); - expect(bouncerFromProvider).toHaveProperty('cancel'); }); describe('delayed execution', function () { @@ -77,7 +71,6 @@ describe('debounce service', function () { it('should delay execution', function () { const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); sinon.assert.notCalled(spy); @@ -85,16 +78,10 @@ describe('debounce service', function () { sinon.assert.calledOnce(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); }); it('should fire on leading edge', function () { const bouncer = debounce(spy, 100, { leading: true }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { leading: true }); bouncer(); sinon.assert.calledOnce(spy); @@ -102,19 +89,10 @@ describe('debounce service', function () { sinon.assert.calledTwice(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledTwice(spy); }); it('should only fire on leading edge', function () { const bouncer = debounce(spy, 100, { leading: true, trailing: false }); - const bouncerFromProvider = debounceFromProvider(spy, 100, { - leading: true, - trailing: false, - }); bouncer(); sinon.assert.calledOnce(spy); @@ -122,17 +100,11 @@ describe('debounce service', function () { sinon.assert.calledOnce(spy); spy.resetHistory(); - - bouncerFromProvider(); - sinon.assert.calledOnce(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); }); it('should reset delayed execution', function () { const cancelSpy = sinon.spy($timeout, 'cancel'); const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); sandbox.clock.tick(1); @@ -145,15 +117,6 @@ describe('debounce service', function () { spy.resetHistory(); cancelSpy.resetHistory(); - - bouncerFromProvider(); - sandbox.clock.tick(1); - - bouncerFromProvider(); - sinon.assert.notCalled(spy); - $timeout.flush(); - sinon.assert.calledOnce(spy); - sinon.assert.calledOnce(cancelSpy); }); }); @@ -161,7 +124,6 @@ describe('debounce service', function () { it('should cancel the $timeout', function () { const cancelSpy = sinon.spy($timeout, 'cancel'); const bouncer = debounce(spy, 100); - const bouncerFromProvider = debounceFromProvider(spy, 100); bouncer(); bouncer.cancel(); @@ -170,12 +132,6 @@ describe('debounce service', function () { $timeout.verifyNoPendingTasks(); cancelSpy.resetHistory(); - - bouncerFromProvider(); - bouncerFromProvider.cancel(); - sinon.assert.calledOnce(cancelSpy); - // throws if pending timeouts - $timeout.verifyNoPendingTasks(); }); }); }); diff --git a/src/plugins/discover/public/application/angular/directives/debounce/index.js b/src/plugins/discover/public/application/angular/directives/debounce/index.js index 35b8339263626..3c51895f19828 100644 --- a/src/plugins/discover/public/application/angular/directives/debounce/index.js +++ b/src/plugins/discover/public/application/angular/directives/debounce/index.js @@ -17,6 +17,4 @@ * under the License. */ -import './debounce'; - -export { DebounceProvider } from './debounce'; +export { createDebounceProviderTimeout } from './debounce'; diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.js index 182b4aeca9a23..e2d5f10a0faf7 100644 --- a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js +++ b/src/plugins/discover/public/application/angular/directives/fixed_scroll.js @@ -19,7 +19,7 @@ import $ from 'jquery'; import _ from 'lodash'; -import { DebounceProvider } from './debounce'; +import { createDebounceProviderTimeout } from './debounce'; const SCROLLER_HEIGHT = 20; @@ -28,124 +28,128 @@ const SCROLLER_HEIGHT = 20; * to the target element's real scrollbar. This is useful when the target element's horizontal scrollbar * might be waaaay down the page, like the doc table on Discover. */ -export function FixedScrollProvider(Private) { - const debounce = Private(DebounceProvider); - +export function FixedScrollProvider($timeout) { return { restrict: 'A', link: function ($scope, $el) { - let $window = $(window); - let $scroller = $('
').height(SCROLLER_HEIGHT); - - /** - * Remove the listeners bound in listen() - * @type {function} - */ - let unlisten = _.noop; - - /** - * Listen for scroll events on the $scroller and the $el, sets unlisten() - * - * unlisten must be called before calling or listen() will throw an Error - * - * Since the browser emits "scroll" events after setting scrollLeft - * the listeners also prevent tug-of-war - * - * @throws {Error} If unlisten was not called first - * @return {undefined} - */ - function listen() { - if (unlisten !== _.noop) { - throw new Error( - 'fixedScroll listeners were not cleaned up properly before re-listening!' - ); - } - - let blockTo; - function bind($from, $to) { - function handler() { - if (blockTo === $to) return (blockTo = null); - $to.scrollLeft((blockTo = $from).scrollLeft()); - } - - $from.on('scroll', handler); - return function () { - $from.off('scroll', handler); - }; - } - - unlisten = _.flow(bind($el, $scroller), bind($scroller, $el), function () { - unlisten = _.noop; - }); - } - - /** - * Revert DOM changes and event listeners - * @return {undefined} - */ - function cleanUp() { - unlisten(); - $scroller.detach(); - $el.css('padding-bottom', 0); - } - - /** - * Modify the DOM and attach event listeners based on need. - * Is called many times to re-setup, must be idempotent - * @return {undefined} - */ - function setup() { - cleanUp(); - - const containerWidth = $el.width(); - const contentWidth = $el.prop('scrollWidth'); - const containerHorizOverflow = contentWidth - containerWidth; - - const elTop = $el.offset().top - $window.scrollTop(); - const elBottom = elTop + $el.height(); - const windowVertOverflow = elBottom - $window.height(); - - const requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0; - if (!requireScroller) return; - - // push the content away from the scroller - $el.css('padding-bottom', SCROLLER_HEIGHT); - - // fill the scroller with a dummy element that mimics the content - $scroller - .width(containerWidth) - .html($('
').css({ width: contentWidth, height: SCROLLER_HEIGHT })) - .insertAfter($el); + return createFixedScroll($scope, $timeout)($el); + }, + }; +} - // listen for scroll events - listen(); +export function createFixedScroll($scope, $timeout) { + const debounce = createDebounceProviderTimeout($timeout); + return function (el) { + const $el = typeof el.css === 'function' ? el : $(el); + let $window = $(window); + let $scroller = $('
').height(SCROLLER_HEIGHT); + + /** + * Remove the listeners bound in listen() + * @type {function} + */ + let unlisten = _.noop; + + /** + * Listen for scroll events on the $scroller and the $el, sets unlisten() + * + * unlisten must be called before calling or listen() will throw an Error + * + * Since the browser emits "scroll" events after setting scrollLeft + * the listeners also prevent tug-of-war + * + * @throws {Error} If unlisten was not called first + * @return {undefined} + */ + function listen() { + if (unlisten !== _.noop) { + throw new Error('fixedScroll listeners were not cleaned up properly before re-listening!'); } - let width; - let scrollWidth; - function checkWidth() { - const newScrollWidth = $el.prop('scrollWidth'); - const newWidth = $el.width(); - - if (scrollWidth !== newScrollWidth || width !== newWidth) { - $scope.$apply(setup); - - scrollWidth = newScrollWidth; - width = newWidth; + let blockTo; + function bind($from, $to) { + function handler() { + if (blockTo === $to) return (blockTo = null); + $to.scrollLeft((blockTo = $from).scrollLeft()); } - } - const debouncedCheckWidth = debounce(checkWidth, 100, { - invokeApply: false, - }); - $scope.$watch(debouncedCheckWidth); + $from.on('scroll', handler); + return function () { + $from.off('scroll', handler); + }; + } - // cleanup when the scope is destroyed - $scope.$on('$destroy', function () { - cleanUp(); - debouncedCheckWidth.cancel(); - $scroller = $window = null; + unlisten = _.flow(bind($el, $scroller), bind($scroller, $el), function () { + unlisten = _.noop; }); - }, + } + + /** + * Revert DOM changes and event listeners + * @return {undefined} + */ + function cleanUp() { + unlisten(); + $scroller.detach(); + $el.css('padding-bottom', 0); + } + + /** + * Modify the DOM and attach event listeners based on need. + * Is called many times to re-setup, must be idempotent + * @return {undefined} + */ + function setup() { + cleanUp(); + + const containerWidth = $el.width(); + const contentWidth = $el.prop('scrollWidth'); + const containerHorizOverflow = contentWidth - containerWidth; + + const elTop = $el.offset().top - $window.scrollTop(); + const elBottom = elTop + $el.height(); + const windowVertOverflow = elBottom - $window.height(); + + const requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0; + if (!requireScroller) return; + + // push the content away from the scroller + $el.css('padding-bottom', SCROLLER_HEIGHT); + + // fill the scroller with a dummy element that mimics the content + $scroller + .width(containerWidth) + .html($('
').css({ width: contentWidth, height: SCROLLER_HEIGHT })) + .insertAfter($el); + + // listen for scroll events + listen(); + } + + let width; + let scrollWidth; + function checkWidth() { + const newScrollWidth = $el.prop('scrollWidth'); + const newWidth = $el.width(); + + if (scrollWidth !== newScrollWidth || width !== newWidth) { + $scope.$apply(setup); + + scrollWidth = newScrollWidth; + width = newWidth; + } + } + + const debouncedCheckWidth = debounce(checkWidth, 100, { + invokeApply: false, + }); + $scope.$watch(debouncedCheckWidth); + + function destroy() { + cleanUp(); + debouncedCheckWidth.cancel(); + $scroller = $window = null; + } + return destroy; }; } diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js index 65255d6c0c4a4..e44bb45cf2431 100644 --- a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js +++ b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js @@ -23,17 +23,12 @@ import $ from 'jquery'; import sinon from 'sinon'; -import { PrivateProvider, initAngularBootstrap } from '../../../../../kibana_legacy/public'; +import { initAngularBootstrap } from '../../../../../kibana_legacy/public'; import { FixedScrollProvider } from './fixed_scroll'; -import { DebounceProviderTimeout } from './debounce/debounce'; const testModuleName = 'fixedScroll'; -angular - .module(testModuleName, []) - .provider('Private', PrivateProvider) - .service('debounce', ['$timeout', DebounceProviderTimeout]) - .directive('fixedScroll', FixedScrollProvider); +angular.module(testModuleName, []).directive('fixedScroll', FixedScrollProvider); describe('FixedScroll directive', function () { const sandbox = sinon.createSandbox(); @@ -127,7 +122,7 @@ describe('FixedScroll directive', function () { return { $container: $el, $content: $content, - $scroller: $parent.find('.fixed-scroll-scroller'), + $scroller: $parent.find('.dscTableFixedScroll__scroller'), }; }; }); diff --git a/src/plugins/discover/public/application/angular/directives/no_results.js b/src/plugins/discover/public/application/angular/directives/no_results.js index 965c1271c2f2c..d8a39d9178e93 100644 --- a/src/plugins/discover/public/application/angular/directives/no_results.js +++ b/src/plugins/discover/public/application/angular/directives/no_results.js @@ -24,7 +24,6 @@ import PropTypes from 'prop-types'; import { EuiCallOut, EuiCode, - EuiCodeBlock, EuiDescriptionList, EuiFlexGroup, EuiFlexItem, @@ -37,72 +36,12 @@ import { getServices } from '../../../kibana_services'; // eslint-disable-next-line react/prefer-stateless-function export class DiscoverNoResults extends Component { static propTypes = { - shardFailures: PropTypes.array, timeFieldName: PropTypes.string, queryLanguage: PropTypes.string, }; render() { - const { shardFailures, timeFieldName, queryLanguage } = this.props; - - let shardFailuresMessage; - - if (shardFailures && shardFailures.length) { - const failures = shardFailures.map((failure, index) => ( -
- - - - - ), - failureShard: `‘${failure.shard}’`, - }} - /> - - - - - {JSON.stringify(failure.reason)} - - {index < shardFailures.length - 1 ? : undefined} -
- )); - - shardFailuresMessage = ( - - - - -

- -

- -

- -

- - {failures} -
-
- ); - } + const { timeFieldName, queryLanguage } = this.props; let timeFieldMessage; @@ -264,8 +203,6 @@ export class DiscoverNoResults extends Component { iconType="help" data-test-subj="discoverNoResults" /> - - {shardFailuresMessage} {timeFieldMessage} {luceneQueryMessage} diff --git a/src/plugins/discover/public/application/angular/directives/no_results.test.js b/src/plugins/discover/public/application/angular/directives/no_results.test.js index 7de792c612993..60c50048a39ef 100644 --- a/src/plugins/discover/public/application/angular/directives/no_results.test.js +++ b/src/plugins/discover/public/application/angular/directives/no_results.test.js @@ -42,35 +42,6 @@ beforeEach(() => { describe('DiscoverNoResults', () => { describe('props', () => { - describe('shardFailures', () => { - test('renders failures list when there are failures', () => { - const shardFailures = [ - { - index: 'A', - shard: '1', - reason: { reason: 'Awful error' }, - }, - { - index: 'B', - shard: '2', - reason: { reason: 'Bad error' }, - }, - ]; - - const component = renderWithIntl(); - - expect(component).toMatchSnapshot(); - }); - - test(`doesn't render failures list when there are no failures`, () => { - const shardFailures = []; - - const component = renderWithIntl(); - - expect(component).toMatchSnapshot(); - }); - }); - describe('timeFieldName', () => { test('renders time range feedback', () => { const component = renderWithIntl(); diff --git a/src/plugins/discover/public/application/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html deleted file mode 100644 index e0e452aaa41c5..0000000000000 --- a/src/plugins/discover/public/application/angular/discover.html +++ /dev/null @@ -1,160 +0,0 @@ - -

{{screenTitle}}

- - - - - -
-
-
-
- - -
-
- -
- - - - - -
- - - -
- -
- -
- - - - - -
- - - - -
- -
-

- - - - - -
-
-
-
-
-
-
diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index b75ac75e5f2ed..7871cc4b16464 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -29,12 +29,11 @@ import { getState, splitState } from './discover_state'; import { RequestAdapter } from '../../../../inspector/public'; import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; import { getSortArray, getSortForSearchSource } from './doc_table'; +import { createFixedScroll } from './directives/fixed_scroll'; import * as columnActions from './doc_table/actions/columns'; - -import indexTemplate from './discover.html'; +import indexTemplateLegacy from './discover_legacy.html'; import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; -import '../components/fetch_error'; import { getPainlessError } from './get_painless_error'; import { discoverResponseHandler } from './response_handler'; import { @@ -71,7 +70,6 @@ import { indexPatterns as indexPatternsUtils, connectToQueryState, syncQueryStateWithUrl, - search, } from '../../../../data/public'; import { getIndexPatternId } from '../helpers/get_index_pattern_id'; import { addFatalError } from '../../../../kibana_legacy/public'; @@ -115,7 +113,7 @@ app.config(($routeProvider) => { }; const discoverRoute = { ...defaults, - template: indexTemplate, + template: indexTemplateLegacy, reloadOnSearch: false, resolve: { savedObjects: function ($route, Promise) { @@ -308,18 +306,10 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise mode: 'absolute', }); }; - $scope.intervalOptions = search.aggs.intervalOptions; $scope.minimumVisibleRows = 50; $scope.fetchStatus = fetchStatuses.UNINITIALIZED; $scope.showSaveQuery = uiCapabilities.discover.saveQuery; - $scope.$watch( - () => uiCapabilities.discover.saveQuery, - (newCapability) => { - $scope.showSaveQuery = newCapability; - } - ); - let abortController; $scope.$on('$destroy', () => { if (abortController) abortController.abort(); @@ -471,7 +461,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise ]; }; $scope.topNavMenu = getTopNavLinks(); - $scope.setHeaderActionMenu = getHeaderActionMenuMounter(); $scope.searchSource .setField('index', $scope.indexPattern) @@ -515,8 +504,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise ]); } - $scope.screenTitle = savedSearch.title; - const getFieldCounts = async () => { // the field counts aren't set until we have the data back, // so we wait for the fetch to be done before proceeding @@ -612,6 +599,9 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise timefield: getTimeField(), savedSearch: savedSearch, indexPatternList: $route.current.locals.savedObjects.ip.list, + config: config, + fixedScroll: createFixedScroll($scope, $timeout), + setHeaderActionMenu: getHeaderActionMenuMounter(), }; const shouldSearchOnPageLoad = () => { @@ -771,6 +761,7 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise if (!init.complete) return; $scope.fetchCounter++; $scope.fetchError = undefined; + $scope.minimumVisibleRows = 50; if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { $scope.resultState = 'none'; return; @@ -868,9 +859,6 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise tabifiedData, getDimensions($scope.vis.data.aggs.aggs, $scope.timeRange) ); - if ($scope.vis.data.aggs.aggs[1]) { - $scope.bucketInterval = $scope.vis.data.aggs.aggs[1].buckets.getInterval(); - } $scope.updateTime(); } diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html new file mode 100644 index 0000000000000..8582f71c0cb88 --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -0,0 +1,36 @@ + + + + diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index ac0dc054485f0..5ddb6a92b5fd4 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -55,6 +55,10 @@ export interface AppState { * Array of the used sorting [[field,direction],...] */ sort?: string[][]; + /** + * id of the used saved query + */ + savedQuery?: string; } interface GetStateParams { diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx new file mode 100644 index 0000000000000..ad2b674af014c --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -0,0 +1,131 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import angular, { auto, ICompileService, IScope } from 'angular'; +import { render } from 'react-dom'; +import React, { useRef, useEffect } from 'react'; +import { getServices, IIndexPattern } from '../../../kibana_services'; +import { IndexPatternField } from '../../../../../data/common/index_patterns'; +export type AngularScope = IScope; + +export interface AngularDirective { + template: string; +} + +/** + * Compiles and injects the give angular template into the given dom node + * returns a function to cleanup the injected angular element + */ +export async function injectAngularElement( + domNode: Element, + template: string, + scopeProps: any, + getInjector: () => Promise +): Promise<() => void> { + const $injector = await getInjector(); + const rootScope: AngularScope = $injector.get('$rootScope'); + const $compile: ICompileService = $injector.get('$compile'); + const newScope = Object.assign(rootScope.$new(), scopeProps); + + const $target = angular.element(domNode); + const $element = angular.element(template); + + newScope.$apply(() => { + const linkFn = $compile($element); + $target.empty().append($element); + linkFn(newScope); + }); + + return () => { + newScope.$destroy(); + }; +} + +/** + * Converts a given legacy angular directive to a render function + * for usage in a react component. Note that the rendering is async + */ +export function convertDirectiveToRenderFn( + directive: AngularDirective, + getInjector: () => Promise +) { + return (domNode: Element, props: any) => { + let rejected = false; + + const cleanupFnPromise = injectAngularElement(domNode, directive.template, props, getInjector); + cleanupFnPromise.catch(() => { + rejected = true; + render(
error
, domNode); + }); + + return () => { + if (!rejected) { + // for cleanup + // http://roubenmeschian.com/rubo/?p=51 + cleanupFnPromise.then((cleanup) => cleanup()); + } + }; + }; +} + +export interface DocTableLegacyProps { + columns: string[]; + searchDescription?: string; + searchTitle?: string; + onFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + rows: Array>; + indexPattern: IIndexPattern; + minimumVisibleRows: number; + onAddColumn: (column: string) => void; + onSort: (sort: string[][]) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + sort?: string[][]; +} + +export function DocTableLegacy(renderProps: DocTableLegacyProps) { + const renderFn = convertDirectiveToRenderFn( + { + template: ``, + }, + () => getServices().getEmbeddableInjector() + ); + const ref = useRef(null); + useEffect(() => { + if (ref && ref.current) { + return renderFn(ref.current, renderProps); + } + }, [renderFn, renderProps]); + return
; +} diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts index f972c158ff3dd..735ee9f555740 100644 --- a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.ts @@ -50,10 +50,6 @@ export function createDocTableDirective(pagerFactory: any, $filter: any) { inspectorAdapters: '=?', }, link: ($scope: LazyScope, $el: JQuery) => { - $scope.$watch('minimumVisibleRows', (minimumVisibleRows: number) => { - $scope.limit = Math.max(minimumVisibleRows || 50, $scope.limit || 50); - }); - $scope.persist = { sorting: $scope.sorting, columns: $scope.columns, @@ -77,7 +73,7 @@ export function createDocTableDirective(pagerFactory: any, $filter: any) { if (!hits) return; // Reset infinite scroll limit - $scope.limit = 50; + $scope.limit = $scope.minimumVisibleRows || 50; if (hits.length === 0) { dispatchRenderComplete($el[0]); diff --git a/src/plugins/discover/public/application/angular/get_painless_error.ts b/src/plugins/discover/public/application/angular/get_painless_error.ts index e1e98d9df27b1..162dacd3ac3b7 100644 --- a/src/plugins/discover/public/application/angular/get_painless_error.ts +++ b/src/plugins/discover/public/application/angular/get_painless_error.ts @@ -18,20 +18,77 @@ */ import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -export function getPainlessError(error: Error) { - const rootCause: Array<{ lang: string; script: string }> | undefined = get( - error, - 'body.attributes.error.root_cause' - ); - const message: string = get(error, 'body.message'); +interface FailedShards { + shard: number; + index: string; + node: string; + reason: { + type: string; + reason: string; + script_stack: string[]; + script: string; + lang: string; + position: { + offset: number; + start: number; + end: number; + }; + caused_by: { + type: string; + reason: string; + }; + }; +} + +interface EsError { + body: { + statusCode: number; + error: string; + message: string; + attributes?: { + error?: { + root_cause?: [ + { + lang: string; + script: string; + } + ]; + type: string; + reason: string; + caused_by: { + type: string; + reason: string; + phase: string; + grouped: boolean; + failed_shards: FailedShards[]; + }; + }; + }; + }; +} + +export function getCause(error: EsError) { + const cause = error.body?.attributes?.error?.root_cause; + if (cause) { + return cause[0]; + } + + const failedShards = error.body?.attributes?.error?.caused_by?.failed_shards; + + if (failedShards && failedShards[0] && failedShards[0].reason) { + return error.body?.attributes?.error?.caused_by?.failed_shards[0].reason; + } +} + +export function getPainlessError(error: EsError) { + const cause = getCause(error); - if (!rootCause) { + if (!cause) { return; } - const [{ lang, script }] = rootCause; + const { lang, script } = cause; if (lang !== 'painless') { return; @@ -44,6 +101,6 @@ export function getPainlessError(error: Error) { defaultMessage: "Error with Painless scripted field '{script}'.", values: { script }, }), - error: message, + error: error.body?.message, }; } diff --git a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts new file mode 100644 index 0000000000000..a3502cbb211fa --- /dev/null +++ b/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoverLegacy } from './discover_legacy'; + +export function createDiscoverLegacyDirective(reactDirective: any) { + return reactDirective(DiscoverLegacy, [ + ['addColumn', { watchDepth: 'reference' }], + ['fetch', { watchDepth: 'reference' }], + ['fetchCounter', { watchDepth: 'reference' }], + ['fetchError', { watchDepth: 'reference' }], + ['fieldCounts', { watchDepth: 'reference' }], + ['histogramData', { watchDepth: 'reference' }], + ['hits', { watchDepth: 'reference' }], + ['indexPattern', { watchDepth: 'reference' }], + ['minimumVisibleRows', { watchDepth: 'reference' }], + ['onAddFilter', { watchDepth: 'reference' }], + ['onChangeInterval', { watchDepth: 'reference' }], + ['onMoveColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ['onSetColumns', { watchDepth: 'reference' }], + ['onSkipBottomButtonClick', { watchDepth: 'reference' }], + ['onSort', { watchDepth: 'reference' }], + ['opts', { watchDepth: 'reference' }], + ['resetQuery', { watchDepth: 'reference' }], + ['resultState', { watchDepth: 'reference' }], + ['rows', { watchDepth: 'reference' }], + ['savedSearch', { watchDepth: 'reference' }], + ['searchSource', { watchDepth: 'reference' }], + ['setIndexPattern', { watchDepth: 'reference' }], + ['showSaveQuery', { watchDepth: 'reference' }], + ['state', { watchDepth: 'reference' }], + ['timefilterUpdateHandler', { watchDepth: 'reference' }], + ['timeRange', { watchDepth: 'reference' }], + ['topNavMenu', { watchDepth: 'reference' }], + ['updateQuery', { watchDepth: 'reference' }], + ['updateSavedQueryId', { watchDepth: 'reference' }], + ['vis', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover/public/application/components/discover_legacy.tsx new file mode 100644 index 0000000000000..1a98843649259 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_legacy.tsx @@ -0,0 +1,324 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useCallback, useEffect } from 'react'; +import classNames from 'classnames'; +import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { IUiSettingsClient, MountPoint } from 'kibana/public'; +import { HitsCounter } from './hits_counter'; +import { TimechartHeader } from './timechart_header'; +import { DiscoverSidebar } from './sidebar'; +import { getServices, IIndexPattern } from '../../kibana_services'; +// @ts-ignore +import { DiscoverNoResults } from '../angular/directives/no_results'; +import { DiscoverUninitialized } from '../angular/directives/uninitialized'; +import { DiscoverHistogram } from '../angular/directives/histogram'; +import { LoadingSpinner } from './loading_spinner/loading_spinner'; +import { DiscoverFetchError, FetchError } from './fetch_error/fetch_error'; +import { DocTableLegacy } from '../angular/doc_table/create_doc_table_react'; +import { SkipBottomButton } from './skip_bottom_button'; +import { + IndexPatternField, + search, + ISearchSource, + TimeRange, + Query, + IndexPatternAttributes, +} from '../../../../data/public'; +import { Chart } from '../angular/helpers/point_series'; +import { AppState } from '../angular/discover_state'; +import { SavedSearch } from '../../saved_searches'; + +import { SavedObject } from '../../../../../core/types'; +import { Vis } from '../../../../visualizations/public'; +import { TopNavMenuData } from '../../../../navigation/public'; + +export interface DiscoverLegacyProps { + addColumn: (column: string) => void; + fetch: () => void; + fetchCounter: number; + fetchError: FetchError; + fieldCounts: Record; + histogramData: Chart; + hits: number; + indexPattern: IIndexPattern; + minimumVisibleRows: number; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + onChangeInterval: (interval: string) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + onSetColumns: (columns: string[]) => void; + onSkipBottomButtonClick: () => void; + onSort: (sort: string[][]) => void; + opts: { + savedSearch: SavedSearch; + config: IUiSettingsClient; + indexPatternList: Array>; + timefield: string; + sampleSize: number; + fixedScroll: (el: HTMLElement) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + }; + resetQuery: () => void; + resultState: string; + rows: Array>; + searchSource: ISearchSource; + setIndexPattern: (id: string) => void; + showSaveQuery: boolean; + state: AppState; + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; + timeRange?: { from: string; to: string }; + topNavMenu: TopNavMenuData[]; + updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + updateSavedQueryId: (savedQueryId?: string) => void; + vis?: Vis; +} + +export function DiscoverLegacy({ + addColumn, + fetch, + fetchCounter, + fetchError, + fieldCounts, + histogramData, + hits, + indexPattern, + minimumVisibleRows, + onAddFilter, + onChangeInterval, + onMoveColumn, + onRemoveColumn, + onSkipBottomButtonClick, + onSort, + opts, + resetQuery, + resultState, + rows, + searchSource, + setIndexPattern, + showSaveQuery, + state, + timefilterUpdateHandler, + timeRange, + topNavMenu, + updateQuery, + updateSavedQueryId, + vis, +}: DiscoverLegacyProps) { + const [isSidebarClosed, setIsSidebarClosed] = useState(false); + const { TopNavMenu } = getServices().navigation.ui; + const { savedSearch, indexPatternList } = opts; + const bucketAggConfig = vis?.data?.aggs?.aggs[1]; + const bucketInterval = + bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) + ? bucketAggConfig.buckets?.getInterval() + : undefined; + const [fixedScrollEl, setFixedScrollEl] = useState(); + + useEffect(() => (fixedScrollEl ? opts.fixedScroll(fixedScrollEl) : undefined), [ + fixedScrollEl, + opts, + ]); + const fixedScrollRef = useCallback( + (node: HTMLElement) => { + if (node !== null) { + setFixedScrollEl(node); + } + }, + [setFixedScrollEl] + ); + const sidebarClassName = classNames({ + closed: isSidebarClosed, + }); + + const mainSectionClassName = classNames({ + 'col-md-10': !isSidebarClosed, + 'col-md-12': isSidebarClosed, + }); + + return ( + +
+

{savedSearch.title}

+ +
+
+
+ {!isSidebarClosed && ( +
+ +
+ )} + setIsSidebarClosed(!isSidebarClosed)} + data-test-subj="collapseSideBarButton" + aria-controls="discover-sidebar" + aria-expanded={isSidebarClosed ? 'false' : 'true'} + aria-label="Toggle sidebar" + className="dscCollapsibleSidebar__collapseButton" + /> +
+
+ {resultState === 'none' && ( + + )} + {resultState === 'uninitialized' && } + {/* @TODO: Solved in the Angular way to satisfy functional test - should be improved*/} + + {fetchError && } +
+ +
+
+ {resultState === 'ready' && ( +
+ + 0 ? hits : 0} + showResetButton={!!(savedSearch && savedSearch.id)} + onResetQuery={resetQuery} + /> + {opts.timefield && ( + + )} + + {opts.timefield && ( +
+ {vis && rows.length !== 0 && ( +
+ +
+ )} +
+ )} + +
+
+

+ +

+ {rows && rows.length && ( +
+ + + ​ + + {rows.length === opts.sampleSize && ( +
+ + + window.scrollTo(0, 0)}> + + +
+ )} +
+ )} +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx index 880a493983adf..dc8f1238eac6f 100644 --- a/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx +++ b/src/plugins/discover/public/application/components/fetch_error/fetch_error.tsx @@ -20,18 +20,20 @@ import './fetch_error.scss'; import React, { Fragment } from 'react'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiCallOut, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; -import { getAngularModule, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; + +export interface FetchError { + lang: string; + script: string; + message: string; + error: string; +} interface Props { - fetchError: { - lang: string; - script: string; - message: string; - error: string; - }; + fetchError: FetchError; } -const DiscoverFetchError = ({ fetchError }: Props) => { +export const DiscoverFetchError = ({ fetchError }: Props) => { if (!fetchError) { return null; } @@ -92,9 +94,3 @@ const DiscoverFetchError = ({ fetchError }: Props) => { ); }; - -export function createFetchErrorDirective(reactDirective: any) { - return reactDirective(DiscoverFetchError); -} - -getAngularModule().directive('discoverFetchError', createFetchErrorDirective); diff --git a/src/plugins/discover/public/application/components/fetch_error/index.js b/src/plugins/discover/public/application/components/fetch_error/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/fetch_error/index.js rename to src/plugins/discover/public/application/components/fetch_error/index.ts diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts b/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts deleted file mode 100644 index 8d45e28370cad..0000000000000 --- a/src/plugins/discover/public/application/components/hits_counter/hits_counter_directive.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { HitsCounter } from './hits_counter'; - -export function createHitsCounterDirective(reactDirective: any) { - return reactDirective(HitsCounter, [ - ['hits', { watchDepth: 'reference' }], - ['showResetButton', { watchDepth: 'reference' }], - ['onResetQuery', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/application/components/hits_counter/index.ts b/src/plugins/discover/public/application/components/hits_counter/index.ts index 58e7a9eda7f51..0ce95f061df17 100644 --- a/src/plugins/discover/public/application/components/hits_counter/index.ts +++ b/src/plugins/discover/public/application/components/hits_counter/index.ts @@ -18,4 +18,3 @@ */ export { HitsCounter } from './hits_counter'; -export { createHitsCounterDirective } from './hits_counter_directive'; diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx index 44b922bf0f708..4e1754638d479 100644 --- a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx @@ -18,24 +18,18 @@ */ import React from 'react'; import { EuiLoadingSpinner, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; export function LoadingSpinner() { return ( - - <> - -

- -

-
- - - -
+ <> + +

+ +

+
+ + + ); } - -export function createLoadingSpinnerDirective(reactDirective: any) { - return reactDirective(LoadingSpinner); -} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 850624888b24a..2407cff181901 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -68,7 +68,7 @@ export interface DiscoverSidebarProps { /** * Currently selected index pattern */ - selectedIndexPattern: IndexPattern; + selectedIndexPattern?: IndexPattern; /** * Callback function to select another index pattern */ diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts b/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts deleted file mode 100644 index b271c920e5e01..0000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar_directive.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { DiscoverSidebar } from './discover_sidebar'; - -export function createDiscoverSidebarDirective(reactDirective: any) { - return reactDirective(DiscoverSidebar, [ - ['columns', { watchDepth: 'reference' }], - ['fieldCounts', { watchDepth: 'reference' }], - ['hits', { watchDepth: 'reference' }], - ['indexPatternList', { watchDepth: 'reference' }], - ['onAddField', { watchDepth: 'reference' }], - ['onAddFilter', { watchDepth: 'reference' }], - ['onRemoveField', { watchDepth: 'reference' }], - ['selectedIndexPattern', { watchDepth: 'reference' }], - ['setIndexPattern', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/application/components/sidebar/index.ts b/src/plugins/discover/public/application/components/sidebar/index.ts index 1b837840b52f6..aec8dfc86e817 100644 --- a/src/plugins/discover/public/application/components/sidebar/index.ts +++ b/src/plugins/discover/public/application/components/sidebar/index.ts @@ -18,4 +18,3 @@ */ export { DiscoverSidebar } from './discover_sidebar'; -export { createDiscoverSidebarDirective } from './discover_sidebar_directive'; diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts index 13051f88c9591..22a6e7a628555 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_details.ts @@ -25,8 +25,11 @@ export function getDetails( field: IndexPatternField, hits: Array>, columns: string[], - indexPattern: IndexPattern + indexPattern?: IndexPattern ) { + if (!indexPattern) { + return {}; + } const details = { ...fieldCalculator.getFieldValueCounts({ hits, diff --git a/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts index c96a8f5ce17b9..eff7c2ec3c1c8 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts +++ b/src/plugins/discover/public/application/components/sidebar/lib/get_index_pattern_field_list.ts @@ -20,8 +20,8 @@ import { difference } from 'lodash'; import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; export function getIndexPatternFieldList( - indexPattern: IndexPattern, - fieldCounts: Record + indexPattern?: IndexPattern, + fieldCounts?: Record ) { if (!indexPattern || !fieldCounts) return []; diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/index.ts b/src/plugins/discover/public/application/components/skip_bottom_button/index.ts index 2feaa35e0d61f..b3d93e40be0bd 100644 --- a/src/plugins/discover/public/application/components/skip_bottom_button/index.ts +++ b/src/plugins/discover/public/application/components/skip_bottom_button/index.ts @@ -18,4 +18,3 @@ */ export { SkipBottomButton } from './skip_bottom_button'; -export { createSkipBottomButtonDirective } from './skip_bottom_button_directive'; diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts deleted file mode 100644 index 27f17b25fd447..0000000000000 --- a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button_directive.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SkipBottomButton } from './skip_bottom_button'; - -export function createSkipBottomButtonDirective(reactDirective: any) { - return reactDirective(SkipBottomButton, [['onClick', { watchDepth: 'reference' }]]); -} diff --git a/src/plugins/discover/public/application/components/timechart_header/index.ts b/src/plugins/discover/public/application/components/timechart_header/index.ts index 43473319c318c..34bed2cd72a74 100644 --- a/src/plugins/discover/public/application/components/timechart_header/index.ts +++ b/src/plugins/discover/public/application/components/timechart_header/index.ts @@ -18,4 +18,3 @@ */ export { TimechartHeader } from './timechart_header'; -export { createTimechartHeaderDirective } from './timechart_header_directive'; diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx index a4c10e749d868..7889b05a88415 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx @@ -29,8 +29,10 @@ describe('timechart header', function () { beforeAll(() => { props = { - from: 'May 14, 2020 @ 11:05:13.590', - to: 'May 14, 2020 @ 11:20:13.590', + timeRange: { + from: 'May 14, 2020 @ 11:05:13.590', + to: 'May 14, 2020 @ 11:20:13.590', + }, stateInterval: 's', options: [ { @@ -47,9 +49,11 @@ describe('timechart header', function () { }, ], onChangeInterval: jest.fn(), - showScaledInfo: undefined, - bucketIntervalDescription: 'second', - bucketIntervalScale: undefined, + bucketInterval: { + scaled: undefined, + description: 'second', + scale: undefined, + }, }; }); @@ -58,8 +62,8 @@ describe('timechart header', function () { expect(component.find(EuiIconTip).length).toBe(0); }); - it('TimechartHeader renders an info text by providing the showScaledInfo property', () => { - props.showScaledInfo = true; + it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => { + props.bucketInterval!.scaled = true; component = mountWithIntl(); expect(component.find(EuiIconTip).length).toBe(1); }); diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx index 8789847058aff..1451106827ee0 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -27,16 +27,28 @@ import { } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import moment from 'moment'; export interface TimechartHeaderProps { /** - * the query from date string + * Format of date to be displayed */ - from: string; + dateFormat?: string; /** - * the query to date string + * Interval for the buckets of the recent request */ - to: string; + bucketInterval?: { + scaled?: boolean; + description?: string; + scale?: number; + }; + /** + * Range of dates to be displayed + */ + timeRange?: { + from: string; + to: string; + }; /** * Interval Options */ @@ -49,31 +61,29 @@ export interface TimechartHeaderProps { * selected interval */ stateInterval: string; - /** - * displays the scaled info of the interval - */ - showScaledInfo: boolean | undefined; - /** - * scaled info description - */ - bucketIntervalDescription: string; - /** - * bucket interval scale - */ - bucketIntervalScale: number | undefined; } export function TimechartHeader({ - from, - to, + bucketInterval, + dateFormat, + timeRange, options, onChangeInterval, stateInterval, - showScaledInfo, - bucketIntervalDescription, - bucketIntervalScale, }: TimechartHeaderProps) { const [interval, setInterval] = useState(stateInterval); + const toMoment = useCallback( + (datetime: string) => { + if (!datetime) { + return ''; + } + if (!dateFormat) { + return datetime; + } + return moment(datetime).format(dateFormat); + }, + [dateFormat] + ); useEffect(() => { setInterval(stateInterval); @@ -84,6 +94,10 @@ export function TimechartHeader({ onChangeInterval(e.target.value); }; + if (!timeRange || !bucketInterval) { + return null; + } + return ( @@ -95,7 +109,7 @@ export function TimechartHeader({ delay="long" > - {`${from} - ${to} ${ + {`${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${ interval !== 'auto' ? i18n.translate('discover.timechartHeader.timeIntervalSelect.per', { defaultMessage: 'per', @@ -125,7 +139,7 @@ export function TimechartHeader({ value={interval} onChange={handleIntervalChange} append={ - showScaledInfo ? ( + bucketInterval.scaled ? ( 1 + bucketInterval!.scale && bucketInterval!.scale > 1 ? i18n.translate('discover.bucketIntervalTooltip.tooLargeBucketsText', { defaultMessage: 'buckets that are too large', }) : i18n.translate('discover.bucketIntervalTooltip.tooManyBucketsText', { defaultMessage: 'too many buckets', }), - bucketIntervalDescription, + bucketIntervalDescription: bucketInterval.description, }, })} color="warning" diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts b/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts deleted file mode 100644 index 027236cd46521..0000000000000 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header_directive.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { TimechartHeader } from './timechart_header'; - -export function createTimechartHeaderDirective(reactDirective: any) { - return reactDirective(TimechartHeader, [ - ['from', { watchDepth: 'reference' }], - ['to', { watchDepth: 'reference' }], - ['options', { watchDepth: 'reference' }], - ['onChangeInterval', { watchDepth: 'reference' }], - ['stateInterval', { watchDepth: 'reference' }], - ['showScaledInfo', { watchDepth: 'reference' }], - ['bucketIntervalDescription', { watchDepth: 'reference' }], - ['bucketIntervalScale', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 12562d8571a25..fdb14b3f1f63e 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -44,6 +44,7 @@ import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; import { getHistory } from './kibana_services'; import { KibanaLegacyStart } from '../../kibana_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -58,6 +59,7 @@ export interface DiscoverServices { indexPatterns: IndexPatternsContract; inspector: InspectorPublicPluginStart; metadata: { branch: string }; + navigation: NavigationPublicPluginStart; share?: SharePluginStart; kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; @@ -65,6 +67,7 @@ export interface DiscoverServices { toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; getSavedSearchUrlById: (id: string) => Promise; + getEmbeddableInjector: any; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; } @@ -72,7 +75,8 @@ export interface DiscoverServices { export async function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, - context: PluginInitializerContext + context: PluginInitializerContext, + getEmbeddableInjector: any ): Promise { const services: SavedObjectKibanaServices = { savedObjectsClient: core.savedObjects.client, @@ -92,6 +96,7 @@ export async function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, + getEmbeddableInjector, getSavedSearchById: async (id: string) => savedObjectService.get(id), getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, @@ -100,6 +105,7 @@ export async function buildServices( metadata: { branch: context.env.packageInfo.branch, }, + navigation: plugins.navigation, share: plugins.share, kibanaLegacy: plugins.kibanaLegacy, urlForwarding: plugins.urlForwarding, diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 85b0752f13463..1ca0bb20e8723 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -40,16 +40,10 @@ import { createTableRowDirective } from './application/angular/doc_table/compone import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory'; import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll'; import { createDocViewerDirective } from './application/angular/doc_viewer'; -import { CollapsibleSidebarProvider } from './application/angular/directives/collapsible_sidebar/collapsible_sidebar'; -// @ts-ignore -import { FixedScrollProvider } from './application/angular/directives/fixed_scroll'; -// @ts-ignore -import { DebounceProviderTimeout } from './application/angular/directives/debounce/debounce'; import { createRenderCompleteDirective } from './application/angular/directives/render_complete'; import { initAngularBootstrap, configureAppAngularModule, - KbnAccessibleClickProvider, PrivateProvider, PromiseServiceCreator, registerListenEventListener, @@ -57,14 +51,10 @@ import { createTopNavDirective, createTopNavHelper, } from '../../kibana_legacy/public'; -import { createDiscoverSidebarDirective } from './application/components/sidebar'; -import { createHitsCounterDirective } from '././application/components/hits_counter'; -import { createLoadingSpinnerDirective } from '././application/components/loading_spinner/loading_spinner'; -import { createTimechartHeaderDirective } from './application/components/timechart_header'; import { createContextErrorMessageDirective } from './application/components/context_error_message'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; -import { createSkipBottomButtonDirective } from './application/components/skip_bottom_button'; +import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive'; /** * returns the main inner angular module, it contains all the parts of Angular Discover @@ -88,11 +78,9 @@ export function getInnerAngularModule( export function getInnerAngularModuleEmbeddable( name: string, core: CoreStart, - deps: DiscoverStartPlugins, - context: PluginInitializerContext + deps: DiscoverStartPlugins ) { - const module = initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); - return module; + return initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); } let initialized = false; @@ -129,8 +117,7 @@ export function initializeInnerAngularModule( ]) .config(watchMultiDecorator) .directive('icon', (reactDirective) => reactDirective(EuiIcon)) - .directive('renderComplete', createRenderCompleteDirective) - .service('debounce', ['$timeout', DebounceProviderTimeout]); + .directive('renderComplete', createRenderCompleteDirective); } return angular @@ -149,18 +136,9 @@ export function initializeInnerAngularModule( ]) .config(watchMultiDecorator) .run(registerListenEventListener) - .directive('icon', (reactDirective) => reactDirective(EuiIcon)) - .directive('kbnAccessibleClick', KbnAccessibleClickProvider) - .directive('collapsibleSidebar', CollapsibleSidebarProvider) - .directive('fixedScroll', FixedScrollProvider) .directive('renderComplete', createRenderCompleteDirective) - .directive('discoverSidebar', createDiscoverSidebarDirective) - .directive('skipBottomButton', createSkipBottomButtonDirective) - .directive('hitsCounter', createHitsCounterDirective) - .directive('loadingSpinner', createLoadingSpinnerDirective) - .directive('timechartHeader', createTimechartHeaderDirective) - .directive('contextErrorMessage', createContextErrorMessageDirective) - .service('debounce', ['$timeout', DebounceProviderTimeout]); + .directive('discoverLegacy', createDiscoverLegacyDirective) + .directive('contextErrorMessage', createContextErrorMessageDirective); } function createLocalPromiseModule() { diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index dd9b57b568e42..440bd3fdf86d3 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -327,7 +327,12 @@ export class DiscoverPlugin if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.initializerContext); + const services = await buildServices( + core, + plugins, + this.initializerContext, + this.getEmbeddableInjector + ); setServices(services); this.servicesInitialized = true; @@ -380,12 +385,7 @@ export class DiscoverPlugin const { core, plugins } = await this.initializeServices(); getServices().kibanaLegacy.loadFontAwesome(); const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); - getInnerAngularModuleEmbeddable( - embeddableAngularName, - core, - plugins, - this.initializerContext - ); + getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); const mountpoint = document.createElement('div'); this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); } diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index d601d087afcee..13361cb647ddc 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -28,6 +28,7 @@ export interface SavedSearch { columns: string[]; sort: SortOrder[]; destroy: () => void; + lastSavedTitle?: string; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/src/plugins/embeddable/.eslintrc.json b/src/plugins/embeddable/.eslintrc.json new file mode 100644 index 0000000000000..2aab6c2d9093b --- /dev/null +++ b/src/plugins/embeddable/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/src/legacy/server/status/lib/case_conversion.test.ts b/src/plugins/embeddable/common/lib/migrate_base_input.ts similarity index 61% rename from src/legacy/server/status/lib/case_conversion.test.ts rename to src/plugins/embeddable/common/lib/migrate_base_input.ts index a231ee0ba4b0f..0d5dc508e20ad 100644 --- a/src/legacy/server/status/lib/case_conversion.test.ts +++ b/src/plugins/embeddable/common/lib/migrate_base_input.ts @@ -17,20 +17,23 @@ * under the License. */ -import { keysToSnakeCaseShallow } from './case_conversion'; +import { SavedObjectReference } from '../../../../core/types'; +import { EmbeddableInput } from '../types'; -describe('keysToSnakeCaseShallow', () => { - test("should convert all of an object's keys to snake case", () => { - const data = { - camelCase: 'camel_case', - 'kebab-case': 'kebab_case', - snake_case: 'snake_case', - }; +export const telemetryBaseEmbeddableInput = ( + state: EmbeddableInput, + telemetryData: Record +) => { + return telemetryData; +}; - const result = keysToSnakeCaseShallow(data); +export const extractBaseEmbeddableInput = (state: EmbeddableInput) => { + return { state, references: [] as SavedObjectReference[] }; +}; - expect(result.camel_case).toBe('camel_case'); - expect(result.kebab_case).toBe('kebab_case'); - expect(result.snake_case).toBe('snake_case'); - }); -}); +export const injectBaseEmbeddableInput = ( + state: EmbeddableInput, + references: SavedObjectReference[] +) => { + return state; +}; diff --git a/src/plugins/embeddable/common/types.ts b/src/plugins/embeddable/common/types.ts new file mode 100644 index 0000000000000..68b842c934de8 --- /dev/null +++ b/src/plugins/embeddable/common/types.ts @@ -0,0 +1,70 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SerializableState } from '../../kibana_utils/common'; +import { Query, TimeRange } from '../../data/common/query'; +import { Filter } from '../../data/common/es_query/filters'; + +export enum ViewMode { + EDIT = 'edit', + VIEW = 'view', +} + +export type EmbeddableInput = { + viewMode?: ViewMode; + title?: string; + /** + * Note this is not a saved object id. It is used to uniquely identify this + * Embeddable instance from others (e.g. inside a container). It's possible to + * have two Embeddables where everything else is the same but the id. + */ + id: string; + lastReloadRequestTime?: number; + hidePanelTitles?: boolean; + + /** + * Reserved key for enhancements added by other plugins. + */ + enhancements?: SerializableState; + + /** + * List of action IDs that this embeddable should not render. + */ + disabledActions?: string[]; + + /** + * Whether this embeddable should not execute triggers. + */ + disableTriggers?: boolean; + + /** + * Time range of the chart. + */ + timeRange?: TimeRange; + + /** + * Visualization query string used to narrow down results. + */ + query?: Query; + + /** + * Visualization filters used to narrow down results. + */ + filters?: Filter[]; +}; diff --git a/src/plugins/embeddable/kibana.json b/src/plugins/embeddable/kibana.json index 6a8e6079232aa..1ecf76dbbd5c2 100644 --- a/src/plugins/embeddable/kibana.json +++ b/src/plugins/embeddable/kibana.json @@ -1,7 +1,7 @@ { "id": "embeddable", "version": "kibana", - "server": false, + "server": true, "ui": true, "requiredPlugins": [ "inspector", diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 33cf210763b10..5c95214ef591b 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -19,7 +19,6 @@ import { UiActionsSetup } from '../../ui_actions/public'; import { contextMenuTrigger, - createFilterAction, panelBadgeTrigger, EmbeddableContext, CONTEXT_MENU_TRIGGER, @@ -29,8 +28,6 @@ import { ACTION_INSPECT_PANEL, REMOVE_PANEL_ACTION, ACTION_EDIT_PANEL, - FilterActionContext, - ACTION_APPLY_FILTER, panelNotificationTrigger, PANEL_NOTIFICATION_TRIGGER, } from './lib'; @@ -48,7 +45,6 @@ declare module '../../ui_actions/public' { [ACTION_INSPECT_PANEL]: EmbeddableContext; [REMOVE_PANEL_ACTION]: EmbeddableContext; [ACTION_EDIT_PANEL]: EmbeddableContext; - [ACTION_APPLY_FILTER]: FilterActionContext; } } @@ -60,8 +56,4 @@ export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(contextMenuTrigger); uiActions.registerTrigger(panelBadgeTrigger); uiActions.registerTrigger(panelNotificationTrigger); - - const actionApplyFilter = createFilterAction(); - - uiActions.registerAction(actionApplyFilter); }; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 57253c1f741ab..7609f07d660bc 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -24,7 +24,6 @@ import { EmbeddablePublicPlugin } from './plugin'; export { ACTION_ADD_PANEL, - ACTION_APPLY_FILTER, ACTION_EDIT_PANEL, Adapters, AddPanelAction, @@ -78,6 +77,8 @@ export { EmbeddableRendererProps, } from './lib'; +export { EnhancementRegistryDefinition } from './types'; + export function plugin(initializerContext: PluginInitializerContext) { return new EmbeddablePublicPlugin(initializerContext); } diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts deleted file mode 100644 index 88c1a5917e609..0000000000000 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createFilterAction } from './apply_filter_action'; -import { expectErrorAsync } from '../../tests/helpers'; -import { defaultTrigger } from '../../../../ui_actions/public/triggers'; - -test('has ACTION_APPLY_FILTER type and id', () => { - const action = createFilterAction(); - expect(action.id).toBe('ACTION_APPLY_FILTER'); - expect(action.type).toBe('ACTION_APPLY_FILTER'); -}); - -test('has expected display name', () => { - const action = createFilterAction(); - expect(action.getDisplayName({} as any)).toMatchInlineSnapshot(`"Apply filter to current view"`); -}); - -describe('getIconType()', () => { - test('returns "filter" icon', async () => { - const action = createFilterAction(); - const result = action.getIconType({} as any); - expect(result).toBe('filter'); - }); -}); - -describe('isCompatible()', () => { - test('when embeddable filters and filters exist, returns true', async () => { - const action = createFilterAction(); - const result = await action.isCompatible({ - embeddable: { - getRoot: () => ({ - getInput: () => ({ - filters: [], - }), - }), - } as any, - filters: [], - trigger: defaultTrigger, - }); - expect(result).toBe(true); - }); - - test('when embeddable filters not set, returns false', async () => { - const action = createFilterAction(); - const result = await action.isCompatible({ - embeddable: { - getRoot: () => ({ - getInput: () => ({ - // filters: [], - }), - }), - } as any, - filters: [], - trigger: defaultTrigger, - }); - expect(result).toBe(false); - }); - - test('when triggerContext or filters are not set, returns false', async () => { - const action = createFilterAction(); - - const result1 = await action.isCompatible({ - embeddable: { - getRoot: () => ({ - getInput: () => ({ - filters: [], - }), - }), - } as any, - } as any); - expect(result1).toBe(false); - }); -}); - -const getEmbeddable = () => { - const root = { - getInput: jest.fn(() => ({ - filters: [], - })), - updateInput: jest.fn(), - }; - const embeddable = { - getRoot: () => root, - } as any; - return [embeddable, root]; -}; - -describe('execute()', () => { - describe('when no filters are given', () => { - test('throws an error', async () => { - const action = createFilterAction(); - const error = await expectErrorAsync(() => - action.execute({ - embeddable: getEmbeddable(), - } as any) - ); - expect(error).toBeInstanceOf(Error); - expect(error.message).toBe('Applying a filter requires a filter and embeddable as context'); - }); - - test('updates filter input on success', async () => { - const action = createFilterAction(); - const [embeddable, root] = getEmbeddable(); - - await action.execute({ - embeddable, - filters: ['FILTER' as any], - trigger: defaultTrigger, - }); - - expect(root.updateInput).toHaveBeenCalledTimes(1); - expect(root.updateInput.mock.calls[0][0]).toMatchObject({ - filters: ['FILTER'], - }); - }); - }); -}); diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts deleted file mode 100644 index 3460203aac29c..0000000000000 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import { ActionByType, createAction, IncompatibleActionError } from '../ui_actions'; -import { IEmbeddable, EmbeddableInput } from '../embeddables'; -import { Filter } from '../../../../../plugins/data/public'; - -export const ACTION_APPLY_FILTER = 'ACTION_APPLY_FILTER'; - -type RootEmbeddable = IEmbeddable; -export interface FilterActionContext { - embeddable: IEmbeddable; - filters: Filter[]; -} - -async function isCompatible(context: FilterActionContext) { - if (context.embeddable === undefined) { - return false; - } - const root = context.embeddable.getRoot() as RootEmbeddable; - return Boolean(root.getInput().filters !== undefined && context.filters !== undefined); -} - -export function createFilterAction(): ActionByType { - return createAction({ - type: ACTION_APPLY_FILTER, - id: ACTION_APPLY_FILTER, - order: 100, - getIconType: () => 'filter', - getDisplayName: () => { - return i18n.translate('embeddableApi.actions.applyFilterActionTitle', { - defaultMessage: 'Apply filter to current view', - }); - }, - isCompatible, - execute: async ({ embeddable, filters }) => { - if (!filters || !embeddable) { - throw new Error('Applying a filter requires a filter and embeddable as context'); - } - - if (!(await isCompatible({ embeddable, filters }))) { - throw new IncompatibleActionError(); - } - - const root = embeddable.getRoot() as RootEmbeddable; - - root.updateInput({ - filters, - }); - }, - }); -} diff --git a/src/plugins/embeddable/public/lib/actions/index.ts b/src/plugins/embeddable/public/lib/actions/index.ts index ea32c6aa2d455..8be2c3f5df450 100644 --- a/src/plugins/embeddable/public/lib/actions/index.ts +++ b/src/plugins/embeddable/public/lib/actions/index.ts @@ -17,5 +17,4 @@ * under the License. */ -export * from './apply_filter_action'; export * from './edit_panel_action'; diff --git a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts index b22f16c94aff8..e2047dca1f770 100644 --- a/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts +++ b/src/plugins/embeddable/public/lib/embeddables/default_embeddable_factory_provider.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ + import { SavedObjectAttributes } from 'kibana/public'; import { EmbeddableFactoryDefinition } from './embeddable_factory_definition'; import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; @@ -47,6 +48,9 @@ export const defaultEmbeddableFactoryProvider = < isEditable: def.isEditable.bind(def), getDisplayName: def.getDisplayName.bind(def), savedObjectMetaData: def.savedObjectMetaData, + telemetry: def.telemetry || (() => ({})), + inject: def.inject || ((state: EmbeddableInput) => state), + extract: def.extract || ((state: EmbeddableInput) => ({ state, references: [] })), }; return factory; }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index ffe8a5bf6e7dc..9267d600360cf 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -21,10 +21,11 @@ import { cloneDeep, isEqual } from 'lodash'; import * as Rx from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { RenderCompleteDispatcher } from '../../../../kibana_utils/public'; -import { Adapters, ViewMode } from '../types'; +import { Adapters } from '../types'; import { IContainer } from '../containers'; -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; +import { EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { TriggerContextMapping } from '../ui_actions'; +import { EmbeddableInput, ViewMode } from '../../../common/types'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts index 7949b6fb8ba27..a6fa46fbc4e3e 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory.ts @@ -23,6 +23,7 @@ import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { ErrorEmbeddable } from './error_embeddable'; import { IContainer } from '../containers/i_container'; import { PropertySpec } from '../types'; +import { PersistableState } from '../../../../kibana_utils/common'; export interface EmbeddableInstanceConfiguration { id: string; @@ -44,7 +45,7 @@ export interface EmbeddableFactory< TEmbeddableOutput >, TSavedObjectAttributes extends SavedObjectAttributes = SavedObjectAttributes -> { +> extends PersistableState { // A unique identified for this factory, which will be used to map an embeddable spec to // a factory that can generate an instance of it. readonly type: string; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts index b8985f7311ea9..224a11a201b88 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_factory_definition.ts @@ -40,5 +40,8 @@ export type EmbeddableFactoryDefinition< | 'savedObjectMetaData' | 'canCreateNew' | 'getDefaultInput' + | 'telemetry' + | 'extract' + | 'inject' > >; diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index e8aecdba0abc4..3843950c164c9 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -20,57 +20,15 @@ import { Observable } from 'rxjs'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; -import { ViewMode } from '../types'; import { TriggerContextMapping } from '../../../../ui_actions/public'; -import type { TimeRange, Query, Filter } from '../../../../data/common'; +import { EmbeddableInput } from '../../../common/types'; export interface EmbeddableError { name: string; message: string; } -export interface EmbeddableInput { - viewMode?: ViewMode; - title?: string; - /** - * Note this is not a saved object id. It is used to uniquely identify this - * Embeddable instance from others (e.g. inside a container). It's possible to - * have two Embeddables where everything else is the same but the id. - */ - id: string; - lastReloadRequestTime?: number; - hidePanelTitles?: boolean; - - /** - * Reserved key for enhancements added by other plugins. - */ - enhancements?: unknown; - - /** - * List of action IDs that this embeddable should not render. - */ - disabledActions?: string[]; - - /** - * Whether this embeddable should not execute triggers. - */ - disableTriggers?: boolean; - - /** - * Time range of the chart. - */ - timeRange?: TimeRange; - - /** - * Visualization query string used to narrow down results. - */ - query?: Query; - - /** - * Visualization filters used to narrow down results. - */ - filters?: Filter[]; -} +export { EmbeddableInput }; export interface EmbeddableOutput { // Whether the embeddable is actively loading. diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index f3c4cae720193..5f73ef2ca7688 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -37,7 +37,7 @@ export interface PanelHeaderProps { title?: string; isViewMode: boolean; hidePanelTitles: boolean; - getActionContextMenuPanel: () => Promise; + getActionContextMenuPanel: () => Promise; closeContextMenu: boolean; badges: Array>; notifications: Array>; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx index b4c349600e8f6..629a5f8c880e8 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx @@ -28,14 +28,14 @@ import { } from '@elastic/eui'; export interface PanelOptionsMenuProps { - getActionContextMenuPanel: () => Promise; + getActionContextMenuPanel: () => Promise; isViewMode: boolean; closeContextMenu: boolean; title?: string; } interface State { - actionContextMenuPanel?: EuiContextMenuPanelDescriptor; + actionContextMenuPanel?: EuiContextMenuPanelDescriptor[]; isPopoverOpen: boolean; } @@ -117,7 +117,7 @@ export class PanelOptionsMenu extends React.Component ); diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/filterable_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/filterable_container.tsx index ceaa74218904d..db71b94ac855f 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/filterable_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/filterable_container.tsx @@ -32,7 +32,6 @@ export interface FilterableContainerInput extends ContainerInput { * https://github.com/microsoft/TypeScript/issues/15300 is fixed so we use a type * here instead */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type InheritedChildrenInput = { filters: Filter[]; id?: string; diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index 913c3a0b30826..d47979b9419f3 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -30,7 +30,6 @@ export const HELLO_WORLD_CONTAINER = 'HELLO_WORLD_CONTAINER'; * https://github.com/microsoft/TypeScript/issues/15300 is fixed so we use a type * here instead */ -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions type InheritedInput = { id: string; viewMode: ViewMode; diff --git a/src/plugins/embeddable/public/lib/types.ts b/src/plugins/embeddable/public/lib/types.ts index 1cfff7baca186..7fe189dea2381 100644 --- a/src/plugins/embeddable/public/lib/types.ts +++ b/src/plugins/embeddable/public/lib/types.ts @@ -32,10 +32,5 @@ export interface PropertySpec { description: string; value?: string; } - -export enum ViewMode { - EDIT = 'edit', - VIEW = 'view', -} - +export { ViewMode } from '../../common/types'; export { Adapters }; diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index 2064236e9ae7f..26c10121adb3d 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -109,6 +109,7 @@ export const mockRefOrValEmbeddable = < const createSetupContract = (): Setup => { const setupContract: Setup = { registerEmbeddableFactory: jest.fn(), + registerEnhancement: jest.fn(), setCustomEmbeddableFactoryProvider: jest.fn(), }; return setupContract; @@ -118,6 +119,9 @@ const createStartContract = (): Start => { const startContract: Start = { getEmbeddableFactories: jest.fn(), getEmbeddableFactory: jest.fn(), + telemetry: jest.fn(), + extract: jest.fn(), + inject: jest.fn(), EmbeddablePanel: jest.fn(), getEmbeddablePanel: jest.fn(), getStateTransfer: jest.fn(() => createEmbeddableStateTransferMock() as EmbeddableStateTransfer), diff --git a/src/plugins/embeddable/public/plugin.test.ts b/src/plugins/embeddable/public/plugin.test.ts index e37d602ad8cac..5fd3bcdd61318 100644 --- a/src/plugins/embeddable/public/plugin.test.ts +++ b/src/plugins/embeddable/public/plugin.test.ts @@ -22,21 +22,6 @@ import { EmbeddableFactoryProvider } from './types'; import { defaultEmbeddableFactoryProvider } from './lib'; import { HelloWorldEmbeddable } from '../../../../examples/embeddable_examples/public'; -test('cannot register embeddable factory with the same ID', async () => { - const coreSetup = coreMock.createSetup(); - const coreStart = coreMock.createStart(); - const { setup } = testPlugin(coreSetup, coreStart); - const embeddableFactoryId = 'ID'; - const embeddableFactory = {} as any; - - setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); - expect(() => - setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory) - ).toThrowError( - 'Embeddable factory [embeddableFactoryId = ID] already registered in Embeddables API.' - ); -}); - test('can set custom embeddable factory provider', async () => { const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -108,3 +93,90 @@ test('custom embeddable factory provider test for intercepting embeddable creati await new Promise((resolve) => process.nextTick(resolve)); expect(updateCount).toEqual(0); }); + +describe('embeddable factory', () => { + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + const { setup, doStart } = testPlugin(coreSetup, coreStart); + const start = doStart(); + const embeddableFactoryId = 'ID'; + const embeddableFactory = { + type: embeddableFactoryId, + create: jest.fn(), + getDisplayName: () => 'Test', + isEditable: () => Promise.resolve(true), + extract: jest.fn().mockImplementation((state) => ({ state, references: [] })), + inject: jest.fn().mockImplementation((state) => state), + telemetry: jest.fn().mockResolvedValue({}), + } as any; + const embeddableState = { + id: embeddableFactoryId, + my: 'state', + } as any; + + test('cannot register embeddable factory with the same ID', async () => { + setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory); + expect(() => + setup.registerEmbeddableFactory(embeddableFactoryId, embeddableFactory) + ).toThrowError( + 'Embeddable factory [embeddableFactoryId = ID] already registered in Embeddables API.' + ); + }); + + test('embeddableFactory extract function gets called when calling embeddable extract', () => { + start.extract(embeddableState); + expect(embeddableFactory.extract).toBeCalledWith(embeddableState); + }); + + test('embeddableFactory inject function gets called when calling embeddable inject', () => { + start.inject(embeddableState, []); + expect(embeddableFactory.extract).toBeCalledWith(embeddableState); + }); + + test('embeddableFactory telemetry function gets called when calling embeddable telemetry', () => { + start.telemetry(embeddableState, {}); + expect(embeddableFactory.telemetry).toBeCalledWith(embeddableState, {}); + }); +}); + +describe('embeddable enhancements', () => { + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + const { setup, doStart } = testPlugin(coreSetup, coreStart); + const start = doStart(); + const embeddableEnhancement = { + id: 'test', + extract: jest.fn().mockImplementation((state) => ({ state, references: [] })), + inject: jest.fn().mockImplementation((state) => state), + telemetry: jest.fn().mockResolvedValue({}), + } as any; + const embeddableState = { + enhancements: { + test: { + my: 'state', + }, + }, + } as any; + + test('cannot register embeddable enhancement with the same ID', async () => { + setup.registerEnhancement(embeddableEnhancement); + expect(() => setup.registerEnhancement(embeddableEnhancement)).toThrowError( + 'enhancement with id test already exists in the registry' + ); + }); + + test('enhancement extract function gets called when calling embeddable extract', () => { + start.extract(embeddableState); + expect(embeddableEnhancement.extract).toBeCalledWith(embeddableState.enhancements.test); + }); + + test('enhancement inject function gets called when calling embeddable inject', () => { + start.inject(embeddableState, []); + expect(embeddableEnhancement.extract).toBeCalledWith(embeddableState.enhancements.test); + }); + + test('enhancement telemetry function gets called when calling embeddable telemetry', () => { + start.telemetry(embeddableState, {}); + expect(embeddableEnhancement.telemetry).toBeCalledWith(embeddableState.enhancements.test, {}); + }); +}); diff --git a/src/plugins/embeddable/public/plugin.tsx b/src/plugins/embeddable/public/plugin.tsx index 2ca31994b722d..00eb923c26662 100644 --- a/src/plugins/embeddable/public/plugin.tsx +++ b/src/plugins/embeddable/public/plugin.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import { Subscription } from 'rxjs'; +import { identity } from 'lodash'; import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; import { getSavedObjectFinder } from '../../saved_objects/public'; import { UiActionsSetup, UiActionsStart } from '../../ui_actions/public'; @@ -29,8 +30,15 @@ import { Plugin, ScopedHistory, PublicAppInfo, + SavedObjectReference, } from '../../../core/public'; -import { EmbeddableFactoryRegistry, EmbeddableFactoryProvider } from './types'; +import { + EmbeddableFactoryRegistry, + EmbeddableFactoryProvider, + EnhancementsRegistry, + EnhancementRegistryDefinition, + EnhancementRegistryItem, +} from './types'; import { bootstrap } from './bootstrap'; import { EmbeddableFactory, @@ -42,6 +50,12 @@ import { } from './lib'; import { EmbeddableFactoryDefinition } from './lib/embeddables/embeddable_factory_definition'; import { EmbeddableStateTransfer } from './lib/state_transfer'; +import { + extractBaseEmbeddableInput, + injectBaseEmbeddableInput, + telemetryBaseEmbeddableInput, +} from '../common/lib/migrate_base_input'; +import { PersistableState, SerializableState } from '../../kibana_utils/common'; export interface EmbeddableSetupDependencies { data: DataPublicPluginSetup; @@ -63,10 +77,11 @@ export interface EmbeddableSetup { id: string, factory: EmbeddableFactoryDefinition ) => () => EmbeddableFactory; + registerEnhancement: (enhancement: EnhancementRegistryDefinition) => void; setCustomEmbeddableFactoryProvider: (customProvider: EmbeddableFactoryProvider) => void; } -export interface EmbeddableStart { +export interface EmbeddableStart extends PersistableState { getEmbeddableFactory: < I extends EmbeddableInput = EmbeddableInput, O extends EmbeddableOutput = EmbeddableOutput, @@ -88,6 +103,7 @@ export class EmbeddablePublicPlugin implements Plugin = new Map(); private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map(); + private readonly enhancements: EnhancementsRegistry = new Map(); private customEmbeddableFactoryProvider?: EmbeddableFactoryProvider; private outgoingOnlyStateTransfer: EmbeddableStateTransfer = {} as EmbeddableStateTransfer; private isRegistryReady = false; @@ -101,6 +117,7 @@ export class EmbeddablePublicPlugin implements Plugin { if (this.customEmbeddableFactoryProvider) { throw new Error( @@ -168,6 +185,9 @@ export class EmbeddablePublicPlugin implements Plugin = {}) => { + const enhancements: Record = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + let telemetry = telemetryBaseEmbeddableInput(state, telemetryData); + if (factory) { + telemetry = factory.telemetry(state, telemetry); + } + Object.keys(enhancements).map((key) => { + if (!enhancements[key]) return; + telemetry = this.getEnhancement(key).telemetry(enhancements[key], telemetry); + }); + + return telemetry; + }; + + private extract = (state: EmbeddableInput) => { + const enhancements = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + const baseResponse = extractBaseEmbeddableInput(state); + let updatedInput = baseResponse.state; + const refs = baseResponse.references; + + if (factory) { + const factoryResponse = factory.extract(state); + updatedInput = factoryResponse.state; + refs.push(...factoryResponse.references); + } + + updatedInput.enhancements = {}; + Object.keys(enhancements).forEach((key) => { + if (!enhancements[key]) return; + const enhancementResult = this.getEnhancement(key).extract( + enhancements[key] as SerializableState + ); + refs.push(...enhancementResult.references); + updatedInput.enhancements![key] = enhancementResult.state; + }); + + return { + state: updatedInput, + references: refs, + }; + }; + + private inject = (state: EmbeddableInput, references: SavedObjectReference[]) => { + const enhancements = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + let updatedInput = injectBaseEmbeddableInput(state, references); + + if (factory) { + updatedInput = factory.inject(updatedInput, references); + } + + updatedInput.enhancements = {}; + Object.keys(enhancements).forEach((key) => { + if (!enhancements[key]) return; + updatedInput.enhancements![key] = this.getEnhancement(key).inject( + enhancements[key] as SerializableState, + references + ); + }); + + return updatedInput; + }; + + private registerEnhancement = (enhancement: EnhancementRegistryDefinition) => { + if (this.enhancements.has(enhancement.id)) { + throw new Error(`enhancement with id ${enhancement.id} already exists in the registry`); + } + this.enhancements.set(enhancement.id, { + id: enhancement.id, + telemetry: enhancement.telemetry || (() => ({})), + inject: enhancement.inject || identity, + extract: + enhancement.extract || + ((state: SerializableState) => { + return { state, references: [] }; + }), + }); + }; + + private getEnhancement = (id: string): EnhancementRegistryItem => { + return ( + this.enhancements.get(id) || { + id: 'unknown', + telemetry: () => ({}), + inject: identity, + extract: (state: SerializableState) => { + return { state, references: [] }; + }, + } + ); + }; + private getEmbeddableFactories = () => { this.ensureFactoriesExist(); return this.embeddableFactories.values(); @@ -215,12 +332,6 @@ export class EmbeddablePublicPlugin implements Plugin; }; diff --git a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts deleted file mode 100644 index f8c4a4a7e4b72..0000000000000 --- a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { testPlugin } from './test_plugin'; -import { EmbeddableOutput, isErrorEmbeddable, createFilterAction } from '../lib'; -import { - FilterableContainer, - FilterableContainerInput, - FILTERABLE_CONTAINER, - FilterableEmbeddableFactory, - HelloWorldContainer, - FILTERABLE_EMBEDDABLE, - FilterableEmbeddable, - FilterableContainerFactory, - FilterableEmbeddableInput, -} from '../lib/test_samples'; -import { esFilters } from '../../../data/public'; -import { applyFilterTrigger } from '../../../ui_actions/public'; - -test('ApplyFilterAction applies the filter to the root of the container tree', async () => { - const { doStart, setup } = testPlugin(); - - const factory2 = new FilterableEmbeddableFactory(); - const factory1 = new FilterableContainerFactory(async () => await api.getEmbeddableFactory); - setup.registerEmbeddableFactory(factory2.type, factory2); - setup.registerEmbeddableFactory(factory1.type, factory1); - - const api = doStart(); - - const applyFilterAction = createFilterAction(); - - const root = new FilterableContainer( - { id: 'root', panels: {}, filters: [] }, - api.getEmbeddableFactory - ); - - const node1 = await root.addNewEmbeddable< - FilterableContainerInput, - EmbeddableOutput, - FilterableContainer - >(FILTERABLE_CONTAINER, { panels: {}, id: 'node1' }); - - const node2 = await root.addNewEmbeddable< - FilterableContainerInput, - EmbeddableOutput, - FilterableContainer - >(FILTERABLE_CONTAINER, { panels: {}, id: 'Node2' }); - - if (isErrorEmbeddable(node1) || isErrorEmbeddable(node2)) { - throw new Error(); - } - - const embeddable = await node2.addNewEmbeddable< - FilterableEmbeddableInput, - EmbeddableOutput, - FilterableEmbeddable - >(FILTERABLE_EMBEDDABLE, { id: 'leaf' }); - - if (isErrorEmbeddable(embeddable)) { - throw new Error(); - } - - const filter: any = { - $state: { store: esFilters.FilterStateStore.APP_STATE }, - meta: { - disabled: false, - negate: false, - alias: '', - }, - query: { match: { extension: { query: 'foo' } } }, - }; - - await applyFilterAction.execute({ embeddable, filters: [filter], trigger: applyFilterTrigger }); - expect(root.getInput().filters.length).toBe(1); - expect(node1.getInput().filters.length).toBe(1); - expect(embeddable.getInput().filters.length).toBe(1); - expect(node2.getInput().filters.length).toBe(1); -}); - -test('ApplyFilterAction is incompatible if the root container does not accept a filter as input', async () => { - const { doStart, setup } = testPlugin(); - - const factory = new FilterableEmbeddableFactory(); - setup.registerEmbeddableFactory(factory.type, factory); - const api = doStart(); - const applyFilterAction = createFilterAction(); - - const parent = new HelloWorldContainer({ id: 'root', panels: {} }, { - getEmbeddableFactory: api.getEmbeddableFactory, - } as any); - const embeddable = await parent.addNewEmbeddable< - FilterableContainerInput, - EmbeddableOutput, - FilterableContainer - >(FILTERABLE_EMBEDDABLE, { id: 'leaf' }); - - if (isErrorEmbeddable(embeddable)) { - throw new Error(); - } - - // @ts-ignore - expect(await applyFilterAction.isCompatible({ embeddable })).toBe(false); -}); - -test('trying to execute on incompatible context throws an error ', async () => { - const { doStart, setup } = testPlugin(); - - const factory = new FilterableEmbeddableFactory(); - setup.registerEmbeddableFactory(factory.type, factory); - - const api = doStart(); - const applyFilterAction = createFilterAction(); - - const parent = new HelloWorldContainer({ id: 'root', panels: {} }, { - getEmbeddableFactory: api.getEmbeddableFactory, - } as any); - - const embeddable = await parent.addNewEmbeddable< - FilterableContainerInput, - EmbeddableOutput, - FilterableContainer - >(FILTERABLE_EMBEDDABLE, { id: 'leaf' }); - - if (isErrorEmbeddable(embeddable)) { - throw new Error(); - } - - async function check() { - await applyFilterAction.execute({ embeddable } as any); - } - await expect(check()).rejects.toThrow(Error); -}); - -test('gets title', async () => { - const applyFilterAction = createFilterAction(); - expect(applyFilterAction.getDisplayName({} as any)).toBeDefined(); -}); diff --git a/src/plugins/embeddable/public/types.ts b/src/plugins/embeddable/public/types.ts index 2d112b2359818..c5148bbaefb6b 100644 --- a/src/plugins/embeddable/public/types.ts +++ b/src/plugins/embeddable/public/types.ts @@ -25,8 +25,24 @@ import { IEmbeddable, EmbeddableFactoryDefinition, } from './lib/embeddables'; +import { + PersistableState, + PersistableStateDefinition, + SerializableState, +} from '../../kibana_utils/common'; export type EmbeddableFactoryRegistry = Map; +export type EnhancementsRegistry = Map; + +export interface EnhancementRegistryDefinition

+ extends PersistableStateDefinition

{ + id: string; +} + +export interface EnhancementRegistryItem

+ extends PersistableState

{ + id: string; +} export type EmbeddableFactoryProvider = < I extends EmbeddableInput = EmbeddableInput, diff --git a/src/plugins/embeddable/server/index.ts b/src/plugins/embeddable/server/index.ts new file mode 100644 index 0000000000000..1138478bff4b7 --- /dev/null +++ b/src/plugins/embeddable/server/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EmbeddableServerPlugin, EmbeddableSetup } from './plugin'; + +export { EmbeddableSetup }; + +export { EnhancementRegistryDefinition, EmbeddableRegistryDefinition } from './types'; + +export const plugin = () => new EmbeddableServerPlugin(); diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts new file mode 100644 index 0000000000000..f79c4b7620110 --- /dev/null +++ b/src/plugins/embeddable/server/plugin.ts @@ -0,0 +1,186 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup, CoreStart, Plugin, SavedObjectReference } from 'kibana/server'; +import { identity } from 'lodash'; +import { + EmbeddableFactoryRegistry, + EnhancementsRegistry, + EnhancementRegistryDefinition, + EnhancementRegistryItem, + EmbeddableRegistryDefinition, +} from './types'; +import { + extractBaseEmbeddableInput, + injectBaseEmbeddableInput, + telemetryBaseEmbeddableInput, +} from '../common/lib/migrate_base_input'; +import { SerializableState } from '../../kibana_utils/common'; +import { EmbeddableInput } from '../common/types'; + +export interface EmbeddableSetup { + registerEmbeddableFactory: (factory: EmbeddableRegistryDefinition) => void; + registerEnhancement: (enhancement: EnhancementRegistryDefinition) => void; +} + +export class EmbeddableServerPlugin implements Plugin { + private readonly embeddableFactories: EmbeddableFactoryRegistry = new Map(); + private readonly enhancements: EnhancementsRegistry = new Map(); + + public setup(core: CoreSetup) { + return { + registerEmbeddableFactory: this.registerEmbeddableFactory, + registerEnhancement: this.registerEnhancement, + }; + } + + public start(core: CoreStart) { + return { + telemetry: this.telemetry, + extract: this.extract, + inject: this.inject, + }; + } + + public stop() {} + + private telemetry = (state: EmbeddableInput, telemetryData: Record = {}) => { + const enhancements: Record = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + let telemetry = telemetryBaseEmbeddableInput(state, telemetryData); + if (factory) { + telemetry = factory.telemetry(state, telemetry); + } + Object.keys(enhancements).map((key) => { + if (!enhancements[key]) return; + telemetry = this.getEnhancement(key).telemetry(enhancements[key], telemetry); + }); + + return telemetry; + }; + + private extract = (state: EmbeddableInput) => { + const enhancements = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + const baseResponse = extractBaseEmbeddableInput(state); + let updatedInput = baseResponse.state; + const refs = baseResponse.references; + + if (factory) { + const factoryResponse = factory.extract(state); + updatedInput = factoryResponse.state; + refs.push(...factoryResponse.references); + } + + updatedInput.enhancements = {}; + Object.keys(enhancements).forEach((key) => { + if (!enhancements[key]) return; + const enhancementResult = this.getEnhancement(key).extract( + enhancements[key] as SerializableState + ); + refs.push(...enhancementResult.references); + updatedInput.enhancements![key] = enhancementResult.state; + }); + + return { + state: updatedInput, + references: refs, + }; + }; + + private inject = (state: EmbeddableInput, references: SavedObjectReference[]) => { + const enhancements = state.enhancements || {}; + const factory = this.getEmbeddableFactory(state.id); + + let updatedInput = injectBaseEmbeddableInput(state, references); + + if (factory) { + updatedInput = factory.inject(updatedInput, references); + } + + updatedInput.enhancements = {}; + Object.keys(enhancements).forEach((key) => { + if (!enhancements[key]) return; + updatedInput.enhancements![key] = this.getEnhancement(key).inject( + enhancements[key] as SerializableState, + references + ); + }); + + return updatedInput; + }; + + private registerEnhancement = (enhancement: EnhancementRegistryDefinition) => { + if (this.enhancements.has(enhancement.id)) { + throw new Error(`enhancement with id ${enhancement.id} already exists in the registry`); + } + this.enhancements.set(enhancement.id, { + id: enhancement.id, + telemetry: enhancement.telemetry || (() => ({})), + inject: enhancement.inject || identity, + extract: + enhancement.extract || + ((state: SerializableState) => { + return { state, references: [] }; + }), + }); + }; + + private getEnhancement = (id: string): EnhancementRegistryItem => { + return ( + this.enhancements.get(id) || { + id: 'unknown', + telemetry: () => ({}), + inject: identity, + extract: (state: SerializableState) => { + return { state, references: [] }; + }, + } + ); + }; + + private registerEmbeddableFactory = (factory: EmbeddableRegistryDefinition) => { + if (this.embeddableFactories.has(factory.id)) { + throw new Error( + `Embeddable factory [embeddableFactoryId = ${factory.id}] already registered in Embeddables API.` + ); + } + this.embeddableFactories.set(factory.id, { + id: factory.id, + telemetry: factory.telemetry || (() => ({})), + inject: factory.inject || identity, + extract: factory.extract || ((state: EmbeddableInput) => ({ state, references: [] })), + }); + }; + + private getEmbeddableFactory = (embeddableFactoryId: string) => { + return ( + this.embeddableFactories.get(embeddableFactoryId) || { + id: 'unknown', + telemetry: () => ({}), + inject: (state: EmbeddableInput) => state, + extract: (state: EmbeddableInput) => { + return { state, references: [] }; + }, + } + ); + }; +} diff --git a/src/plugins/embeddable/server/types.ts b/src/plugins/embeddable/server/types.ts new file mode 100644 index 0000000000000..64f9325dad3cb --- /dev/null +++ b/src/plugins/embeddable/server/types.ts @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + PersistableState, + PersistableStateDefinition, + SerializableState, +} from '../../kibana_utils/common'; +import { EmbeddableInput } from '../common/types'; + +export type EmbeddableFactoryRegistry = Map; +export type EnhancementsRegistry = Map; + +export interface EnhancementRegistryDefinition

+ extends PersistableStateDefinition

{ + id: string; +} + +export interface EnhancementRegistryItem

+ extends PersistableState

{ + id: string; +} + +export interface EmbeddableRegistryDefinition

+ extends PersistableStateDefinition

{ + id: string; +} + +export interface EmbeddableRegistryItem

+ extends PersistableState

{ + id: string; +} diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx index 7a42ed7fad427..b175066b81c8e 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { act } from 'react-dom/test-utils'; import { mount, ReactWrapper } from 'enzyme'; import sinon from 'sinon'; @@ -111,6 +111,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { requestConfig ); + // Force a re-render of the component to stress-test the useRequest hook and verify its + // state remains unaffected. + const [, setState] = useState(false); + useEffect(() => { + setState(true); + }, []); + hookResult.isInitialRequest = isInitialRequest; hookResult.isLoading = isLoading; hookResult.error = error; diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index e04f84a67b8a3..9d40291423cac 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -49,7 +49,7 @@ export const useRequest = ( // Consumers can use isInitialRequest to implement a polling UX. const requestCountRef = useRef(0); - const isInitialRequest = requestCountRef.current === 0; + const isInitialRequestRef = useRef(true); const pollIntervalIdRef = useRef(null); const clearPollInterval = useCallback(() => { @@ -98,6 +98,9 @@ export const useRequest = ( return; } + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + setError(responseError); // If there's an error, keep the data from the last request in case it's still useful to the user. if (!responseError) { @@ -146,7 +149,7 @@ export const useRequest = ( }, [clearPollInterval]); return { - isInitialRequest, + isInitialRequest: isInitialRequestRef.current, isLoading, error, data, diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap index 1b10756c2975c..bf1e8c8f0b401 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.js.snap @@ -164,6 +164,7 @@ exports[`home directories should not render directory entry when showOnHomePage {stackManagement ? ( - + `; + +exports[`ManageData render empty without any features 1`] = ``; diff --git a/src/plugins/home/public/application/components/manage_data/manage_data.test.tsx b/src/plugins/home/public/application/components/manage_data/manage_data.test.tsx index 5d00370caf2cc..0e86bf7dd3d84 100644 --- a/src/plugins/home/public/application/components/manage_data/manage_data.test.tsx +++ b/src/plugins/home/public/application/components/manage_data/manage_data.test.tsx @@ -88,4 +88,9 @@ describe('ManageData', () => { ); expect(component).toMatchSnapshot(); }); + + test('render empty without any features', () => { + const component = shallowWithIntl(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/plugins/home/public/application/components/manage_data/manage_data.tsx b/src/plugins/home/public/application/components/manage_data/manage_data.tsx index 0dfb4f949f0c7..85f1bc04f353b 100644 --- a/src/plugins/home/public/application/components/manage_data/manage_data.tsx +++ b/src/plugins/home/public/application/components/manage_data/manage_data.tsx @@ -36,31 +36,37 @@ export const ManageData: FC = ({ addBasePath, features }) => ( <> {features.length > 1 &&

- -

- -

-
+ {features.length > 0 && ( +
+ +

+ +

+
- + - - {features.map((feature) => ( - - - - ))} - -
+ + {features.map((feature) => ( + + + + ))} + +
+ )} ); diff --git a/src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_panel.test.tsx.snap b/src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_panel.test.tsx.snap index 4e8441bd64b11..ad92aac67d51b 100644 --- a/src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_panel.test.tsx.snap +++ b/src/plugins/home/public/application/components/solutions_section/__snapshots__/solution_panel.test.tsx.snap @@ -3,6 +3,7 @@ exports[`SolutionPanel renders the solution panel for the given solution 1`] = ` diff --git a/src/plugins/home/public/application/components/solutions_section/solution_panel.tsx b/src/plugins/home/public/application/components/solutions_section/solution_panel.tsx index c2ae2f82eaa46..83572e238bffd 100644 --- a/src/plugins/home/public/application/components/solutions_section/solution_panel.tsx +++ b/src/plugins/home/public/application/components/solutions_section/solution_panel.tsx @@ -53,6 +53,7 @@ interface Props { export const SolutionPanel: FC = ({ addBasePath, solution }) => ( { expect(service.get()).toEqual([]); }); }); + + describe('visibility filtering', () => { + test('retains items with no "visible" callback', () => { + const service = new FeatureCatalogueRegistry(); + service.setup().register(DASHBOARD_FEATURE); + const capabilities = { catalogue: {} } as any; + service.start({ capabilities }); + expect(service.get()).toEqual([DASHBOARD_FEATURE]); + }); + + test('retains items with a "visible" callback which returns "true"', () => { + const service = new FeatureCatalogueRegistry(); + const feature = { + ...DASHBOARD_FEATURE, + visible: () => true, + }; + service.setup().register(feature); + const capabilities = { catalogue: {} } as any; + service.start({ capabilities }); + expect(service.get()).toEqual([feature]); + }); + + test('removes items with a "visible" callback which returns "false"', () => { + const service = new FeatureCatalogueRegistry(); + const feature = { + ...DASHBOARD_FEATURE, + visible: () => false, + }; + service.setup().register(feature); + const capabilities = { catalogue: {} } as any; + service.start({ capabilities }); + expect(service.get()).toEqual([]); + }); + }); }); describe('title sorting', () => { diff --git a/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts b/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts index 766afb11a87c0..d965042b65cef 100644 --- a/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts +++ b/src/plugins/home/public/services/feature_catalogue/feature_catalogue_registry.ts @@ -45,6 +45,8 @@ export interface FeatureCatalogueEntry { readonly showOnHomePage: boolean; /** An ordinal used to sort features relative to one another for display on the home page */ readonly order?: number; + /** Optional function to control visibility of this feature. */ + readonly visible?: () => boolean; } /** @public */ @@ -103,7 +105,10 @@ export class FeatureCatalogueRegistry { } const capabilities = this.capabilities; return [...this.features.values()] - .filter((entry) => capabilities.catalogue[entry.id] !== false) + .filter( + (entry) => + capabilities.catalogue[entry.id] !== false && (entry.visible ? entry.visible() : true) + ) .sort(compareByKey('title')); } diff --git a/src/plugins/input_control_vis/kibana.json b/src/plugins/input_control_vis/kibana.json index 6928eb19d02e1..c6d1157f5ff25 100644 --- a/src/plugins/input_control_vis/kibana.json +++ b/src/plugins/input_control_vis/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["data", "expressions", "visualizations"], + "requiredPlugins": ["data", "expressions", "visualizations", "visDefaultEditor"], "requiredBundles": ["kibanaReact"] } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md index 1ffd01fc6fde7..cb80538fd1718 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md @@ -28,10 +28,10 @@ This collection occurs by default for every application registered via the menti ## Developer notes -In order to keep the count of the events, this collector uses 2 Saved Objects: +In order to keep the count of the events, this collector uses 3 Saved Objects: -1. `application_usage_transactional`: It stores each individually reported event (up to 90 days old). Grouped by `timestamp` and `appId`. -2. `application_usage_totals`: It stores the sum of all the events older than 90 days old per `appId`. +1. `application_usage_transactional`: It stores each individually reported event. Grouped by `timestamp` and `appId`. The reason for having these documents instead of editing `application_usage_daily` documents on very report is to provide faster response to the requests to `/api/ui_metric/report` (creating new documents instead of finding and editing existing ones) and to avoid conflicts when multiple users reach to the API concurrently. +2. `application_usage_daily`: Periodically, documents from `application_usage_transactional` are aggregated to daily summaries and deleted. Also grouped by `timestamp` and `appId`. +3. `application_usage_totals`: It stores the sum of all the events older than 90 days old, grouped by `appId`. -Both of them use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`. `application_usage_transactional` also stores `timestamp: { type: 'date' }`. -but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). +All the types use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`, but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). `application_usage_transactional` and `application_usage_daily` also store `timestamp: { type: 'date' }`. diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts deleted file mode 100644 index 5658b38120596..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; -import { - CollectorOptions, - createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; - -import { registerApplicationUsageCollector } from './'; -import { - ROLL_INDICES_INTERVAL, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './telemetry_application_usage_collector'; - -describe('telemetry_application_usage', () => { - jest.useFakeTimers(); - - let collector: CollectorOptions; - - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - - const getUsageCollector = jest.fn(); - const registerType = jest.fn(); - const callCluster = jest.fn(); - - beforeAll(() => - registerApplicationUsageCollector(usageCollectionMock, registerType, getUsageCollector) - ); - afterAll(() => jest.clearAllTimers()); - - test('registered collector is set', () => { - expect(collector).not.toBeUndefined(); - }); - - test('if no savedObjectClient initialised, return undefined', async () => { - expect(await collector.fetch(callCluster)).toBeUndefined(); - jest.runTimersToTime(ROLL_INDICES_INTERVAL); - }); - - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({}); - expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - savedObjectClient.find.mockImplementation(async (opts) => { - if (opts.type === SAVED_OBJECTS_TOTAL_TYPE) { - return { - saved_objects: [ - { - id: 'appId', - attributes: { - appId: 'appId', - minutesOnScreen: 10, - numberOfClicks: 10, - }, - }, - ], - total: 1, - } as any; - } - if ((opts.page || 1) > 2) { - return { saved_objects: [], total }; - } - const doc = { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date().toISOString(), - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }; - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({ - appId: { - clicks_total: total - 1 + 10, - clicks_7_days: total - 1, - clicks_30_days: total - 1, - clicks_90_days: total - 1, - minutes_on_screen_total: total - 1 + 10, - minutes_on_screen_7_days: total - 1, - minutes_on_screen_30_days: total - 1, - minutes_on_screen_90_days: total - 1, - }, - }); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - id: 'appId', - type: SAVED_OBJECTS_TOTAL_TYPE, - attributes: { - appId: 'appId', - minutesOnScreen: total - 1 + 10, - numberOfClicks: total - 1 + 10, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(total - 1); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_TRANSACTIONAL_TYPE, - 'test-id' - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts new file mode 100644 index 0000000000000..f8bc17fc40df0 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts @@ -0,0 +1,299 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { rollDailyData, rollTotals } from './rollups'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollDailyData(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).not.toBeCalled(); + expect(savedObjectClient.bulkCreate).not.toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + }); + + test('migrate some docs', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'test-id-2', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_DAILY_TYPE, + id: 'appId:2020-01-01', + attributes: { + appId: 'appId', + timestamp: '2020-01-01T00:00:00.000Z', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-1' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-2' + ); + }); + + test('error getting the daily document', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw new Error('Something went terribly wrong'); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); +}); + +describe('rollTotals', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + case SAVED_OBJECTS_TOTAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); + + test('migrate some documents', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId-2:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1', + attributes: { + appId: 'appId-1', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2', + attributes: { + appId: 'appId-2', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-2:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01' + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts new file mode 100644 index 0000000000000..3020147e95d98 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts @@ -0,0 +1,202 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ISavedObjectsRepository, SavedObject, Logger } from 'kibana/server'; +import moment from 'moment'; +import { + ApplicationUsageDaily, + ApplicationUsageTotal, + ApplicationUsageTransactional, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests) + */ +type ApplicationUsageDailyWithVersion = Pick< + SavedObject, + 'version' | 'attributes' +>; + +/** + * Aggregates all the transactional events into daily aggregates + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + let toCreate: Map; + do { + toCreate = new Map(); + const { saved_objects: rawApplicationUsageTransactional } = await savedObjectsClient.find< + ApplicationUsageTransactional + >({ + type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 1000, // Process 1000 at a time as a compromise of speed and overload + }); + + for (const doc of rawApplicationUsageTransactional) { + const { + attributes: { appId, minutesOnScreen, numberOfClicks, timestamp }, + } = doc; + const dayId = moment(timestamp).format('YYYY-MM-DD'); + const dailyId = `${appId}:${dayId}`; + const existingDoc = + toCreate.get(dailyId) || (await getDailyDoc(savedObjectsClient, dailyId, appId, dayId)); + toCreate.set(dailyId, { + ...existingDoc, + attributes: { + ...existingDoc.attributes, + minutesOnScreen: existingDoc.attributes.minutesOnScreen + minutesOnScreen, + numberOfClicks: existingDoc.attributes.numberOfClicks + numberOfClicks, + }, + }); + } + if (toCreate.size > 0) { + await savedObjectsClient.bulkCreate( + [...toCreate.entries()].map(([id, { attributes, version }]) => ({ + type: SAVED_OBJECTS_DAILY_TYPE, + id, + attributes, + version, // Providing version to ensure via conflict matching that only 1 Kibana instance (or interval) is taking care of the updates + })), + { overwrite: true } + ); + await Promise.all( + rawApplicationUsageTransactional.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( + ) + ); + } + } while (toCreate.size > 0); + } catch (err) { + logger.warn(`Failed to rollup transactional to daily entries`); + logger.warn(err); + } +} + +/** + * Gets daily doc from the SavedObjects repository. Creates a new one if not found + * @param savedObjectsClient + * @param id The ID of the document to retrieve (typically, `${appId}:${dayId}`) + * @param appId The application ID + * @param dayId The date of the document in the format YYYY-MM-DD + */ +async function getDailyDoc( + savedObjectsClient: ISavedObjectsRepository, + id: string, + appId: string, + dayId: string +): Promise { + try { + return await savedObjectsClient.get(SAVED_OBJECTS_DAILY_TYPE, id); + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + return { + attributes: { + appId, + // Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects + timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(), + minutesOnScreen: 0, + numberOfClicks: 0, + }, + }; + } + throw err; + } +} + +/** + * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days + * @param logger + * @param savedObjectsClient + */ +export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + ] = await Promise.all([ + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_TOTAL_TYPE, + }), + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, + }), + ]); + + const existingTotals = rawApplicationUsageTotals.reduce( + (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { + return { + ...acc, + // No need to sum because there should be 1 document per appId only + [appId]: { appId, numberOfClicks, minutesOnScreen }, + }; + }, + {} as Record + ); + + const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { + const { appId, numberOfClicks, minutesOnScreen } = attributes; + + const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; + + return { + ...acc, + [appId]: { + appId, + numberOfClicks: numberOfClicks + existing.numberOfClicks, + minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, + }, + }; + }, existingTotals); + + await Promise.all([ + Object.entries(totals).length && + savedObjectsClient.bulkCreate( + Object.entries(totals).map(([id, entry]) => ({ + type: SAVED_OBJECTS_TOTAL_TYPE, + id, + attributes: entry, + })), + { overwrite: true } + ), + ...rawApplicationUsageDaily.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( + ), + ]); + } catch (err) { + logger.warn(`Failed to rollup daily entries to totals`); + logger.warn(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 551c6e230972e..861dc98c0c465 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -19,19 +19,34 @@ import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; +/** + * Used for accumulating the totals of all the stats older than 90d + */ export interface ApplicationUsageTotal extends SavedObjectAttributes { appId: string; minutesOnScreen: number; numberOfClicks: number; } +export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; +/** + * Used for storing each of the reports received from the users' browsers + */ export interface ApplicationUsageTransactional extends ApplicationUsageTotal { timestamp: string; } +export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; + +/** + * Used to aggregate the transactional events into daily summaries so we can purge the granular events + */ +export type ApplicationUsageDaily = ApplicationUsageTransactional; +export const SAVED_OBJECTS_DAILY_TYPE = 'application_usage_daily'; export function registerMappings(registerType: SavedObjectsServiceSetup['registerType']) { + // Type for storing ApplicationUsageTotal registerType({ - name: 'application_usage_totals', + name: SAVED_OBJECTS_TOTAL_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { @@ -42,15 +57,28 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe }, }); + // Type for storing ApplicationUsageDaily registerType({ - name: 'application_usage_transactional', + name: SAVED_OBJECTS_DAILY_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { dynamic: false, properties: { + // This type requires `timestamp` to be indexed so we can use it when rolling up totals (timestamp < now-90d) timestamp: { type: 'date' }, }, }, }); + + // Type for storing ApplicationUsageTransactional (declaring empty mappings because we don't use the internal fields for query/aggregations) + registerType({ + name: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, + }); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts new file mode 100644 index 0000000000000..709736a37d802 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -0,0 +1,213 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { + CollectorOptions, + createUsageCollectionSetupMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { + ROLL_INDICES_START, + ROLL_TOTAL_INDICES_INTERVAL, + registerApplicationUsageCollector, +} from './telemetry_application_usage_collector'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('telemetry_application_usage', () => { + jest.useFakeTimers(); + + const logger = loggingSystemMock.createLogger(); + + let collector: CollectorOptions; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = config; + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const getUsageCollector = jest.fn(); + const registerType = jest.fn(); + const callCluster = jest.fn(); + + beforeAll(() => + registerApplicationUsageCollector(logger, usageCollectionMock, registerType, getUsageCollector) + ); + afterAll(() => jest.clearAllTimers()); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + }); + + test('if no savedObjectClient initialised, return undefined', async () => { + expect(collector.isReady()).toBe(false); + expect(await collector.fetch(callCluster)).toBeUndefined(); + jest.runTimersToTime(ROLL_INDICES_START); + }); + + test('when savedObjectClient is initialised, return something', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation( + async () => + ({ + saved_objects: [], + total: 0, + } as any) + ); + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(collector.isReady()).toBe(true); + expect(await collector.fetch(callCluster)).toStrictEqual({}); + expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); + }); + + test('it only gets 10k even when there are more documents (ES limitation)', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + const total = 10000; + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId', + attributes: { + appId: 'appId', + minutesOnScreen: 10, + numberOfClicks: 10, + }, + }, + ], + total: 1, + } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + const doc = { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }; + const savedObjects = new Array(total).fill(doc); + return { saved_objects: savedObjects, total: total + 1 }; + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId:YYYY-MM-DD', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: total + 1 + 10, + clicks_7_days: total + 1, + clicks_30_days: total + 1, + clicks_90_days: total + 1, + minutes_on_screen_total: (total + 1) * 0.5 + 10, + minutes_on_screen_7_days: (total + 1) * 0.5, + minutes_on_screen_30_days: (total + 1) * 0.5, + minutes_on_screen_90_days: (total + 1) * 0.5, + }, + }); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + id: 'appId', + type: SAVED_OBJECTS_TOTAL_TYPE, + attributes: { + appId: 'appId', + minutesOnScreen: 10.5, + numberOfClicks: 11, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:YYYY-MM-DD' + ); + }); + + test('old transactional data not migrated yet', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + case SAVED_OBJECTS_DAILY_TYPE: + return { saved_objects: [], total: 0 } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { + saved_objects: [ + { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date(0).toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: 1, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0.5, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }, + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index 69137681e0597..36c89d0a0b4a8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -18,29 +18,42 @@ */ import moment from 'moment'; -import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; +import { timer } from 'rxjs'; +import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; import { + ApplicationUsageDaily, ApplicationUsageTotal, ApplicationUsageTransactional, registerMappings, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, } from './saved_objects_types'; import { applicationUsageSchema } from './schema'; +import { rollDailyData, rollTotals } from './rollups'; /** - * Roll indices every 24h + * Roll total indices every 24h */ -export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; +export const ROLL_TOTAL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Roll daily indices every 30 minutes. + * This means that, assuming a user can visit all the 44 apps we can possibly report + * in the 3 minutes interval the browser reports to the server, up to 22 users can have the same + * behaviour and we wouldn't need to paginate in the transactional documents (less than 10k docs). + * + * Based on a more normal expected use case, the users could visit up to 5 apps in those 3 minutes, + * allowing up to 200 users before reaching the limit. + */ +export const ROLL_DAILY_INDICES_INTERVAL = 30 * 60 * 1000; /** * Start rolling indices after 5 minutes up */ export const ROLL_INDICES_START = 5 * 60 * 1000; -export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; -export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; - export interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; @@ -55,6 +68,7 @@ export interface ApplicationUsageTelemetryReport { } export function registerApplicationUsageCollector( + logger: Logger, usageCollection: UsageCollectionSetup, registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined @@ -71,10 +85,22 @@ export function registerApplicationUsageCollector( if (typeof savedObjectsClient === 'undefined') { return; } - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + { saved_objects: rawApplicationUsageTransactional }, + ] = await Promise.all([ + savedObjectsClient.find({ + type: SAVED_OBJECTS_TOTAL_TYPE, + perPage: 10000, // We only have 44 apps for now. This limit is OK. + }), + savedObjectsClient.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + perPage: 10000, // We can have up to 44 apps * 91 days = 4004 docs. This limit is OK + }), + savedObjectsClient.find({ type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 10000, // If we have more than those, we won't report the rest (they'll be rolled up to the daily soon enough to become a problem) }), ]); @@ -101,51 +127,51 @@ export function registerApplicationUsageCollector( const nowMinus30 = moment().subtract(30, 'days'); const nowMinus90 = moment().subtract(90, 'days'); - const applicationUsage = rawApplicationUsageTransactional.reduce( - (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { - const existing = acc[appId] || { - clicks_total: 0, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 0, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }; - - const timeOfEntry = moment(timestamp as string); - const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); - const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); - const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); - - const last7Days = { - clicks_7_days: existing.clicks_7_days + numberOfClicks, - minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, - }; - const last30Days = { - clicks_30_days: existing.clicks_30_days + numberOfClicks, - minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, - }; - const last90Days = { - clicks_90_days: existing.clicks_90_days + numberOfClicks, - minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, - }; - - return { - ...acc, - [appId]: { - ...existing, - clicks_total: existing.clicks_total + numberOfClicks, - minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, - ...(isInLast7Days ? last7Days : {}), - ...(isInLast30Days ? last30Days : {}), - ...(isInLast90Days ? last90Days : {}), - }, - }; - }, - applicationUsageFromTotals - ); + const applicationUsage = [ + ...rawApplicationUsageDaily, + ...rawApplicationUsageTransactional, + ].reduce((acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { + const existing = acc[appId] || { + clicks_total: 0, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }; + + const timeOfEntry = moment(timestamp); + const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); + const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); + const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); + + const last7Days = { + clicks_7_days: existing.clicks_7_days + numberOfClicks, + minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, + }; + const last30Days = { + clicks_30_days: existing.clicks_30_days + numberOfClicks, + minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, + }; + const last90Days = { + clicks_90_days: existing.clicks_90_days + numberOfClicks, + minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, + }; + + return { + ...acc, + [appId]: { + ...existing, + clicks_total: existing.clicks_total + numberOfClicks, + minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, + ...(isInLast7Days ? last7Days : {}), + ...(isInLast30Days ? last30Days : {}), + ...(isInLast90Days ? last90Days : {}), + }, + }; + }, applicationUsageFromTotals); return applicationUsage; }, @@ -154,65 +180,10 @@ export function registerApplicationUsageCollector( usageCollection.registerCollector(collector); - setInterval(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_INTERVAL); - setTimeout(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_START); -} - -async function rollTotals(savedObjectsClient?: ISavedObjectsRepository) { - if (!savedObjectsClient) { - return; - } - - try { - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - filter: `${SAVED_OBJECTS_TRANSACTIONAL_TYPE}.attributes.timestamp < now-90d`, - }), - ]); - - const existingTotals = rawApplicationUsageTotals.reduce( - (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { - return { - ...acc, - // No need to sum because there should be 1 document per appId only - [appId]: { appId, numberOfClicks, minutesOnScreen }, - }; - }, - {} as Record - ); - - const totals = rawApplicationUsageTransactional.reduce((acc, { attributes, id }) => { - const { appId, numberOfClicks, minutesOnScreen } = attributes; - - const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; - - return { - ...acc, - [appId]: { - appId, - numberOfClicks: numberOfClicks + existing.numberOfClicks, - minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, - }, - }; - }, existingTotals); - - await Promise.all([ - Object.entries(totals).length && - savedObjectsClient.bulkCreate( - Object.entries(totals).map(([id, entry]) => ({ - type: SAVED_OBJECTS_TOTAL_TYPE, - id, - attributes: entry, - })), - { overwrite: true } - ), - ...rawApplicationUsageTransactional.map( - ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( - ), - ]); - } catch (err) { - // Silent failure - } + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => + rollTotals(logger, getSavedObjectsClient()) + ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts deleted file mode 100644 index d917cd2454e81..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; - -import { findAll } from './find_all'; - -describe('telemetry_application_usage', () => { - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual([]); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - const doc = { id: 'test-id', attributes: { test: 1 } }; - savedObjectClient.find.mockImplementation(async (opts) => { - if ((opts.page || 1) > 2) { - return { saved_objects: [], total } as any; - } - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual( - new Array(total - 1).fill(doc) - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.ts deleted file mode 100644 index 5bb4f20b5c5b1..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - SavedObjectAttributes, - ISavedObjectsRepository, - SavedObjectsFindOptions, - SavedObject, -} from 'kibana/server'; - -export async function findAll( - savedObjectsClient: ISavedObjectsRepository, - opts: SavedObjectsFindOptions -): Promise>> { - const { page = 1, perPage = 10000, ...options } = opts; - const { saved_objects: savedObjects, total } = await savedObjectsClient.find({ - ...options, - page, - perPage, - }); - if (page * perPage >= total) { - return savedObjects; - } - return [...savedObjects, ...(await findAll(savedObjectsClient, { ...opts, page: page + 1 }))]; -} diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index 46768813b1970..9c02a9cbf3204 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -23,7 +23,6 @@ import { SavedObjectsServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { count: number; @@ -55,9 +54,10 @@ export function registerUiMetricUsageCollector( return; } - const rawUiMetrics = await findAll(savedObjectsClient, { + const { saved_objects: rawUiMetrics } = await savedObjectsClient.find({ type: 'ui-metric', fields: ['count'], + perPage: 10000, }); const uiMetricsByAppName = rawUiMetrics.reduce((accum, rawUiMetric) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index d4295c770803e..260acd19ab516 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -30,6 +30,7 @@ import { CoreStart, SavedObjectsServiceSetup, OpsMetrics, + Logger, } from '../../../core/server'; import { registerApplicationUsageCollector, @@ -47,12 +48,14 @@ interface KibanaUsageCollectionPluginsDepsSetup { type SavedObjectsRegisterType = SavedObjectsServiceSetup['registerType']; export class KibanaUsageCollectionPlugin implements Plugin { + private readonly logger: Logger; private readonly legacyConfig$: Observable; private savedObjectsClient?: ISavedObjectsRepository; private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); } @@ -88,7 +91,12 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerKibanaUsageCollector(usageCollection, this.legacyConfig$); registerManagementUsageCollector(usageCollection, getUiSettingsClient); registerUiMetricUsageCollector(usageCollection, registerType, getSavedObjectsClient); - registerApplicationUsageCollector(usageCollection, registerType, getSavedObjectsClient); + registerApplicationUsageCollector( + this.logger.get('application-usage'), + usageCollection, + registerType, + getSavedObjectsClient + ); registerCspCollector(usageCollection, coreSetup.http); } } diff --git a/src/plugins/kibana_utils/common/index.ts b/src/plugins/kibana_utils/common/index.ts index 1ec5737c5a38b..e09290c811c7b 100644 --- a/src/plugins/kibana_utils/common/index.ts +++ b/src/plugins/kibana_utils/common/index.ts @@ -29,3 +29,4 @@ export { distinctUntilChangedWithInitialValue } from './distinct_until_changed_w export { url } from './url'; export { now } from './now'; export { calculateObjectHash } from './calculate_object_hash'; +export * from './persistable_state'; diff --git a/src/plugins/kibana_utils/common/persistable_state/index.ts b/src/plugins/kibana_utils/common/persistable_state/index.ts new file mode 100644 index 0000000000000..ae5e3d514554c --- /dev/null +++ b/src/plugins/kibana_utils/common/persistable_state/index.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectReference } from '../../../../core/types'; + +export type SerializableValue = string | number | boolean | null | undefined | SerializableState; +export type Serializable = SerializableValue | SerializableValue[]; + +// eslint-disable-next-line +export type SerializableState = { + [key: string]: Serializable; +}; + +export interface PersistableState

{ + /** + * function to extract telemetry information + * @param state + * @param collector + */ + telemetry: (state: P, collector: Record) => Record; + /** + * inject function receives state and a list of references and should return state with references injected + * default is identity function + * @param state + * @param references + */ + inject: (state: P, references: SavedObjectReference[]) => P; + /** + * extract function receives state and should return state with references extracted and array of references + * default returns same state with empty reference array + * @param state + */ + extract: (state: P) => { state: P; references: SavedObjectReference[] }; +} + +export type PersistableStateDefinition

= Partial< + PersistableState

+>; diff --git a/src/plugins/kibana_utils/public/ui/configurable.ts b/src/plugins/kibana_utils/public/ui/configurable.ts index 3fa5cdc8b5e47..89bce5ae423ee 100644 --- a/src/plugins/kibana_utils/public/ui/configurable.ts +++ b/src/plugins/kibana_utils/public/ui/configurable.ts @@ -18,11 +18,15 @@ */ import { UiComponent } from '../../common/ui/ui_component'; +import { SerializableState } from '../../common'; /** * Represents something that can be configured by user using UI. */ -export interface Configurable { +export interface Configurable< + Config extends SerializableState = SerializableState, + Context = object +> { /** * Create default config for this item, used when item is created for the first time. */ @@ -42,7 +46,10 @@ export interface Configurable /** * Props provided to `CollectConfig` component on every re-render. */ -export interface CollectConfigProps { +export interface CollectConfigProps< + Config extends SerializableState = SerializableState, + Context = object +> { /** * Current (latest) config of the item. */ diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index fafedf46c2bda..808578c470ae1 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -47,6 +47,8 @@ export class ManagementPlugin implements Plugin(() => ({})); + private hasAnyEnabledApps = true; + constructor(private initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup, { home }: ManagementSetupDependencies) { @@ -65,6 +67,7 @@ export class ManagementPlugin implements Plugin this.hasAnyEnabledApps, }); } @@ -96,11 +99,11 @@ export class ManagementPlugin implements Plugin section.getAppsEnabled().length > 0); - if (!hasAnyEnabledApps) { + if (!this.hasAnyEnabledApps) { this.appUpdater.next(() => { return { status: AppStatus.inaccessible, diff --git a/src/plugins/newsfeed/public/components/flyout_list.tsx b/src/plugins/newsfeed/public/components/flyout_list.tsx index 6e9444c950107..d4cdf72fee613 100644 --- a/src/plugins/newsfeed/public/components/flyout_list.tsx +++ b/src/plugins/newsfeed/public/components/flyout_list.tsx @@ -30,6 +30,7 @@ import { EuiText, EuiBadge, EuiHeaderAlert, + EuiPortal, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { NewsfeedContext } from './newsfeed_header_nav_button'; @@ -42,70 +43,75 @@ export const NewsfeedFlyout = () => { const closeFlyout = useCallback(() => setFlyoutVisible(false), [setFlyoutVisible]); return ( - - - -

- -

- - - - {!newsFetchResult ? ( - - ) : newsFetchResult.feedItems.length > 0 ? ( - newsFetchResult.feedItems.map((item: NewsfeedItem) => { - return ( - - {item.linkText} - - } - date={item.publishOn.format('DD MMMM YYYY')} - badge={item.badge ? {item.badge} : undefined} + + + + +

+ - ); - }) - ) : ( - - )} - - - - - - - - - - {newsFetchResult ? ( - -

- -

-
- ) : null} -
-
-
- +

+
+
+ + {!newsFetchResult ? ( + + ) : newsFetchResult.feedItems.length > 0 ? ( + newsFetchResult.feedItems.map((item: NewsfeedItem) => { + return ( + + {item.linkText} + + } + date={item.publishOn.format('DD MMMM YYYY')} + badge={item.badge ? {item.badge} : undefined} + /> + ); + }) + ) : ( + + )} + + + + + + + + + + {newsFetchResult ? ( + +

+ +

+
+ ) : null} +
+
+
+
+
); }; diff --git a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx index 628cfde18b0d5..7924544640086 100644 --- a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx +++ b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx @@ -19,7 +19,8 @@ import React, { useState, Fragment, useEffect } from 'react'; import * as Rx from 'rxjs'; -import { EuiHeaderSectionItemButton, EuiIcon, EuiNotificationBadge } from '@elastic/eui'; +import { EuiHeaderSectionItemButton, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { NewsfeedFlyout } from './flyout_list'; import { FetchResult } from '../types'; @@ -65,15 +66,19 @@ export const NewsfeedNavButton = ({ apiFetchResult }: Props) => { aria-controls="keyPadMenu" aria-expanded={flyoutVisible} aria-haspopup="true" - aria-label="Newsfeed menu" + aria-label={ + showBadge + ? i18n.translate('newsfeed.headerButton.unreadAriaLabel', { + defaultMessage: 'Newsfeed menu - unread items available', + }) + : i18n.translate('newsfeed.headerButton.readAriaLabel', { + defaultMessage: 'Newsfeed menu - all items read', + }) + } + notification={showBadge ? true : null} onClick={showFlyout} > - {showBadge ? ( - - ▪ - - ) : null} {flyoutVisible ? : null} diff --git a/src/plugins/region_map/kibana.json b/src/plugins/region_map/kibana.json index 6e1980c327dc0..bd5517d2a5bf7 100644 --- a/src/plugins/region_map/kibana.json +++ b/src/plugins/region_map/kibana.json @@ -14,7 +14,7 @@ ], "requiredBundles": [ "kibanaUtils", - "kibanaReact", - "charts" + "charts", + "visDefaultEditor" ] } diff --git a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts index aa1de4b2443a4..dd6953ebcda99 100644 --- a/src/plugins/telemetry/server/routes/telemetry_opt_in.ts +++ b/src/plugins/telemetry/server/routes/telemetry_opt_in.ts @@ -26,6 +26,7 @@ import { StatsGetterConfig, TelemetryCollectionManagerPluginSetup, } from 'src/plugins/telemetry_collection_manager/server'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; import { getTelemetryAllowChangingOptInStatus } from '../../common/telemetry_config'; import { sendTelemetryOptInStatus } from './telemetry_opt_in_stats'; @@ -109,7 +110,13 @@ export function registerTelemetryOptInRoutes({ }); } - await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + try { + await updateTelemetrySavedObject(context.core.savedObjects.client, attributes); + } catch (e) { + if (SavedObjectsErrorHelpers.isForbiddenError(e)) { + return res.forbidden(); + } + } return res.ok({ body: optInStatus }); } ); diff --git a/src/plugins/tile_map/kibana.json b/src/plugins/tile_map/kibana.json index 9881a2dd72308..acf1424229c8e 100644 --- a/src/plugins/tile_map/kibana.json +++ b/src/plugins/tile_map/kibana.json @@ -14,7 +14,7 @@ ], "requiredBundles": [ "kibanaUtils", - "kibanaReact", - "charts" + "charts", + "visDefaultEditor" ] } diff --git a/src/plugins/timelion/public/plugin.ts b/src/plugins/timelion/public/plugin.ts index 24d1b9eb3fb65..7656a808dfb00 100644 --- a/src/plugins/timelion/public/plugin.ts +++ b/src/plugins/timelion/public/plugin.ts @@ -28,6 +28,7 @@ import { AppMountParameters, AppUpdater, ScopedHistory, + AppNavLinkStatus, } from '../../../core/public'; import { Panel } from './panels/panel'; import { initAngularBootstrap, KibanaLegacyStart } from '../../kibana_legacy/public'; @@ -35,7 +36,10 @@ import { createKbnUrlTracker } from '../../kibana_utils/public'; import { DataPublicPluginStart, esFilters, DataPublicPluginSetup } from '../../data/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { VisualizationsStart } from '../../visualizations/public'; -import { VisTypeTimelionPluginStart } from '../../vis_type_timelion/public'; +import { + VisTypeTimelionPluginStart, + VisTypeTimelionPluginSetup, +} from '../../vis_type_timelion/public'; export interface TimelionPluginDependencies { data: DataPublicPluginStart; @@ -55,7 +59,13 @@ export class TimelionPlugin implements Plugin { this.initializerContext = initializerContext; } - public setup(core: CoreSetup, { data }: { data: DataPublicPluginSetup }) { + public setup( + core: CoreSetup, + { + data, + visTypeTimelion, + }: { data: DataPublicPluginSetup; visTypeTimelion: VisTypeTimelionPluginSetup } + ) { const timelionPanels: Map = new Map(); const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ @@ -93,7 +103,8 @@ export class TimelionPlugin implements Plugin { defaultPath: '#/', euiIconType: 'logoKibana', category: DEFAULT_APP_CATEGORIES.kibana, - updater$: this.appStateUpdater.asObservable(), + navLinkStatus: + visTypeTimelion.isUiEnabled === false ? AppNavLinkStatus.hidden : AppNavLinkStatus.default, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); this.currentHistory = params.history; diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index a22b3fa5b0367..fe7c986bdb7ef 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -20,7 +20,7 @@ // @ts-ignore import React from 'react'; import { Action, ActionContext as Context, ActionDefinition } from './action'; -import { Presentable } from '../util/presentable'; +import { Presentable, PresentableGrouping } from '../util/presentable'; import { uiToReactComponent } from '../../../kibana_react/public'; import { ActionType } from '../types'; @@ -36,6 +36,7 @@ export class ActionInternal public readonly order: number = this.definition.order || 0; public readonly MenuItem? = this.definition.MenuItem; public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + public readonly grouping?: PresentableGrouping> = this.definition.grouping; public execute(context: Context) { return this.definition.execute(context); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts index a513bb3c95f24..3a598b547e343 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { buildContextMenuForActions } from './build_eui_context_menu_panels'; import { Action, createAction } from '../actions'; @@ -25,9 +26,9 @@ const createTestAction = ({ dispayName, order, }: { - type: string; + type?: string; dispayName: string; - order: number; + order?: number; }) => createAction({ type: type as any, // mapping doesn't matter for this test @@ -36,32 +37,36 @@ const createTestAction = ({ execute: async () => {}, }); -test('contextMenu actions sorting: order, type, displayName', async () => { +const resultMapper = (panel: EuiContextMenuPanelDescriptor) => ({ + items: panel.items ? panel.items.map((item) => ({ name: item.name })) : [], +}); + +test('sorts items in DESC order by "order" field first, then by display name', async () => { const actions: Action[] = [ createTestAction({ - order: 100, - type: '1', - dispayName: 'a', + order: 1, + type: 'foo', + dispayName: 'a-1', }), createTestAction({ - order: 100, - type: '1', - dispayName: 'b', + order: 2, + type: 'foo', + dispayName: 'a-2', }), createTestAction({ - order: 0, - type: '2', - dispayName: 'c', + order: 3, + type: 'foo', + dispayName: 'a-3', }), createTestAction({ - order: 0, - type: '2', - dispayName: 'd', + order: 2, + type: 'foo', + dispayName: 'b-2', }), createTestAction({ - order: 0, - type: '3', - dispayName: 'aa', + order: 2, + type: 'foo', + dispayName: 'c-2', }), ].sort(() => 0.5 - Math.random()); @@ -69,13 +74,166 @@ test('contextMenu actions sorting: order, type, displayName', async () => { actions: actions.map((action) => ({ action, context: {}, trigger: '' as any })), }); - expect(result.items?.map((item) => item.name as string)).toMatchInlineSnapshot(` + expect(result.map(resultMapper)).toMatchInlineSnapshot(` + Array [ + Object { + "items": Array [ + Object { + "name": "a-3", + }, + Object { + "name": "a-2", + }, + Object { + "name": "b-2", + }, + Object { + "name": "More", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "c-2", + }, + Object { + "name": "a-1", + }, + ], + }, + ] + `); +}); + +test('builds empty menu when no actions provided', async () => { + const menu = await buildContextMenuForActions({ + actions: [], + closeMenu: () => {}, + }); + + expect(menu.map(resultMapper)).toMatchInlineSnapshot(` + Array [ + Object { + "items": Array [], + }, + ] + `); +}); + +test('can build menu with one action', async () => { + const menu = await buildContextMenuForActions({ + actions: [ + { + action: createTestAction({ + dispayName: 'Foo', + }), + context: {}, + trigger: 'TETS_TRIGGER' as any, + }, + ], + closeMenu: () => {}, + }); + + expect(menu.map(resultMapper)).toMatchInlineSnapshot(` + Array [ + Object { + "items": Array [ + Object { + "name": "Foo", + }, + ], + }, + ] + `); +}); + +test('orders items according to "order" field', async () => { + const actions = [ + createTestAction({ + order: 1, + dispayName: 'Foo', + }), + createTestAction({ + order: 2, + dispayName: 'Bar', + }), + ]; + const menu = await buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })), + }); + + expect(menu[0].items![0].name).toBe('Bar'); + expect(menu[0].items![1].name).toBe('Foo'); + + const actions2 = [ + createTestAction({ + order: 2, + dispayName: 'Bar', + }), + createTestAction({ + order: 1, + dispayName: 'Foo', + }), + ]; + const menu2 = await buildContextMenuForActions({ + actions: actions2.map((action) => ({ action, context: {}, trigger: 'TEST' as any })), + }); + + expect(menu2[0].items![0].name).toBe('Bar'); + expect(menu2[0].items![1].name).toBe('Foo'); +}); + +test('hides items behind in "More" submenu if there are more than 4 actions', async () => { + const actions = [ + createTestAction({ + dispayName: 'Foo 1', + }), + createTestAction({ + dispayName: 'Foo 2', + }), + createTestAction({ + dispayName: 'Foo 3', + }), + createTestAction({ + dispayName: 'Foo 4', + }), + createTestAction({ + dispayName: 'Foo 5', + }), + ]; + const menu = await buildContextMenuForActions({ + actions: actions.map((action) => ({ action, context: {}, trigger: 'TEST' as any })), + }); + + expect(menu.map(resultMapper)).toMatchInlineSnapshot(` Array [ - "a", - "b", - "c", - "d", - "aa", + Object { + "items": Array [ + Object { + "name": "Foo 1", + }, + Object { + "name": "Foo 2", + }, + Object { + "name": "Foo 3", + }, + Object { + "name": "More", + }, + ], + }, + Object { + "items": Array [ + Object { + "name": "Foo 4", + }, + Object { + "name": "Foo 5", + }, + ], + }, ] `); }); diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3be1ec781cef6..1fdddfc272e94 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -20,10 +20,9 @@ import * as React from 'react'; import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; import _ from 'lodash'; -import sortBy from 'lodash/sortBy'; import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; -import { Action } from '../actions'; +import { Action, ActionExecutionContext } from '../actions'; import { Trigger } from '../triggers'; import { BaseContext } from '../types'; @@ -31,6 +30,10 @@ export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { defaultMessage: 'Options', }); +export const txtMore = i18n.translate('uiActions.actionPanel.more', { + defaultMessage: 'More', +}); + interface ActionWithContext { action: Action; context: Context; @@ -41,138 +44,177 @@ interface ActionWithContext { trigger: Trigger; } +type ItemDescriptor = EuiContextMenuPanelItemDescriptor & { + _order: number; + _title?: string; +}; + +type PanelDescriptor = EuiContextMenuPanelDescriptor & { + _level?: number; + _icon?: string; + items: ItemDescriptor[]; +}; + +const onClick = (action: Action, context: ActionExecutionContext, close: () => void) => ( + event: React.MouseEvent +) => { + if (event.currentTarget instanceof HTMLAnchorElement) { + // from react-router's + if ( + !event.defaultPrevented && // onClick prevented default + event.button === 0 && // ignore everything but left clicks + (!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc. + !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys + ) { + event.preventDefault(); + action.execute(context); + } + } else action.execute(context); + close(); +}; + /** - * Transforms an array of Actions to the shape EuiContextMenuPanel expects. + * This method adds "More" item to panels, which have more than 4 items; and + * moves all items after the thrird one into that "More" sub-menu. */ -export async function buildContextMenuForActions({ - actions, - title = defaultTitle, - closeMenu = () => {}, -}: { +const wrapMainPanelItemsIntoSubmenu = (panels: Record, id: string) => { + const panel = panels[id]; + if (!panel) return; + const maxItemsBeforeWrapping = 4; + if (!panel.items) return; + if (panel.items.length <= maxItemsBeforeWrapping) return; + const visibleItems = panel.items.slice(0, 3) as ItemDescriptor[]; + const itemsInSubmenu = panel.items.slice(3) as ItemDescriptor[]; + const morePanelId = panel.id + '__more'; + const more: ItemDescriptor = { + name: txtMore, + panel: morePanelId, + icon: 'boxesHorizontal', + 'data-test-subj': `embeddablePanelMore-${id}`, + _order: -1, + }; + panel.items = [...visibleItems, more]; + const subPanel: PanelDescriptor = { + id: morePanelId, + title: panel.title || defaultTitle, + items: itemsInSubmenu, + }; + panels[morePanelId] = subPanel; +}; + +const removeItemMetaFields = (items: ItemDescriptor[]): EuiContextMenuPanelItemDescriptor[] => { + const euiItems: EuiContextMenuPanelItemDescriptor[] = []; + for (const item of items) { + const { _order: omit, _title: omit2, ...rest } = item; + euiItems.push(rest); + } + return euiItems; +}; + +const removePanelMetaFields = (panels: PanelDescriptor[]): EuiContextMenuPanelDescriptor[] => { + const euiPanels: EuiContextMenuPanelDescriptor[] = []; + for (const panel of panels) { + const { _level: omit, _icon: omit2, ...rest } = panel; + euiPanels.push({ ...rest, items: removeItemMetaFields(rest.items) }); + } + return euiPanels; +}; + +export interface BuildContextMenuParams { actions: ActionWithContext[]; title?: string; closeMenu?: () => void; -}): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ - actions, - closeMenu, - }); - - return { - id: 'mainMenu', - title, - items: menuItems, - }; } /** - * Transform an array of Actions into the shape needed to build an EUIContextMenu + * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -async function buildEuiContextMenuPanelItems({ +export async function buildContextMenuForActions({ actions, - closeMenu, -}: { - actions: ActionWithContext[]; - closeMenu: () => void; -}) { - actions = sortBy( - actions, - (a) => -1 * (a.action.order ?? 0), - (a) => a.action.type, - (a) => a.action.getDisplayName({ ...a.context, trigger: a.trigger }) - ); - - const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async ({ action, context, trigger }, index) => { - const isCompatible = await action.isCompatible({ - ...context, - trigger, - }); - if (!isCompatible) { - return; + title = defaultTitle, + closeMenu = () => {}, +}: BuildContextMenuParams): Promise { + const panels: Record = { + mainMenu: { + id: 'mainMenu', + title, + items: [], + }, + }; + const promises = actions.map(async (item) => { + const { action } = item; + const context: ActionExecutionContext = { ...item.context, trigger: item.trigger }; + const isCompatible = await item.action.isCompatible(context); + if (!isCompatible) return; + let parentPanel = ''; + let currentPanel = ''; + if (action.grouping) { + for (let i = 0; i < action.grouping.length; i++) { + const group = action.grouping[i]; + currentPanel = group.id; + if (!panels[currentPanel]) { + const name = group.getDisplayName ? group.getDisplayName(context) : group.id; + panels[currentPanel] = { + id: currentPanel, + title: name, + items: [], + _level: i, + _icon: group.getIconType ? group.getIconType(context) : 'empty', + }; + if (parentPanel) { + panels[parentPanel].items!.push({ + name, + panel: currentPanel, + icon: group.getIconType ? group.getIconType(context) : 'empty', + _order: group.order || 0, + _title: group.getDisplayName ? group.getDisplayName(context) : '', + }); + } + } + parentPanel = currentPanel; + } } - - items[index] = await convertPanelActionToContextMenuItem({ - action, - actionContext: context, - trigger, - closeMenu, + panels[parentPanel || 'mainMenu'].items!.push({ + name: action.MenuItem + ? React.createElement(uiToReactComponent(action.MenuItem), { context }) + : action.getDisplayName(context), + icon: action.getIconType(context), + 'data-test-subj': `embeddablePanelAction-${action.id}`, + onClick: onClick(action, context, closeMenu), + href: action.getHref ? await action.getHref(context) : undefined, + _order: action.order || 0, + _title: action.getDisplayName(context), }); }); - await Promise.all(promises); - return items.filter(Boolean); -} - -async function convertPanelActionToContextMenuItem({ - action, - actionContext, - trigger, - closeMenu, -}: { - action: Action; - actionContext: Context; - trigger: Trigger; - closeMenu: () => void; -}): Promise { - const menuPanelItem: EuiContextMenuPanelItemDescriptor = { - name: action.MenuItem - ? React.createElement(uiToReactComponent(action.MenuItem), { - context: { - ...actionContext, - trigger, - }, - }) - : action.getDisplayName({ - ...actionContext, - trigger, - }), - icon: action.getIconType({ - ...actionContext, - trigger, - }), - panel: _.get(action, 'childContextMenuPanel.id'), - 'data-test-subj': `embeddablePanelAction-${action.id}`, - }; + for (const panel of Object.values(panels)) { + const items = panel.items.filter(Boolean) as ItemDescriptor[]; + panel.items = _.sortBy( + items, + (a) => -1 * (a._order ?? 0), + (a) => a._title + ); + } - menuPanelItem.onClick = (event) => { - if (event.currentTarget instanceof HTMLAnchorElement) { - // from react-router's - if ( - !event.defaultPrevented && // onClick prevented default - event.button === 0 && // ignore everything but left clicks - (!event.currentTarget.target || event.currentTarget.target === '_self') && // let browser handle "target=_blank" etc. - !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys - ) { - event.preventDefault(); - action.execute({ - ...actionContext, - trigger, + wrapMainPanelItemsIntoSubmenu(panels, 'mainMenu'); + + for (const panel of Object.values(panels)) { + if (panel._level === 0) { + // TODO: Add separator line here once it is available in EUI. + // See https://github.com/elastic/eui/pull/4018 + if (panel.items.length > 3) { + panels.mainMenu.items.push({ + name: panel.title || panel.id, + icon: panel._icon || 'empty', + panel: panel.id, }); } else { - // let browser handle navigation + panels.mainMenu.items.push(...panel.items); } - } else { - // not a link - action.execute({ - ...actionContext, - trigger, - }); - } - - closeMenu(); - }; - - if (action.getHref) { - const href = await action.getHref({ - ...actionContext, - trigger, - }); - if (href) { - menuPanelItem.href = href; } } - return menuPanelItem; + const panelList = Object.values(panels); + return removePanelMetaFields(panelList); } diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 476ca0ec17066..4b2d6cae1c8e1 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -33,7 +33,10 @@ export { IncompatibleActionError, } from './actions'; export { buildContextMenuForActions } from './context_menu'; -export { Presentable as UiActionsPresentable } from './util'; +export { + Presentable as UiActionsPresentable, + PresentableGrouping as UiActionsPresentableGrouping, +} from './util'; export { Trigger, TriggerContext, diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index f65a72f334d07..4f0ab52501a95 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -109,7 +109,7 @@ export class UiActionsExecutionService { } private async executeMultipleActions(tasks: ExecuteActionTask[]) { - const panel = await buildContextMenuForActions({ + const panels = await buildContextMenuForActions({ actions: tasks.map(({ action, context, trigger }) => ({ action, context, @@ -121,7 +121,7 @@ export class UiActionsExecutionService { session.close(); }, }); - const session = openContextMenu([panel], { + const session = openContextMenu(panels, { 'data-test-subj': 'multipleActionsContextMenu', }); } diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index e63ff28f42d96..f1aff6322522a 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { defaultMessage: 'Single click', }), description: i18n.translate('uiActions.triggers.valueClickDescription', { - defaultMessage: 'A single point on the visualization', + defaultMessage: 'A data point click on the visualization', }), }; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/util/presentable.ts index 57070f7673f61..59440d6c75976 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -68,4 +68,20 @@ export interface Presentable { * the context and should be displayed to user, otherwise resolves to false. */ isCompatible(context: Context): Promise; + + /** + * Grouping where this item should appear as a submenu. Each entry is a new + * sub-menu level. For example, used to show drilldowns and sharing options + * in panel context menu in a sub-menu. + */ + readonly grouping?: PresentableGrouping; } + +export interface PresentableGroup + extends Partial< + Pick, 'getDisplayName' | 'getDisplayNameTooltip' | 'getIconType' | 'order'> + > { + id: string; +} + +export type PresentableGrouping = Array>; diff --git a/src/plugins/usage_collection/server/plugin.ts b/src/plugins/usage_collection/server/plugin.ts index 00584e1fd5d86..74e70d5ea9d35 100644 --- a/src/plugins/usage_collection/server/plugin.ts +++ b/src/plugins/usage_collection/server/plugin.ts @@ -49,8 +49,25 @@ export class UsageCollectionPlugin implements Plugin { maximumWaitTimeForAllCollectorsInS: config.maximumWaitTimeForAllCollectorsInS, }); + const globalConfig = await this.initializerContext.config.legacy.globalConfig$ + .pipe(first()) + .toPromise(); + const router = core.http.createRouter(); - setupRoutes(router, () => this.savedObjects); + setupRoutes({ + router, + getSavedObjects: () => this.savedObjects, + collectorSet, + config: { + allowAnonymous: core.status.isStatusPageAnonymous(), + kibanaIndex: globalConfig.kibana.index, + kibanaVersion: this.initializerContext.env.packageInfo.version, + server: core.http.getServerInfo(), + uuid: this.initializerContext.env.instanceUuid, + }, + metrics: core.metrics, + overallStatus$: core.status.overall$, + }); return collectorSet; } diff --git a/src/plugins/usage_collection/server/routes/index.ts b/src/plugins/usage_collection/server/routes/index.ts index e6beef3fbdc59..b367ddc184be7 100644 --- a/src/plugins/usage_collection/server/routes/index.ts +++ b/src/plugins/usage_collection/server/routes/index.ts @@ -17,12 +17,39 @@ * under the License. */ -import { IRouter, ISavedObjectsRepository } from 'kibana/server'; +import { + IRouter, + ISavedObjectsRepository, + MetricsServiceSetup, + ServiceStatus, +} from 'kibana/server'; +import { Observable } from 'rxjs'; +import { CollectorSet } from '../collector'; import { registerUiMetricRoute } from './report_metrics'; +import { registerStatsRoute } from './stats'; -export function setupRoutes( - router: IRouter, - getSavedObjects: () => ISavedObjectsRepository | undefined -) { +export function setupRoutes({ + router, + getSavedObjects, + ...rest +}: { + router: IRouter; + getSavedObjects: () => ISavedObjectsRepository | undefined; + config: { + allowAnonymous: boolean; + kibanaIndex: string; + kibanaVersion: string; + uuid: string; + server: { + name: string; + hostname: string; + port: number; + }; + }; + collectorSet: CollectorSet; + metrics: MetricsServiceSetup; + overallStatus$: Observable; +}) { registerUiMetricRoute(router, getSavedObjects); + registerStatsRoute({ router, ...rest }); } diff --git a/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts new file mode 100644 index 0000000000000..2b39eb626e419 --- /dev/null +++ b/src/plugins/usage_collection/server/routes/integration_tests/stats.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { UnwrapPromise } from '@kbn/utility-types'; + +import { + MetricsServiceSetup, + ServiceStatus, + ServiceStatusLevels, +} from '../../../../../core/server'; +import { + contextServiceMock, + loggingSystemMock, + metricsServiceMock, +} from '../../../../../core/server/mocks'; +import { createHttpServer } from '../../../../../core/server/test_utils'; +import { registerStatsRoute } from '../stats'; +import supertest from 'supertest'; +import { CollectorSet } from '../../collector'; + +type HttpService = ReturnType; +type HttpSetup = UnwrapPromise>; + +describe('/api/stats', () => { + let server: HttpService; + let httpSetup: HttpSetup; + let overallStatus$: BehaviorSubject; + let metrics: MetricsServiceSetup; + + beforeEach(async () => { + server = createHttpServer(); + httpSetup = await server.setup({ + context: contextServiceMock.createSetupContract(), + }); + overallStatus$ = new BehaviorSubject({ + level: ServiceStatusLevels.available, + summary: 'everything is working', + }); + metrics = metricsServiceMock.createSetupContract(); + + const router = httpSetup.createRouter(''); + registerStatsRoute({ + router, + collectorSet: new CollectorSet({ + logger: loggingSystemMock.create().asLoggerFactory().get(), + }), + config: { + allowAnonymous: true, + kibanaIndex: '.kibana-test', + kibanaVersion: '8.8.8-SNAPSHOT', + server: { + name: 'mykibana', + hostname: 'mykibana.com', + port: 1234, + }, + uuid: 'xxx-xxxxx', + }, + metrics, + overallStatus$, + }); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('successfully returns data', async () => { + const response = await supertest(httpSetup.server.listener).get('/api/stats').expect(200); + expect(response.body).toMatchObject({ + kibana: { + uuid: 'xxx-xxxxx', + name: 'mykibana', + index: '.kibana-test', + host: 'mykibana.com', + locale: 'en', + transport_address: `mykibana.com:1234`, + version: '8.8.8', + snapshot: true, + status: 'green', + }, + last_updated: expect.any(String), + collection_interval_ms: expect.any(Number), + }); + }); +}); diff --git a/src/plugins/usage_collection/server/routes/stats.ts b/src/plugins/usage_collection/server/routes/stats.ts new file mode 100644 index 0000000000000..7c64c9f180319 --- /dev/null +++ b/src/plugins/usage_collection/server/routes/stats.ts @@ -0,0 +1,190 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import defaultsDeep from 'lodash/defaultsDeep'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { + IRouter, + LegacyAPICaller, + MetricsServiceSetup, + ServiceStatus, + ServiceStatusLevels, +} from '../../../../core/server'; +import { CollectorSet } from '../collector'; + +const STATS_NOT_READY_MESSAGE = i18n.translate('usageCollection.stats.notReadyMessage', { + defaultMessage: 'Stats are not ready yet. Please try again later.', +}); + +const SNAPSHOT_REGEX = /-snapshot/i; + +export function registerStatsRoute({ + router, + config, + collectorSet, + metrics, + overallStatus$, +}: { + router: IRouter; + config: { + allowAnonymous: boolean; + kibanaIndex: string; + kibanaVersion: string; + uuid: string; + server: { + name: string; + hostname: string; + port: number; + }; + }; + collectorSet: CollectorSet; + metrics: MetricsServiceSetup; + overallStatus$: Observable; +}) { + const getUsage = async (callCluster: LegacyAPICaller): Promise => { + const usage = await collectorSet.bulkFetchUsage(callCluster); + return collectorSet.toObject(usage); + }; + + const getClusterUuid = async (callCluster: LegacyAPICaller): Promise => { + const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' }); + return uuid; + }; + + router.get( + { + path: '/api/stats', + options: { + authRequired: !config.allowAnonymous, + tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page + }, + validate: { + query: schema.object({ + extended: schema.oneOf([schema.literal(''), schema.boolean()], { defaultValue: false }), + legacy: schema.oneOf([schema.literal(''), schema.boolean()], { defaultValue: false }), + exclude_usage: schema.oneOf([schema.literal(''), schema.boolean()], { + defaultValue: false, + }), + }), + }, + }, + async (context, req, res) => { + const isExtended = req.query.extended === '' || req.query.extended; + const isLegacy = req.query.legacy === '' || req.query.legacy; + const shouldGetUsage = req.query.exclude_usage === false; + + let extended; + if (isExtended) { + const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; + const collectorsReady = await collectorSet.areAllCollectorsReady(); + + if (shouldGetUsage && !collectorsReady) { + return res.customError({ statusCode: 503, body: { message: STATS_NOT_READY_MESSAGE } }); + } + + const usagePromise = shouldGetUsage ? getUsage(callCluster) : Promise.resolve({}); + const [usage, clusterUuid] = await Promise.all([usagePromise, getClusterUuid(callCluster)]); + + let modifiedUsage = usage; + if (isLegacy) { + // In an effort to make telemetry more easily augmented, we need to ensure + // we can passthrough the data without every part of the process needing + // to know about the change; however, to support legacy use cases where this + // wasn't true, we need to be backwards compatible with how the legacy data + // looked and support those use cases here. + modifiedUsage = Object.keys(usage).reduce((accum, usageKey) => { + if (usageKey === 'kibana') { + accum = { + ...accum, + ...usage[usageKey], + }; + } else if (usageKey === 'reporting') { + accum = { + ...accum, + xpack: { + ...accum.xpack, + reporting: usage[usageKey], + }, + }; + } else { + // I don't think we need to it this for the above conditions, but do it for most as it will + // match the behavior done in monitoring/bulk_uploader + defaultsDeep(accum, { [usageKey]: usage[usageKey] }); + } + + return accum; + }, {} as any); + + extended = { + usage: modifiedUsage, + clusterUuid, + }; + } else { + extended = collectorSet.toApiFieldNames({ + usage: modifiedUsage, + clusterUuid, + }); + } + } + + // Guranteed to resolve immediately due to replay effect on getOpsMetrics$ + // eslint-disable-next-line @typescript-eslint/naming-convention + const { collected_at, ...lastMetrics } = await metrics + .getOpsMetrics$() + .pipe(first()) + .toPromise(); + + const overallStatus = await overallStatus$.pipe(first()).toPromise(); + const kibanaStats = collectorSet.toApiFieldNames({ + ...lastMetrics, + kibana: { + uuid: config.uuid, + name: config.server.name, + index: config.kibanaIndex, + host: config.server.hostname, + locale: i18n.getLocale(), + transport_address: `${config.server.hostname}:${config.server.port}`, + version: config.kibanaVersion.replace(SNAPSHOT_REGEX, ''), + snapshot: SNAPSHOT_REGEX.test(config.kibanaVersion), + status: ServiceStatusToLegacyState[overallStatus.level.toString()], + }, + last_updated: collected_at.toISOString(), + collection_interval_in_millis: metrics.collectionInterval, + }); + + return res.ok({ + body: { + ...kibanaStats, + ...extended, + }, + }); + } + ); +} + +const ServiceStatusToLegacyState: Record = { + [ServiceStatusLevels.critical.toString()]: 'red', + [ServiceStatusLevels.unavailable.toString()]: 'red', + [ServiceStatusLevels.degraded.toString()]: 'yellow', + [ServiceStatusLevels.available.toString()]: 'green', +}; diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json new file mode 100644 index 0000000000000..35ad0a3a8be9a --- /dev/null +++ b/src/plugins/vis_default_editor/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "visDefaultEditor", + "version": "kibana", + "ui": true, + "requiredBundles": ["kibanaUtils", "kibanaReact", "data"] +} diff --git a/src/plugins/vis_default_editor/public/index.ts b/src/plugins/vis_default_editor/public/index.ts index 156d50f451b57..d7eb5eda7bdfe 100644 --- a/src/plugins/vis_default_editor/public/index.ts +++ b/src/plugins/vis_default_editor/public/index.ts @@ -24,3 +24,11 @@ export * from './editor_size'; export * from './vis_options_props'; export * from './utils'; export { ISchemas, Schemas, Schema } from './schemas'; + +/** dummy plugin, we just want visDefaultEditor to have its own bundle */ +export function plugin() { + return new (class VisDefaultEditor { + setup() {} + start() {} + })(); +} diff --git a/src/plugins/vis_type_markdown/kibana.json b/src/plugins/vis_type_markdown/kibana.json index 4196bd7e85707..5723fdefe1e4c 100644 --- a/src/plugins/vis_type_markdown/kibana.json +++ b/src/plugins/vis_type_markdown/kibana.json @@ -4,5 +4,5 @@ "ui": true, "server": true, "requiredPlugins": ["expressions", "visualizations"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "data", "charts", "visualizations", "expressions"] + "requiredBundles": ["kibanaUtils", "kibanaReact", "charts", "visualizations", "expressions", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_metric/kibana.json b/src/plugins/vis_type_metric/kibana.json index 26ca09e22f26e..68ab0bf617e30 100644 --- a/src/plugins/vis_type_metric/kibana.json +++ b/src/plugins/vis_type_metric/kibana.json @@ -5,5 +5,5 @@ "server": true, "ui": true, "requiredPlugins": ["data", "visualizations", "charts", "expressions"], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_table/kibana.json b/src/plugins/vis_type_table/kibana.json index b3c1556429077..b8a68909dc857 100644 --- a/src/plugins/vis_type_table/kibana.json +++ b/src/plugins/vis_type_table/kibana.json @@ -11,8 +11,8 @@ ], "requiredBundles": [ "kibanaUtils", - "kibanaReact", "share", - "charts" + "charts", + "visDefaultEditor" ] } diff --git a/src/plugins/vis_type_tagcloud/kibana.json b/src/plugins/vis_type_tagcloud/kibana.json index 86f72ebfa936d..441a899c3ab53 100644 --- a/src/plugins/vis_type_tagcloud/kibana.json +++ b/src/plugins/vis_type_tagcloud/kibana.json @@ -4,5 +4,5 @@ "ui": true, "server": true, "requiredPlugins": ["data", "expressions", "visualizations", "charts"], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_timelion/kibana.json b/src/plugins/vis_type_timelion/kibana.json index 6946568f5d809..dda33c9fb6f2e 100644 --- a/src/plugins/vis_type_timelion/kibana.json +++ b/src/plugins/vis_type_timelion/kibana.json @@ -5,5 +5,5 @@ "server": true, "ui": true, "requiredPlugins": ["visualizations", "data", "expressions"], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts index 0cf6f3723a639..3442f84599fb8 100644 --- a/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts +++ b/src/plugins/vis_type_timelion/public/helpers/timelion_request_handler.ts @@ -78,7 +78,6 @@ export function getTimelionRequestHandler({ filters: Filter[]; query: Query; visParams: VisParams; - forceFetch?: boolean; }): Promise { const expression = visParams.expression; diff --git a/src/plugins/vis_type_timelion/public/index.ts b/src/plugins/vis_type_timelion/public/index.ts index abfe345d8c672..f5e781b95f2e2 100644 --- a/src/plugins/vis_type_timelion/public/index.ts +++ b/src/plugins/vis_type_timelion/public/index.ts @@ -31,4 +31,4 @@ export { generateTicksProvider } from './helpers/tick_generator'; export { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; -export { VisTypeTimelionPluginStart } from './plugin'; +export { VisTypeTimelionPluginStart, VisTypeTimelionPluginSetup } from './plugin'; diff --git a/src/plugins/vis_type_timelion/public/plugin.ts b/src/plugins/vis_type_timelion/public/plugin.ts index 060fec04deb3f..e2c7efec34c7f 100644 --- a/src/plugins/vis_type_timelion/public/plugin.ts +++ b/src/plugins/vis_type_timelion/public/plugin.ts @@ -66,11 +66,16 @@ export interface VisTypeTimelionPluginStart { getArgValueSuggestions: typeof getArgValueSuggestions; } +/** @public */ +export interface VisTypeTimelionPluginSetup { + isUiEnabled: boolean; +} + /** @internal */ export class TimelionVisPlugin implements Plugin< - void, + VisTypeTimelionPluginSetup, VisTypeTimelionPluginStart, TimelionVisSetupDependencies, TimelionVisStartDependencies @@ -89,14 +94,15 @@ export class TimelionVisPlugin expressions.registerFunction(() => getTimelionVisualizationConfig(dependencies)); visualizations.createReactVisualization(getTimelionVisDefinition(dependencies)); + + return { + isUiEnabled: this.initializerContext.config.get().ui.enabled, + }; } public start(core: CoreStart, plugins: TimelionVisStartDependencies) { setIndexPatterns(plugins.data.indexPatterns); setSavedObjectsClient(core.savedObjects.client); - if (this.initializerContext.config.get().ui.enabled === false) { - core.chrome.navLinks.update('timelion', { hidden: true }); - } return { getArgValueSuggestions, diff --git a/src/plugins/vis_type_timelion/public/timelion_vis_fn.ts b/src/plugins/vis_type_timelion/public/timelion_vis_fn.ts index 7be18a4774d94..d3c6ca5d90371 100644 --- a/src/plugins/vis_type_timelion/public/timelion_vis_fn.ts +++ b/src/plugins/vis_type_timelion/public/timelion_vis_fn.ts @@ -76,7 +76,6 @@ export const getTimelionVisualizationConfig = ( query: get(input, 'query') as Query, filters: get(input, 'filters') as Filter[], visParams, - forceFetch: true, }); response.visType = TIMELION_VIS_NAME; diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index f460257caf5e3..333ed0ff64fdb 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -21,7 +21,7 @@ import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/serve import { VisTypeTimeseriesConfig, config as configSchema } from './config'; import { VisTypeTimeseriesPlugin } from './plugin'; -export { VisTypeTimeseriesSetup, Framework } from './plugin'; +export { VisTypeTimeseriesSetup } from './plugin'; export const config: PluginConfigDescriptor = { deprecations: ({ unused, renameFromRoot }) => [ @@ -39,10 +39,10 @@ export const config: PluginConfigDescriptor = { export { ValidationTelemetryServiceSetup } from './validation_telemetry'; -// @ts-ignore -export { AbstractSearchStrategy } from './lib/search_strategies/strategies/abstract_search_strategy'; -// @ts-ignore -export { AbstractSearchRequest } from './lib/search_strategies/search_requests/abstract_request'; +export { + AbstractSearchStrategy, + ReqFacade, +} from './lib/search_strategies/strategies/abstract_search_strategy'; // @ts-ignore export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; diff --git a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts index 0f0d99bff6f1c..26a1792e3ec70 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_fields.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_fields.ts @@ -16,15 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { uniqBy } from 'lodash'; +import { uniqBy, get } from 'lodash'; import { first, map } from 'rxjs/operators'; import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; -// @ts-ignore -import { getIndexPatternObject } from './vis_data/helpers/get_index_pattern'; -import { indexPatterns } from '../../../data/server'; import { Framework } from '../plugin'; -import { IndexPatternFieldDescriptor, IndexPatternsFetcher } from '../../../data/server'; +import { + indexPatterns, + IndexPatternFieldDescriptor, + IndexPatternsFetcher, +} from '../../../data/server'; import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy'; export async function getFields( @@ -38,6 +39,7 @@ export async function getFields( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, payload: {}, @@ -48,22 +50,6 @@ export async function getFields( }, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( @@ -73,7 +59,15 @@ export async function getFields( .toPromise(); }, }; - const { indexPatternString } = await getIndexPatternObject(reqFacade, indexPattern); + let indexPatternString = indexPattern; + + if (!indexPatternString) { + const [, { data }] = await framework.core.getStartServices(); + const indexPatternsService = await data.indexPatterns.indexPatternsServiceFactory(request); + const defaultIndexPattern = await indexPatternsService.getDefault(); + indexPatternString = get(defaultIndexPattern, 'title', ''); + } + const { searchStrategy, capabilities, diff --git a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts index f697e754a2e00..5eef2b53e2431 100644 --- a/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts +++ b/src/plugins/vis_type_timeseries/server/lib/get_vis_data.ts @@ -21,7 +21,7 @@ import { FakeRequest, RequestHandlerContext } from 'kibana/server'; import _ from 'lodash'; import { first, map } from 'rxjs/operators'; import { getPanelData } from './vis_data/get_panel_data'; -import { Framework } from '../index'; +import { Framework } from '../plugin'; import { ReqFacade } from './search_strategies/strategies/abstract_search_strategy'; interface GetVisDataResponse { @@ -65,28 +65,13 @@ export function getVisData( // level object passed from here. The layers should be refactored fully at some point, but for now // this works and we are still using the New Platform services for these vis data portions. const reqFacade: ReqFacade = { + requestContext, ...request, framework, pre: {}, payload: request.body, getUiSettingsService: () => requestContext.core.uiSettings.client, getSavedObjectsClient: () => requestContext.core.savedObjects.client, - server: { - plugins: { - elasticsearch: { - getCluster: () => { - return { - callWithRequest: async (req: any, endpoint: string, params: any) => { - return await requestContext.core.elasticsearch.legacy.client.callAsCurrentUser( - endpoint, - params - ); - }, - }; - }, - }, - }, - }, getEsShardTimeout: async () => { return await framework.globalConfig$ .pipe( diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js deleted file mode 100644 index abd2a4c65d35c..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -export class AbstractSearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } - - search() { - throw new Error('AbstractSearchRequest: search method should be defined'); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js deleted file mode 100644 index 6f71aa63728d5..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/abstract_request.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; - -describe('AbstractSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - - beforeEach(() => { - req = {}; - callWithRequest = jest.fn(); - searchRequest = new AbstractSearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should throw an error trying to search', () => { - try { - searchRequest.search(); - } catch (error) { - expect(error instanceof Error).toBe(true); - expect(error.message).toEqual('AbstractSearchRequest: search method should be defined'); - } - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js deleted file mode 100644 index 9ada39e359589..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'msearch'; - -export class MultiSearchRequest extends AbstractSearchRequest { - async search(searches) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const multiSearchBody = searches.reduce( - (acc, { body, index }) => [ - ...acc, - { - index, - ignoreUnavailable: true, - }, - body, - ], - [] - ); - - const { responses } = await this.callWithRequest(this.req, SEARCH_METHOD, { - body: multiSearchBody, - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - - return responses; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js deleted file mode 100644 index c113db76332b7..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/multi_search_request.test.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { MultiSearchRequest } from './multi_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('MultiSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new MultiSearchRequest(req, callWithRequest); - }); - - test('should init an MultiSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic msearch', async () => { - const searches = [ - { body: 'body1', index: 'index' }, - { body: 'body2', index: 'index' }, - ]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'msearch', { - body: [ - { ignoreUnavailable: true, index: 'index' }, - 'body1', - { ignoreUnavailable: true, index: 'index' }, - 'body2', - ], - rest_total_hits_as_int: true, - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js deleted file mode 100644 index e6e3bcb527286..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; - -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -export class SearchRequest extends AbstractSearchRequest { - getSearchRequestType(searches) { - const isMultiSearch = Array.isArray(searches) && searches.length > 1; - const SearchRequest = isMultiSearch ? MultiSearchRequest : SingleSearchRequest; - - return new SearchRequest(this.req, this.callWithRequest); - } - - async search(options) { - const concreteSearchRequest = this.getSearchRequestType(options); - - return concreteSearchRequest.search(options); - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js deleted file mode 100644 index 3d35a4aa37c5a..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/search_request.test.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SearchRequest } from './search_request'; -import { MultiSearchRequest } from './multi_search_request'; -import { SingleSearchRequest } from './single_search_request'; - -describe('SearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({ responses: [] }); - searchRequest = new SearchRequest(req, callWithRequest); - }); - - test('should init an AbstractSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should return search value', async () => { - const concreteSearchRequest = { - search: jest.fn().mockReturnValue('concreteSearchRequest'), - }; - const options = {}; - searchRequest.getSearchRequestType = jest.fn().mockReturnValue(concreteSearchRequest); - - const result = await searchRequest.search(options); - - expect(result).toBe('concreteSearchRequest'); - }); - - test('should return a MultiSearchRequest for multi searches', () => { - const searches = [ - { index: 'index', body: 'body' }, - { index: 'index', body: 'body' }, - ]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof MultiSearchRequest).toBe(true); - }); - - test('should return a SingleSearchRequest for single search', () => { - const searches = [{ index: 'index', body: 'body' }]; - - const result = searchRequest.getSearchRequestType(searches); - - expect(result instanceof SingleSearchRequest).toBe(true); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js deleted file mode 100644 index 7d8b60a7e4595..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { AbstractSearchRequest } from './abstract_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -const SEARCH_METHOD = 'search'; - -export class SingleSearchRequest extends AbstractSearchRequest { - async search([{ body, index }]) { - const includeFrozen = await this.req - .getUiSettingsService() - .get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - const resp = await this.callWithRequest(this.req, SEARCH_METHOD, { - ignore_throttled: !includeFrozen, - body, - index, - }); - - return [resp]; - } -} diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js deleted file mode 100644 index b899814f2fe13..0000000000000 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_requests/single_search_request.test.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import { SingleSearchRequest } from './single_search_request'; -import { UI_SETTINGS } from '../../../../../data/server'; - -describe('SingleSearchRequest', () => { - let searchRequest; - let req; - let callWithRequest; - let getServiceMock; - let includeFrozen; - - beforeEach(() => { - includeFrozen = false; - getServiceMock = jest.fn().mockResolvedValue(includeFrozen); - req = { - getUiSettingsService: jest.fn().mockReturnValue({ get: getServiceMock }), - }; - callWithRequest = jest.fn().mockReturnValue({}); - searchRequest = new SingleSearchRequest(req, callWithRequest); - }); - - test('should init an SingleSearchRequest instance', () => { - expect(searchRequest.req).toBe(req); - expect(searchRequest.callWithRequest).toBe(callWithRequest); - expect(searchRequest.search).toBeDefined(); - }); - - test('should get the response from elastic search', async () => { - const searches = [{ body: 'body', index: 'index' }]; - - const responses = await searchRequest.search(searches); - - expect(responses).toEqual([{}]); - expect(req.getUiSettingsService).toHaveBeenCalled(); - expect(getServiceMock).toHaveBeenCalledWith(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); - expect(callWithRequest).toHaveBeenCalledWith(req, 'search', { - body: 'body', - index: 'index', - ignore_throttled: !includeFrozen, - }); - }); -}); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index ecd09653b3b48..66ea4f017dd90 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -65,7 +65,7 @@ describe('SearchStrategyRegister', () => { }); test('should add a strategy if it is an instance of AbstractSearchStrategy', () => { - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); const addedStrategies = registry.addStrategy(anotherSearchStrategy); expect(addedStrategies.length).toEqual(2); @@ -75,7 +75,7 @@ describe('SearchStrategyRegister', () => { test('should return a MockSearchStrategy instance', async () => { const req = {}; const indexPattern = '*'; - const anotherSearchStrategy = new MockSearchStrategy({}, {} as any, {}); + const anotherSearchStrategy = new MockSearchStrategy('es'); registry.addStrategy(anotherSearchStrategy); const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js index 1fbaffd794c89..4dcc67dc46976 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js @@ -18,24 +18,13 @@ */ import { AbstractSearchStrategy } from './abstract_search_strategy'; -class SearchRequest { - constructor(req, callWithRequest) { - this.req = req; - this.callWithRequest = callWithRequest; - } -} - describe('AbstractSearchStrategy', () => { let abstractSearchStrategy; - let server; - let callWithRequestFactory; let req; let mockedFields; let indexPattern; beforeEach(() => { - server = {}; - callWithRequestFactory = jest.fn().mockReturnValue('callWithRequest'); mockedFields = {}; req = { pre: { @@ -45,16 +34,11 @@ describe('AbstractSearchStrategy', () => { }, }; - abstractSearchStrategy = new AbstractSearchStrategy( - server, - callWithRequestFactory, - SearchRequest - ); + abstractSearchStrategy = new AbstractSearchStrategy('es'); }); test('should init an AbstractSearchStrategy instance', () => { - expect(abstractSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(abstractSearchStrategy.getSearchRequest).toBeDefined(); + expect(abstractSearchStrategy.search).toBeDefined(); expect(abstractSearchStrategy.getFieldsForWildcard).toBeDefined(); expect(abstractSearchStrategy.checkForViability).toBeDefined(); }); @@ -65,20 +49,50 @@ describe('AbstractSearchStrategy', () => { expect(fields).toBe(mockedFields); expect(req.pre.indexPatternsService.getFieldsForWildcard).toHaveBeenCalledWith({ pattern: indexPattern, + fieldCapsOptions: { allowNoIndices: true }, }); }); - test('should invoke callWithRequestFactory with req param passed', () => { - abstractSearchStrategy.getCallWithRequestInstance(req); + test('should return response', async () => { + const searches = [{ body: 'body', index: 'index' }]; + const searchFn = jest.fn().mockReturnValue(Promise.resolve({})); - expect(callWithRequestFactory).toHaveBeenCalledWith(server, req); - }); - - test('should return a search request', () => { - const searchRequest = abstractSearchStrategy.getSearchRequest(req); + const responses = await abstractSearchStrategy.search( + { + requestContext: {}, + framework: { + core: { + getStartServices: jest.fn().mockReturnValue( + Promise.resolve([ + {}, + { + data: { + search: { + search: searchFn, + }, + }, + }, + ]) + ), + }, + }, + }, + searches + ); - expect(searchRequest instanceof SearchRequest).toBe(true); - expect(searchRequest.callWithRequest).toBe('callWithRequest'); - expect(searchRequest.req).toBe(req); + expect(responses).toEqual([{}]); + expect(searchFn).toHaveBeenCalledWith( + {}, + { + params: { + body: 'body', + index: 'index', + }, + indexType: undefined, + }, + { + strategy: 'es', + } + ); }); }); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 0b1c6e6e20414..2eb92b2b777e8 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -18,7 +18,7 @@ */ import { - LegacyAPICaller, + RequestHandlerContext, FakeRequest, IUiSettingsClient, SavedObjectsClientContract, @@ -33,6 +33,7 @@ import { IndexPatternsFetcher } from '../../../../../data/server'; * This will be replaced by standard KibanaRequest and RequestContext objects in a later version. */ export type ReqFacade = FakeRequest & { + requestContext: RequestHandlerContext; framework: Framework; payload: unknown; pre: { @@ -40,34 +41,42 @@ export type ReqFacade = FakeRequest & { }; getUiSettingsService: () => IUiSettingsClient; getSavedObjectsClient: () => SavedObjectsClientContract; - server: { - plugins: { - elasticsearch: { - getCluster: () => { - callWithRequest: (req: ReqFacade, endpoint: string, params: any) => Promise; - }; - }; - }; - }; getEsShardTimeout: () => Promise; }; export class AbstractSearchStrategy { - public getCallWithRequestInstance: (req: ReqFacade) => LegacyAPICaller; - public getSearchRequest: (req: ReqFacade) => any; - - constructor( - server: any, - callWithRequestFactory: (server: any, req: ReqFacade) => LegacyAPICaller, - SearchRequest: any - ) { - this.getCallWithRequestInstance = (req) => callWithRequestFactory(server, req); + public searchStrategyName!: string; + public indexType?: string; + public additionalParams: any; - this.getSearchRequest = (req) => { - const callWithRequest = this.getCallWithRequestInstance(req); + constructor(name: string, type?: string, additionalParams: any = {}) { + this.searchStrategyName = name; + this.indexType = type; + this.additionalParams = additionalParams; + } - return new SearchRequest(req, callWithRequest); - }; + async search(req: ReqFacade, bodies: any[], options = {}) { + const [, deps] = await req.framework.core.getStartServices(); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + deps.data.search.search( + req.requestContext, + { + params: { + ...body, + ...this.additionalParams, + }, + indexType: this.indexType, + }, + { + ...options, + strategy: this.searchStrategyName, + } + ) + ); + }); + return Promise.all(requests); } async getFieldsForWildcard(req: ReqFacade, indexPattern: string, capabilities: any) { @@ -75,6 +84,7 @@ export class AbstractSearchStrategy { return await indexPatternsService!.getFieldsForWildcard({ pattern: indexPattern, + fieldCapsOptions: { allowNoIndices: true }, }); } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js index 63f2911ce1118..7c3609ae3c405 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.js @@ -16,21 +16,16 @@ * specific language governing permissions and limitations * under the License. */ + +import { ES_SEARCH_STRATEGY } from '../../../../../data/server'; import { AbstractSearchStrategy } from './abstract_search_strategy'; -import { SearchRequest } from '../search_requests/search_request'; import { DefaultSearchCapabilities } from '../default_search_capabilities'; -const callWithRequestFactory = (server, request) => { - const { callWithRequest } = request.server.plugins.elasticsearch.getCluster('data'); - - return callWithRequest; -}; - export class DefaultSearchStrategy extends AbstractSearchStrategy { name = 'default'; - constructor(server) { - super(server, callWithRequestFactory, SearchRequest); + constructor() { + super(ES_SEARCH_STRATEGY); } checkForViability(req) { diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js index 2e3a459bf06fd..a9994ba3e1f75 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.js @@ -20,42 +20,20 @@ import { DefaultSearchStrategy } from './default_search_strategy'; describe('DefaultSearchStrategy', () => { let defaultSearchStrategy; - let server; - let callWithRequest; let req; beforeEach(() => { - server = {}; - callWithRequest = jest.fn(); - req = { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ - callWithRequest, - }), - }, - }, - }, - }; - defaultSearchStrategy = new DefaultSearchStrategy(server); + req = {}; + defaultSearchStrategy = new DefaultSearchStrategy(); }); test('should init an DefaultSearchStrategy instance', () => { expect(defaultSearchStrategy.name).toBe('default'); expect(defaultSearchStrategy.checkForViability).toBeDefined(); - expect(defaultSearchStrategy.getCallWithRequestInstance).toBeDefined(); - expect(defaultSearchStrategy.getSearchRequest).toBeDefined(); + expect(defaultSearchStrategy.search).toBeDefined(); expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); }); - test('should invoke callWithRequestFactory with passed params', () => { - const value = defaultSearchStrategy.getCallWithRequestInstance(req); - - expect(value).toBe(callWithRequest); - expect(req.server.plugins.elasticsearch.getCluster).toHaveBeenCalledWith('data'); - }); - test('should check a strategy for viability', () => { const value = defaultSearchStrategy.checkForViability(req); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js index b015aaf0ef8db..d8a230dfeef4e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_annotations.js @@ -39,7 +39,6 @@ export async function getAnnotations({ capabilities, series, }) { - const searchRequest = searchStrategy.getSearchRequest(req); const annotations = panel.annotations.filter(validAnnotation); const lastSeriesTimestamp = getLastSeriesTimestamp(series); const handleAnnotationResponseBy = handleAnnotationResponse(lastSeriesTimestamp); @@ -47,6 +46,7 @@ export async function getAnnotations({ const bodiesPromises = annotations.map((annotation) => getAnnotationRequestParams(req, panel, annotation, esQueryConfig, capabilities) ); + const searches = (await Promise.all(bodiesPromises)).reduce( (acc, items) => acc.concat(items), [] @@ -55,10 +55,10 @@ export async function getAnnotations({ if (!searches.length) return { responses: [] }; try { - const data = await searchRequest.search(searches); + const data = await searchStrategy.search(req.framework.core, req.requestContext, searches); return annotations.reduce((acc, annotation, index) => { - acc[annotation.id] = handleAnnotationResponseBy(data[index], annotation); + acc[annotation.id] = handleAnnotationResponseBy(data[index].rawResponse, annotation); return acc; }, {}); diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js index e4bda194299df..82a2ef66cb1c0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_interval_and_timefield.js @@ -17,12 +17,10 @@ * under the License. */ -import { get } from 'lodash'; - const DEFAULT_TIME_FIELD = '@timestamp'; export function getIntervalAndTimefield(panel, series = {}, indexPatternObject) { - const getDefaultTimeField = () => get(indexPatternObject, 'timeFieldName', DEFAULT_TIME_FIELD); + const getDefaultTimeField = () => indexPatternObject?.timeFieldName ?? DEFAULT_TIME_FIELD; const timeField = (series.override_index_pattern && series.series_time_field) || diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js index ee48816c6a8af..1eace13c2e336 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_series_data.js @@ -28,7 +28,6 @@ export async function getSeriesData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategyForPanel(req, panel); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const meta = { type: panel.type, @@ -45,8 +44,13 @@ export async function getSeriesData(req, panel) { [] ); - const data = await searchRequest.search(searches); - const series = data.map(handleResponseBody(panel)); + const data = await searchStrategy.search(req, searches); + + const handleResponseBodyFn = handleResponseBody(panel); + + const series = data.map((resp) => + handleResponseBodyFn(resp.rawResponse ? resp.rawResponse : resp) + ); let annotations = null; if (panel.annotations && panel.annotations.length) { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js index 1d1c245907959..3791eb229db5b 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/get_table_data.js @@ -30,7 +30,6 @@ export async function getTableData(req, panel) { searchStrategy, capabilities, } = await req.framework.searchStrategyRegistry.getViableStrategy(req, panelIndexPattern); - const searchRequest = searchStrategy.getSearchRequest(req); const esQueryConfig = await getEsQueryConfig(req); const { indexPatternObject } = await getIndexPatternObject(req, panelIndexPattern); @@ -41,13 +40,18 @@ export async function getTableData(req, panel) { try { const body = buildRequestBody(req, panel, esQueryConfig, indexPatternObject, capabilities); - const [resp] = await searchRequest.search([ + const [resp] = await searchStrategy.search(req, [ { body, index: panelIndexPattern, }, ]); - const buckets = get(resp, 'aggregations.pivot.buckets', []); + + const buckets = get( + resp.rawResponse ? resp.rawResponse : resp, + 'aggregations.pivot.buckets', + [] + ); return { ...meta, diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index d863937a4e3dc..678ba2b371978 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -33,6 +33,7 @@ import { VisTypeTimeseriesConfig } from './config'; import { getVisData, GetVisData, GetVisDataOptions } from './lib/get_vis_data'; import { ValidationTelemetryService } from './validation_telemetry'; import { UsageCollectionSetup } from '../../usage_collection/server'; +import { PluginStart } from '../../data/server'; import { visDataRoutes } from './routes/vis'; // @ts-ignore import { fieldsRoutes } from './routes/fields'; @@ -47,6 +48,10 @@ interface VisTypeTimeseriesPluginSetupDependencies { usageCollection?: UsageCollectionSetup; } +interface VisTypeTimeseriesPluginStartDependencies { + data: PluginStart; +} + export interface VisTypeTimeseriesSetup { getVisData: ( requestContext: RequestHandlerContext, @@ -57,7 +62,7 @@ export interface VisTypeTimeseriesSetup { } export interface Framework { - core: CoreSetup; + core: CoreSetup; plugins: any; config$: Observable; globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; @@ -74,7 +79,10 @@ export class VisTypeTimeseriesPlugin implements Plugin { this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { + public setup( + core: CoreSetup, + plugins: VisTypeTimeseriesPluginSetupDependencies + ) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); core.uiSettings.register(uiSettings); const config$ = this.initializerContext.config.create(); diff --git a/src/plugins/vis_type_timeseries/server/routes/vis.ts b/src/plugins/vis_type_timeseries/server/routes/vis.ts index 48efd4398e4d4..1ca8b57ab230f 100644 --- a/src/plugins/vis_type_timeseries/server/routes/vis.ts +++ b/src/plugins/vis_type_timeseries/server/routes/vis.ts @@ -21,7 +21,8 @@ import { IRouter, KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData, GetVisDataOptions } from '../lib/get_vis_data'; import { visPayloadSchema } from '../../common/vis_schema'; -import { Framework, ValidationTelemetryServiceSetup } from '../index'; +import { ValidationTelemetryServiceSetup } from '../index'; +import { Framework } from '../plugin'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); diff --git a/src/plugins/vis_type_vega/kibana.json b/src/plugins/vis_type_vega/kibana.json index 7ba5f23f10564..231e252fe2c8a 100644 --- a/src/plugins/vis_type_vega/kibana.json +++ b/src/plugins/vis_type_vega/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["data", "visualizations", "mapsLegacy", "expressions", "inspector"], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "kibanaReact", "visDefaultEditor"] } diff --git a/src/plugins/vis_type_vislib/kibana.json b/src/plugins/vis_type_vislib/kibana.json index 7cba2e0d6a6b4..720abff16b7c7 100644 --- a/src/plugins/vis_type_vislib/kibana.json +++ b/src/plugins/vis_type_vislib/kibana.json @@ -5,5 +5,5 @@ "ui": true, "requiredPlugins": ["charts", "data", "expressions", "visualizations", "kibanaLegacy"], "optionalPlugins": ["visTypeXy"], - "requiredBundles": ["kibanaUtils", "kibanaReact"] + "requiredBundles": ["kibanaUtils", "visDefaultEditor"] } diff --git a/src/plugins/visualizations/public/expressions/visualization_function.ts b/src/plugins/visualizations/public/expressions/visualization_function.ts index 68a153f4272a3..f4241808940b2 100644 --- a/src/plugins/visualizations/public/expressions/visualization_function.ts +++ b/src/plugins/visualizations/public/expressions/visualization_function.ts @@ -117,7 +117,6 @@ export const visualization = (): ExpressionFunctionVisualization => ({ uiState, inspectorAdapters, queryFilter: getFilterManager(), - forceFetch: true, aggs, }); } diff --git a/src/plugins/visualizations/public/vis.test.ts b/src/plugins/visualizations/public/vis.test.ts index a0da8d83bed51..c271888b7c7a4 100644 --- a/src/plugins/visualizations/public/vis.test.ts +++ b/src/plugins/visualizations/public/vis.test.ts @@ -35,7 +35,7 @@ jest.mock('./services', () => { // eslint-disable-next-line const { BaseVisType } = require('./vis_types/base_vis_type'); // eslint-disable-next-line - const { SearchSource } = require('../../data/public/search/search_source'); + const { SearchSource } = require('../../data/common/search/search_source'); // eslint-disable-next-line const fixturesStubbedLogstashIndexPatternProvider = require('../../../fixtures/stubbed_logstash_index_pattern'); const visType = new BaseVisType({ diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 3c4c983efa9fa..0925d1c7cc0c9 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -196,7 +196,7 @@ exports[`NewVisModal filter for visualization types should render as expected 1` > { - before(() => esArchiver.load('elasticsearch')); - after(() => esArchiver.unload('elasticsearch')); - - it('allows search to specific index', async () => - await supertest.post('/elasticsearch/elasticsearch/_search').expect(200)); - - it('allows msearch', async () => - await supertest - .post('/elasticsearch/_msearch') - .set('content-type', 'application/x-ndjson') - .send( - '{"index":"logstash-2015.04.21","ignore_unavailable":true}\n{"size":500,"sort":{"@timestamp":"desc"},"query":{"bool":{"must":[{"query_string":{"analyze_wildcard":true,"query":"*"}},{"bool":{"must":[{"range":{"@timestamp":{"gte":1429577068175,"lte":1429577968175}}}],"must_not":[]}}],"must_not":[]}},"highlight":{"pre_tags":["@kibana-highlighted-field@"],"post_tags":["@/kibana-highlighted-field@"],"fields":{"*":{}}},"aggs":{"2":{"date_histogram":{"field":"@timestamp","interval":"30s","min_doc_count":0,"extended_bounds":{"min":1429577068175,"max":1429577968175}}}},"stored_fields":["*"],"_source": true,"script_fields":{},"docvalue_fields":["timestamp_offset","@timestamp","utc_time"]}\n' - ) - .expect(200)); - }); -} diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index bfbf873cf0616..d07c099634005 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -20,7 +20,6 @@ export default function ({ loadTestFile }) { describe('apis', () => { loadTestFile(require.resolve('./core')); - loadTestFile(require.resolve('./elasticsearch')); loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./home')); loadTestFile(require.resolve('./index_patterns')); diff --git a/test/api_integration/apis/telemetry/telemetry_local.js b/test/api_integration/apis/telemetry/telemetry_local.js index d2d61705b763d..9a5467e622ff3 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.js +++ b/test/api_integration/apis/telemetry/telemetry_local.js @@ -154,5 +154,113 @@ export default function ({ getService }) { expect(expected.every((m) => actual.includes(m))).to.be.ok(); }); + + describe('application usage limits', () => { + const timeRange = { + min: '2018-07-23T22:07:00Z', + max: '2018-07-23T22:13:00Z', + }; + + function createSavedObject() { + return supertest + .post('/api/saved_objects/application_usage_transactional') + .send({ + attributes: { + appId: 'test-app', + minutesOnScreen: 10.99, + numberOfClicks: 10, + timestamp: new Date().toISOString(), + }, + }) + .expect(200) + .then((resp) => resp.body.id); + } + + describe('basic behaviour', () => { + let savedObjectId; + before('create 1 entry', async () => { + return createSavedObject().then((id) => (savedObjectId = id)); + }); + after('cleanup', () => { + return supertest + .delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`) + .expect(200); + }); + + it('should return application_usage data', async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10, + clicks_7_days: 10, + clicks_30_days: 10, + clicks_90_days: 10, + minutes_on_screen_total: 10.99, + minutes_on_screen_7_days: 10.99, + minutes_on_screen_30_days: 10.99, + minutes_on_screen_90_days: 10.99, + }, + }); + }); + }); + + describe('10k + 1', () => { + const savedObjectIds = []; + before('create 10k + 1 entries for application usage', async () => { + await supertest + .post('/api/saved_objects/_bulk_create') + .send( + new Array(10001).fill(0).map(() => ({ + type: 'application_usage_transactional', + attributes: { + appId: 'test-app', + minutesOnScreen: 1, + numberOfClicks: 1, + timestamp: new Date().toISOString(), + }, + })) + ) + .expect(200) + .then((resp) => resp.body.saved_objects.forEach(({ id }) => savedObjectIds.push(id))); + }); + after('clean them all', async () => { + // The SavedObjects API does not allow bulk deleting, and deleting one by one takes ages and the tests timeout + await es.deleteByQuery({ + index: '.kibana', + body: { query: { term: { type: 'application_usage_transactional' } } }, + }); + }); + + it("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10000, + clicks_7_days: 10000, + clicks_30_days: 10000, + clicks_90_days: 10000, + minutes_on_screen_total: 10000, + minutes_on_screen_7_days: 10000, + minutes_on_screen_30_days: 10000, + minutes_on_screen_90_days: 10000, + }, + }); + }); + }); + }); }); } diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index fae4c9198cab6..d94822611f80b 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -21,7 +21,7 @@ import { Role } from './role'; import { User } from './user'; import { RoleMappings } from './role_mappings'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { createTestUserService } from './test_user'; +import { createTestUserService, TestUserSupertestProvider } from './test_user'; export async function SecurityServiceProvider(context: FtrProviderContext) { const { getService } = context; @@ -31,11 +31,13 @@ export async function SecurityServiceProvider(context: FtrProviderContext) { const role = new Role(log, kibanaServer); const user = new User(log, kibanaServer); const testUser = await createTestUserService(role, user, context); + const testUserSupertest = TestUserSupertestProvider(context); return new (class SecurityService { roleMappings = new RoleMappings(log, kibanaServer); testUser = testUser; role = role; user = user; + testUserSupertest = testUserSupertest; })(); } diff --git a/test/common/services/security/test_user.ts b/test/common/services/security/test_user.ts index ed786d6ff3c34..7183943591c88 100644 --- a/test/common/services/security/test_user.ts +++ b/test/common/services/security/test_user.ts @@ -16,12 +16,18 @@ * specific language governing permissions and limitations * under the License. */ +import { format as formatUrl } from 'url'; +import supertestAsPromised from 'supertest-as-promised'; + import { Role } from './role'; import { User } from './user'; import { FtrProviderContext } from '../../ftr_provider_context'; import { Browser } from '../../../functional/services/common'; import { TestSubjects } from '../../../functional/services/common'; +const TEST_USER_NAME = 'test_user'; +const TEST_USER_PASSWORD = 'changeme'; + export async function createTestUserService( role: Role, user: User, @@ -50,15 +56,15 @@ export async function createTestUserService( } try { // delete the test_user if present (will it error if the user doesn't exist?) - await user.delete('test_user'); + await user.delete(TEST_USER_NAME); } catch (exception) { log.debug('no test user to delete'); } // create test_user with username and pwd log.debug(`default roles = ${config.get('security.defaultRoles')}`); - await user.create('test_user', { - password: 'changeme', + await user.create(TEST_USER_NAME, { + password: TEST_USER_PASSWORD, roles: config.get('security.defaultRoles'), full_name: 'test user', }); @@ -74,8 +80,8 @@ export async function createTestUserService( async setRoles(roles: string[], shouldRefreshBrowser: boolean = true) { if (isEnabled()) { log.debug(`set roles = ${roles}`); - await user.create('test_user', { - password: 'changeme', + await user.create(TEST_USER_NAME, { + password: TEST_USER_PASSWORD, roles, full_name: 'test user', }); @@ -93,3 +99,15 @@ export async function createTestUserService( } })(); } + +export function TestUserSupertestProvider({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaServerConfig = config.get('servers.kibana'); + + return supertestAsPromised( + formatUrl({ + ...kibanaServerConfig, + auth: `${TEST_USER_NAME}:${TEST_USER_PASSWORD}`, + }) + ); +} diff --git a/test/functional/apps/dashboard/index.js b/test/functional/apps/dashboard/index.js index 5a30456bd59ab..de4b3df9c40ef 100644 --- a/test/functional/apps/dashboard/index.js +++ b/test/functional/apps/dashboard/index.js @@ -96,7 +96,9 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./dashboard_time_picker')); loadTestFile(require.resolve('./bwc_shared_urls')); - loadTestFile(require.resolve('./panel_controls')); + loadTestFile(require.resolve('./panel_replacing')); + loadTestFile(require.resolve('./panel_cloning')); + loadTestFile(require.resolve('./panel_context_menu')); loadTestFile(require.resolve('./dashboard_state')); }); diff --git a/test/functional/apps/dashboard/panel_cloning.ts b/test/functional/apps/dashboard/panel_cloning.ts new file mode 100644 index 0000000000000..0535b66f08a16 --- /dev/null +++ b/test/functional/apps/dashboard/panel_cloning.ts @@ -0,0 +1,80 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const PageObjects = getPageObjects([ + 'dashboard', + 'header', + 'visualize', + 'discover', + 'timePicker', + ]); + + describe('dashboard panel cloning', function viewEditModeTests() { + before(async function () { + await PageObjects.dashboard.initTests(); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.timePicker.setHistoricalDataRange(); + await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + }); + + after(async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('clones a panel', async () => { + const initialPanelTitles = await PageObjects.dashboard.getPanelTitles(); + await dashboardPanelActions.clonePanelByTitle(PIE_CHART_VIS_NAME); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + const postPanelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1); + }); + + it('appends a clone title tag', async () => { + const panelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(panelTitles[1]).to.equal(PIE_CHART_VIS_NAME + ' (copy)'); + }); + + it('retains original panel dimensions', async () => { + const panelDimensions = await PageObjects.dashboard.getPanelDimensions(); + expect(panelDimensions[0]).to.eql(panelDimensions[1]); + }); + + it('gives a correct title to the clone of a clone', async () => { + const initialPanelTitles = await PageObjects.dashboard.getPanelTitles(); + const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1]; + await dashboardPanelActions.clonePanelByTitle(clonedPanelName); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + const postPanelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1); + expect(postPanelTitles[postPanelTitles.length - 1]).to.equal( + PIE_CHART_VIS_NAME + ' (copy 1)' + ); + }); + }); +} diff --git a/test/functional/apps/dashboard/panel_context_menu.ts b/test/functional/apps/dashboard/panel_context_menu.ts new file mode 100644 index 0000000000000..0b9e873f46151 --- /dev/null +++ b/test/functional/apps/dashboard/panel_context_menu.ts @@ -0,0 +1,185 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { PIE_CHART_VIS_NAME } from '../../page_objects/dashboard_page'; +import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const PageObjects = getPageObjects([ + 'dashboard', + 'header', + 'visualize', + 'discover', + 'timePicker', + ]); + const dashboardName = 'Dashboard Panel Controls Test'; + + describe('dashboard panel context menu', function viewEditModeTests() { + before(async function () { + await PageObjects.dashboard.initTests(); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.timePicker.setHistoricalDataRange(); + await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + }); + + after(async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('are hidden in view mode', async function () { + await PageObjects.dashboard.saveDashboard(dashboardName); + + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.expectMissingEditPanelAction(); + await dashboardPanelActions.expectMissingRemovePanelAction(); + }); + + it('are shown in edit mode', async function () { + await PageObjects.dashboard.switchToEditMode(); + + const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible(); + expect(isContextMenuIconVisible).to.equal(true); + + await dashboardPanelActions.expectExistsEditPanelAction(); + await dashboardPanelActions.expectExistsClonePanelAction(); + await dashboardPanelActions.expectExistsReplacePanelAction(); + await dashboardPanelActions.expectExistsRemovePanelAction(); + await dashboardPanelActions.expectExistsToggleExpandAction(); + }); + + it('are shown in edit mode after a hard refresh', async () => { + // Based off an actual bug encountered in a PR where a hard refresh in + // edit mode did not show the edit mode controls. + const currentUrl = await browser.getCurrentUrl(); + // The second parameter of true will include the timestamp in the url and + // trigger a hard refresh. + await browser.get(currentUrl.toString(), true); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await dashboardPanelActions.expectExistsEditPanelAction(); + await dashboardPanelActions.expectExistsClonePanelAction(); + await dashboardPanelActions.expectExistsReplacePanelAction(); + await dashboardPanelActions.expectExistsRemovePanelAction(); + + // Get rid of the timestamp in the url. + await browser.get(currentUrl.toString(), false); + }); + + describe('visualization object edit menu', () => { + it('opens a visualization when edit link is clicked', async () => { + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH); + }); + + it('deletes the visualization when delete link is clicked', async () => { + await PageObjects.header.clickDashboard(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.removePanel(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.be(0); + }); + }); + + describe('saved search object edit menu', () => { + const searchName = 'my search'; + + before(async () => { + await PageObjects.header.clickDiscover(); + await PageObjects.discover.clickNewSearchButton(); + await dashboardVisualizations.createSavedSearch({ name: searchName, fields: ['bytes'] }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.header.clickDashboard(); + + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.addSavedSearch(searchName); + }); + + it('should be one panel on dashboard', async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.be(1); + }); + + it('opens a saved search when edit link is clicked', async () => { + await dashboardPanelActions.clickEdit(); + await PageObjects.header.waitUntilLoadingHasFinished(); + const queryName = await PageObjects.discover.getCurrentQueryName(); + expect(queryName).to.be(searchName); + }); + + it('deletes the saved search when delete link is clicked', async () => { + await PageObjects.header.clickDashboard(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.removePanel(); + + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.be(0); + }); + }); + + describe('on an expanded panel', function () { + before('reset dashboard', async () => { + const currentUrl = await browser.getCurrentUrl(); + await browser.get(currentUrl.toString(), false); + }); + + before('and add one panel and save to put dashboard in "view" mode', async () => { + await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + await PageObjects.dashboard.saveDashboard(dashboardName); + }); + + before('expand panel to "full screen"', async () => { + await dashboardPanelActions.clickExpandPanelToggle(); + }); + + it('context menu actions are hidden in view mode', async function () { + await dashboardPanelActions.expectMissingEditPanelAction(); + await dashboardPanelActions.expectMissingDuplicatePanelAction(); + await dashboardPanelActions.expectMissingReplacePanelAction(); + await dashboardPanelActions.expectMissingRemovePanelAction(); + }); + + describe('in edit mode', () => { + it('switch to edit mode', async function () { + await PageObjects.dashboard.switchToEditMode(); + }); + + it('some context menu actions should be present', async function () { + await dashboardPanelActions.expectExistsEditPanelAction(); + await dashboardPanelActions.expectExistsClonePanelAction(); + await dashboardPanelActions.expectExistsReplacePanelAction(); + }); + + it('"remove panel" action should not be present', async function () { + await dashboardPanelActions.expectMissingRemovePanelAction(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/dashboard/panel_controls.js b/test/functional/apps/dashboard/panel_controls.js deleted file mode 100644 index 748e9fdc5f19d..0000000000000 --- a/test/functional/apps/dashboard/panel_controls.js +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; - -import { - PIE_CHART_VIS_NAME, - AREA_CHART_VIS_NAME, - LINE_CHART_VIS_NAME, -} from '../../page_objects/dashboard_page'; -import { VisualizeConstants } from '../../../../src/plugins/visualize/public/application/visualize_constants'; - -export default function ({ getService, getPageObjects }) { - const browser = getService('browser'); - const dashboardPanelActions = getService('dashboardPanelActions'); - const dashboardAddPanel = getService('dashboardAddPanel'); - const dashboardReplacePanel = getService('dashboardReplacePanel'); - const dashboardVisualizations = getService('dashboardVisualizations'); - const renderable = getService('renderable'); - const PageObjects = getPageObjects([ - 'dashboard', - 'header', - 'visualize', - 'discover', - 'timePicker', - ]); - const dashboardName = 'Dashboard Panel Controls Test'; - - describe('dashboard panel controls', function viewEditModeTests() { - before(async function () { - await PageObjects.dashboard.initTests(); - await PageObjects.dashboard.preserveCrossAppState(); - }); - - after(async function () { - await PageObjects.dashboard.gotoDashboardLandingPage(); - }); - - describe('visualization object replace flyout', () => { - let intialDimensions; - before(async () => { - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.timePicker.setHistoricalDataRange(); - await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); - await dashboardAddPanel.addVisualization(LINE_CHART_VIS_NAME); - intialDimensions = await PageObjects.dashboard.getPanelDimensions(); - }); - - after(async function () { - await PageObjects.dashboard.gotoDashboardLandingPage(); - }); - - it('replaces old panel with selected panel', async () => { - await dashboardPanelActions.replacePanelByTitle(PIE_CHART_VIS_NAME); - await dashboardReplacePanel.replaceEmbeddable(AREA_CHART_VIS_NAME); - await PageObjects.header.waitUntilLoadingHasFinished(); - const panelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(panelTitles.length).to.be(2); - expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME); - }); - - it('replaces selected visualization with old dimensions', async () => { - const newDimensions = await PageObjects.dashboard.getPanelDimensions(); - expect(intialDimensions[0]).to.eql(newDimensions[0]); - }); - - it('replaced panel persisted correctly when dashboard is hard refreshed', async () => { - const currentUrl = await browser.getCurrentUrl(); - await browser.get(currentUrl, true); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - const panelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(panelTitles.length).to.be(2); - expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME); - }); - - it('replaced panel with saved search', async () => { - const replacedSearch = 'replaced saved search'; - await dashboardVisualizations.createSavedSearch({ - name: replacedSearch, - fields: ['bytes', 'agent'], - }); - await PageObjects.header.clickDashboard(); - const inViewMode = await PageObjects.dashboard.getIsInViewMode(); - if (inViewMode) { - await PageObjects.dashboard.switchToEditMode(); - } - await dashboardPanelActions.replacePanelByTitle(AREA_CHART_VIS_NAME); - await dashboardReplacePanel.replaceEmbeddable(replacedSearch, 'search'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - const panelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(panelTitles.length).to.be(2); - expect(panelTitles[0]).to.be(replacedSearch); - }); - }); - - describe('panel cloning', function () { - before(async () => { - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.timePicker.setHistoricalDataRange(); - await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); - }); - - after(async function () { - await PageObjects.dashboard.gotoDashboardLandingPage(); - }); - - it('clones a panel', async () => { - const initialPanelTitles = await PageObjects.dashboard.getPanelTitles(); - await dashboardPanelActions.clonePanelByTitle(PIE_CHART_VIS_NAME); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - const postPanelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1); - }); - - it('appends a clone title tag', async () => { - const panelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(panelTitles[1]).to.equal(PIE_CHART_VIS_NAME + ' (copy)'); - }); - - it('retains original panel dimensions', async () => { - const panelDimensions = await PageObjects.dashboard.getPanelDimensions(); - expect(panelDimensions[0]).to.eql(panelDimensions[1]); - }); - - it('gives a correct title to the clone of a clone', async () => { - const initialPanelTitles = await PageObjects.dashboard.getPanelTitles(); - const clonedPanelName = initialPanelTitles[initialPanelTitles.length - 1]; - await dashboardPanelActions.clonePanelByTitle(clonedPanelName); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.dashboard.waitForRenderComplete(); - const postPanelTitles = await PageObjects.dashboard.getPanelTitles(); - expect(postPanelTitles.length).to.equal(initialPanelTitles.length + 1); - expect(postPanelTitles[postPanelTitles.length - 1]).to.equal( - PIE_CHART_VIS_NAME + ' (copy 1)' - ); - }); - }); - - describe('panel edit controls', function () { - before(async () => { - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.timePicker.setHistoricalDataRange(); - await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); - }); - - it('are hidden in view mode', async function () { - await PageObjects.dashboard.saveDashboard(dashboardName); - - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.expectMissingEditPanelAction(); - await dashboardPanelActions.expectMissingRemovePanelAction(); - }); - - it('are shown in edit mode', async function () { - await PageObjects.dashboard.switchToEditMode(); - - const isContextMenuIconVisible = await dashboardPanelActions.isContextMenuIconVisible(); - expect(isContextMenuIconVisible).to.equal(true); - await dashboardPanelActions.openContextMenu(); - - await dashboardPanelActions.expectExistsEditPanelAction(); - await dashboardPanelActions.expectExistsReplacePanelAction(); - await dashboardPanelActions.expectExistsDuplicatePanelAction(); - await dashboardPanelActions.expectExistsRemovePanelAction(); - }); - - // Based off an actual bug encountered in a PR where a hard refresh in edit mode did not show the edit mode - // controls. - it('are shown in edit mode after a hard refresh', async () => { - const currentUrl = await browser.getCurrentUrl(); - // the second parameter of true will include the timestamp in the url and trigger a hard refresh. - await browser.get(currentUrl.toString(), true); - await PageObjects.header.waitUntilLoadingHasFinished(); - - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.expectExistsEditPanelAction(); - await dashboardPanelActions.expectExistsReplacePanelAction(); - await dashboardPanelActions.expectExistsDuplicatePanelAction(); - await dashboardPanelActions.expectExistsRemovePanelAction(); - - // Get rid of the timestamp in the url. - await browser.get(currentUrl.toString(), false); - }); - - describe('on an expanded panel', function () { - it('are hidden in view mode', async function () { - await renderable.waitForRender(); - await PageObjects.dashboard.saveDashboard(dashboardName); - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.clickExpandPanelToggle(); - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.expectMissingEditPanelAction(); - await dashboardPanelActions.expectMissingReplacePanelAction(); - await dashboardPanelActions.expectMissingDuplicatePanelAction(); - await dashboardPanelActions.expectMissingRemovePanelAction(); - }); - - it('in edit mode hides remove icons ', async function () { - await PageObjects.dashboard.switchToEditMode(); - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.expectExistsEditPanelAction(); - await dashboardPanelActions.expectExistsReplacePanelAction(); - await dashboardPanelActions.expectExistsDuplicatePanelAction(); - await dashboardPanelActions.expectMissingRemovePanelAction(); - await dashboardPanelActions.clickExpandPanelToggle(); - }); - }); - - describe('visualization object edit menu', () => { - it('opens a visualization when edit link is clicked', async () => { - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.clickEdit(); - await PageObjects.header.waitUntilLoadingHasFinished(); - const currentUrl = await browser.getCurrentUrl(); - expect(currentUrl).to.contain(VisualizeConstants.EDIT_PATH); - }); - - it('deletes the visualization when delete link is clicked', async () => { - await PageObjects.header.clickDashboard(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await dashboardPanelActions.removePanel(); - - const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.be(0); - }); - }); - - describe('saved search object edit menu', () => { - const searchName = 'my search'; - before(async () => { - await PageObjects.header.clickDiscover(); - await PageObjects.discover.clickNewSearchButton(); - await dashboardVisualizations.createSavedSearch({ name: searchName, fields: ['bytes'] }); - await PageObjects.header.waitUntilLoadingHasFinished(); - await PageObjects.header.clickDashboard(); - const inViewMode = await PageObjects.dashboard.getIsInViewMode(); - if (inViewMode) { - await PageObjects.dashboard.switchToEditMode(); - } - await dashboardAddPanel.addSavedSearch(searchName); - - const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.be(1); - }); - - it('opens a saved search when edit link is clicked', async () => { - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.clickEdit(); - await PageObjects.header.waitUntilLoadingHasFinished(); - const queryName = await PageObjects.discover.getCurrentQueryName(); - expect(queryName).to.be(searchName); - }); - - it('deletes the saved search when delete link is clicked', async () => { - await PageObjects.header.clickDashboard(); - await PageObjects.header.waitUntilLoadingHasFinished(); - await dashboardPanelActions.removePanel(); - - const panelCount = await PageObjects.dashboard.getPanelCount(); - expect(panelCount).to.be(0); - }); - }); - }); - - // Panel expand should also be shown in view mode, but only on mouse hover. - describe('panel expand control', function () { - it('shown in edit mode', async function () { - await PageObjects.dashboard.gotoDashboardEditMode(dashboardName); - await dashboardPanelActions.openContextMenu(); - await dashboardPanelActions.expectExistsToggleExpandAction(); - }); - }); - }); -} diff --git a/test/functional/apps/dashboard/panel_replacing.ts b/test/functional/apps/dashboard/panel_replacing.ts new file mode 100644 index 0000000000000..6bf3dbbe47b1d --- /dev/null +++ b/test/functional/apps/dashboard/panel_replacing.ts @@ -0,0 +1,100 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { + PIE_CHART_VIS_NAME, + AREA_CHART_VIS_NAME, + LINE_CHART_VIS_NAME, +} from '../../page_objects/dashboard_page'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const dashboardReplacePanel = getService('dashboardReplacePanel'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const PageObjects = getPageObjects([ + 'dashboard', + 'header', + 'visualize', + 'discover', + 'timePicker', + ]); + + describe('replace dashboard panels', function viewEditModeTests() { + let intialDimensions: undefined | Array<{ width: number; height: number }>; + + before(async function () { + await PageObjects.dashboard.initTests(); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.timePicker.setHistoricalDataRange(); + await dashboardAddPanel.addVisualization(PIE_CHART_VIS_NAME); + await dashboardAddPanel.addVisualization(LINE_CHART_VIS_NAME); + intialDimensions = await PageObjects.dashboard.getPanelDimensions(); + }); + + after(async function () { + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + it('replaces old panel with selected panel', async () => { + await dashboardPanelActions.replacePanelByTitle(PIE_CHART_VIS_NAME); + await dashboardReplacePanel.replaceEmbeddable(AREA_CHART_VIS_NAME); + await PageObjects.header.waitUntilLoadingHasFinished(); + const panelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(panelTitles.length).to.be(2); + expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME); + const newDimensions = await PageObjects.dashboard.getPanelDimensions(); + expect(intialDimensions![0]).to.eql(newDimensions[0]); + }); + + it('replaced panel persisted correctly when dashboard is hard refreshed', async () => { + const currentUrl = await browser.getCurrentUrl(); + await browser.get(currentUrl, true); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + const panelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(panelTitles.length).to.be(2); + expect(panelTitles[0]).to.be(AREA_CHART_VIS_NAME); + }); + + it('replaced panel with saved search', async () => { + const replacedSearch = 'replaced saved search'; + await dashboardVisualizations.createSavedSearch({ + name: replacedSearch, + fields: ['bytes', 'agent'], + }); + await PageObjects.header.clickDashboard(); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + await dashboardPanelActions.replacePanelByTitle(AREA_CHART_VIS_NAME); + await dashboardReplacePanel.replaceEmbeddable(replacedSearch, 'search'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.dashboard.waitForRenderComplete(); + const panelTitles = await PageObjects.dashboard.getPanelTitles(); + expect(panelTitles.length).to.be(2); + expect(panelTitles[0]).to.be(replacedSearch); + }); + }); +} diff --git a/test/functional/apps/discover/_errors.js b/test/functional/apps/discover/_errors.ts similarity index 92% rename from test/functional/apps/discover/_errors.js rename to test/functional/apps/discover/_errors.ts index 614059dc8ac94..9520d652a65d5 100644 --- a/test/functional/apps/discover/_errors.js +++ b/test/functional/apps/discover/_errors.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 5a224d930ee42..7a99509257bf7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -254,7 +254,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getSidebarWidth() { - const sidebar = await find.byCssSelector('.sidebar-list'); + const sidebar = await testSubjects.find('discover-sidebar'); return await sidebar.getAttribute('clientWidth'); } diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index e8f8982d7163c..c12c633926c1c 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -43,6 +43,14 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont return !(await testSubjects.exists(`addSampleDataSet${id}`)); } + async getVisibileSolutions() { + const solutionPanels = await testSubjects.findAll('~homSolutionPanel', 2000); + const panelAttributes = await Promise.all( + solutionPanels.map((panel) => panel.getAttribute('data-test-subj')) + ); + return panelAttributes.map((attributeValue) => attributeValue.split('homSolutionPanel_')[1]); + } + async addSampleDataSet(id: string) { const isInstalled = await this.isSampleDataSetInstalled(id); if (!isInstalled) { diff --git a/test/functional/page_objects/newsfeed_page.ts b/test/functional/page_objects/newsfeed_page.ts index 52267138819ab..6e5e801589295 100644 --- a/test/functional/page_objects/newsfeed_page.ts +++ b/test/functional/page_objects/newsfeed_page.ts @@ -21,6 +21,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function NewsfeedPageProvider({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); + const find = getService('find'); const retry = getService('retry'); const flyout = getService('flyout'); const testSubjects = getService('testSubjects'); @@ -49,7 +50,7 @@ export function NewsfeedPageProvider({ getService, getPageObjects }: FtrProvider } async getRedButtonSign() { - return await testSubjects.exists('showBadgeNews'); + return await find.existsByCssSelector('.euiHeaderSectionItemButton__notification--dot'); } async getNewsfeedList() { diff --git a/test/functional/services/dashboard/panel_actions.ts b/test/functional/services/dashboard/panel_actions.ts index bc21a62b9df79..2cea377d327e1 100644 --- a/test/functional/services/dashboard/panel_actions.ts +++ b/test/functional/services/dashboard/panel_actions.ts @@ -59,12 +59,32 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft async openContextMenu(parent?: WebElementWrapper) { log.debug(`openContextMenu(${parent}`); + if (await testSubjects.exists('embeddablePanelContextMenuOpen')) return; await this.toggleContextMenu(parent); await this.expectContextMenuToBeOpen(); } + async hasContextMenuMoreItem() { + return await testSubjects.exists('embeddablePanelMore-mainMenu'); + } + + async clickContextMenuMoreItem() { + const hasMoreSubPanel = await testSubjects.exists('embeddablePanelMore-mainMenu'); + if (hasMoreSubPanel) { + await testSubjects.click('embeddablePanelMore-mainMenu'); + } + } + + async openContextMenuMorePanel(parent?: WebElementWrapper) { + await this.openContextMenu(parent); + await this.clickContextMenuMoreItem(); + } + async clickEdit() { log.debug('clickEdit'); + await this.openContextMenu(); + const isActionVisible = await testSubjects.exists(EDIT_PANEL_DATA_TEST_SUBJ); + if (!isActionVisible) await this.clickContextMenuMoreItem(); await testSubjects.clickWhenNotDisabled(EDIT_PANEL_DATA_TEST_SUBJ); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.common.waitForTopNavToBeVisible(); @@ -82,18 +102,28 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft } async clickExpandPanelToggle() { + log.debug(`clickExpandPanelToggle`); + this.openContextMenu(); + const isActionVisible = await testSubjects.exists(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); + if (!isActionVisible) await this.clickContextMenuMoreItem(); await testSubjects.click(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); } async removePanel() { log.debug('removePanel'); await this.openContextMenu(); + const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); + if (!isActionVisible) await this.clickContextMenuMoreItem(); + const isPanelActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); + if (!isPanelActionVisible) await this.clickContextMenuMoreItem(); await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ); } async removePanelByTitle(title: string) { const header = await this.getPanelHeading(title); await this.openContextMenu(header); + const isActionVisible = await testSubjects.exists(REMOVE_PANEL_DATA_TEST_SUBJ); + if (!isActionVisible) await this.clickContextMenuMoreItem(); await testSubjects.click(REMOVE_PANEL_DATA_TEST_SUBJ); } @@ -110,6 +140,10 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft } else { await this.openContextMenu(); } + const actionExists = await testSubjects.exists(REPLACE_PANEL_DATA_TEST_SUBJ); + if (!actionExists) { + await this.clickContextMenuMoreItem(); + } await testSubjects.click(REPLACE_PANEL_DATA_TEST_SUBJ); } @@ -131,52 +165,78 @@ export function DashboardPanelActionsProvider({ getService, getPageObjects }: Ft async openInspector(parent: WebElementWrapper) { await this.openContextMenu(parent); + const exists = await testSubjects.exists(OPEN_INSPECTOR_TEST_SUBJ); + if (!exists) { + await this.clickContextMenuMoreItem(); + } await testSubjects.click(OPEN_INSPECTOR_TEST_SUBJ); } async expectExistsRemovePanelAction() { log.debug('expectExistsRemovePanelAction'); - await testSubjects.existOrFail(REMOVE_PANEL_DATA_TEST_SUBJ); + await this.expectExistsPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ); } - async expectMissingRemovePanelAction() { - log.debug('expectMissingRemovePanelAction'); - await testSubjects.missingOrFail(REMOVE_PANEL_DATA_TEST_SUBJ); + async expectExistsPanelAction(testSubject: string) { + log.debug('expectExistsPanelAction', testSubject); + await this.openContextMenu(); + if (await testSubjects.exists(CLONE_PANEL_DATA_TEST_SUBJ)) return; + if (await this.hasContextMenuMoreItem()) { + await this.clickContextMenuMoreItem(); + } + await testSubjects.existOrFail(CLONE_PANEL_DATA_TEST_SUBJ); + await this.toggleContextMenu(); + } + + async expectMissingPanelAction(testSubject: string) { + log.debug('expectMissingPanelAction', testSubject); + await this.openContextMenu(); + await testSubjects.missingOrFail(testSubject); + if (await this.hasContextMenuMoreItem()) { + await this.clickContextMenuMoreItem(); + await testSubjects.missingOrFail(testSubject); + } + await this.toggleContextMenu(); } async expectExistsEditPanelAction() { log.debug('expectExistsEditPanelAction'); - await testSubjects.existOrFail(EDIT_PANEL_DATA_TEST_SUBJ); + await this.expectExistsPanelAction(EDIT_PANEL_DATA_TEST_SUBJ); } async expectExistsReplacePanelAction() { log.debug('expectExistsReplacePanelAction'); - await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ); + await this.expectExistsPanelAction(REPLACE_PANEL_DATA_TEST_SUBJ); } - async expectExistsDuplicatePanelAction() { - log.debug('expectExistsDuplicatePanelAction'); - await testSubjects.existOrFail(REPLACE_PANEL_DATA_TEST_SUBJ); + async expectExistsClonePanelAction() { + log.debug('expectExistsClonePanelAction'); + await this.expectExistsPanelAction(CLONE_PANEL_DATA_TEST_SUBJ); } async expectMissingEditPanelAction() { log.debug('expectMissingEditPanelAction'); - await testSubjects.missingOrFail(EDIT_PANEL_DATA_TEST_SUBJ); + await this.expectMissingPanelAction(EDIT_PANEL_DATA_TEST_SUBJ); } async expectMissingReplacePanelAction() { log.debug('expectMissingReplacePanelAction'); - await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ); + await this.expectMissingPanelAction(REPLACE_PANEL_DATA_TEST_SUBJ); } async expectMissingDuplicatePanelAction() { log.debug('expectMissingDuplicatePanelAction'); - await testSubjects.missingOrFail(REPLACE_PANEL_DATA_TEST_SUBJ); + await this.expectMissingPanelAction(CLONE_PANEL_DATA_TEST_SUBJ); + } + + async expectMissingRemovePanelAction() { + log.debug('expectMissingRemovePanelAction'); + await this.expectMissingPanelAction(REMOVE_PANEL_DATA_TEST_SUBJ); } async expectExistsToggleExpandAction() { log.debug('expectExistsToggleExpandAction'); - await testSubjects.existOrFail(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); + await this.expectExistsPanelAction(TOGGLE_EXPAND_PANEL_DATA_TEST_SUBJ); } async getPanelHeading(title: string) { diff --git a/test/functional/services/dashboard/replace_panel.ts b/test/functional/services/dashboard/replace_panel.ts index d1cb4e5e697a1..2abc9cf23b72e 100644 --- a/test/functional/services/dashboard/replace_panel.ts +++ b/test/functional/services/dashboard/replace_panel.ts @@ -73,7 +73,7 @@ export function DashboardReplacePanelProvider({ getService }: FtrProviderContext return this.replaceEmbeddable(vizName, 'visualization'); } - async replaceEmbeddable(embeddableName: string, embeddableType: string) { + async replaceEmbeddable(embeddableName: string, embeddableType?: string) { log.debug( `DashboardReplacePanel.replaceEmbeddable, name: ${embeddableName}, type: ${embeddableType}` ); diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index 7f3744c16397a..87a1bc20920a4 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json index acb0cf67ac5c7..8bbf6274bd15f 100644 --- a/test/plugin_functional/plugins/kbn_sample_panel_action/package.json +++ b/test/plugin_functional/plugins/kbn_sample_panel_action/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "react": "^16.12.0", "typescript": "4.0.2" } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index ff84c25400af0..c0d9a03d02c32 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -12,7 +12,7 @@ "build": "rm -rf './target' && tsc" }, "devDependencies": { - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "@kbn/plugin-helpers": "1.0.0", "react": "^16.12.0", "typescript": "4.0.2" diff --git a/test/scripts/jenkins_visual_regression.sh b/test/scripts/jenkins_baseline.sh similarity index 63% rename from test/scripts/jenkins_visual_regression.sh rename to test/scripts/jenkins_baseline.sh index 17345d4301882..e679ac7f31bd1 100755 --- a/test/scripts/jenkins_visual_regression.sh +++ b/test/scripts/jenkins_baseline.sh @@ -9,10 +9,3 @@ linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" mkdir -p "$installDir" tar -xzf "$linuxBuild" -C "$installDir" --strip=1 - -echo " -> running visual regression tests from kibana directory" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_baseline.sh similarity index 64% rename from test/scripts/jenkins_xpack_visual_regression.sh rename to test/scripts/jenkins_xpack_baseline.sh index 55d4a524820c5..7577b6927d166 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -14,16 +14,5 @@ tar -xzf "$linuxBuild" -C "$installDir" --strip=1 mkdir -p "$WORKSPACE/kibana-build-xpack" cp -pR install/kibana/. $WORKSPACE/kibana-build-xpack/ -# cd "$KIBANA_DIR" -# source "test/scripts/jenkins_xpack_page_load_metrics.sh" - cd "$KIBANA_DIR" source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" - -echo " -> running visual regression tests from x-pack directory" -cd "$XPACK_DIR" -yarn percy exec -t 10000 -- -- \ - node scripts/functional_tests \ - --debug --bail \ - --kibana-install-dir "$installDir" \ - --config test/visual_regression/config.ts; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index e5b39584a519b..28eb94405abbb 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -1,4 +1,4 @@ -def withPostBuildReporting(Closure closure) { +def withPostBuildReporting(Map params, Closure closure) { try { closure() } finally { @@ -9,8 +9,10 @@ def withPostBuildReporting(Closure closure) { print ex } - catchErrors { - runErrorReporter([pwd()] + parallelWorkspaces) + if (params.runErrorReporter) { + catchErrors { + runErrorReporter([pwd()] + parallelWorkspaces) + } } catchErrors { diff --git a/vars/workers.groovy b/vars/workers.groovy index e582e996a78b5..b6ff5b27667dd 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -118,11 +118,11 @@ def base(Map params, Closure closure) { // Worker for ci processes. Extends the base worker and adds GCS artifact upload, error reporting, junit processing def ci(Map params, Closure closure) { - def config = [ramDisk: true, bootstrapped: true] + params + def config = [ramDisk: true, bootstrapped: true, runErrorReporter: true] + params return base(config) { kibanaPipeline.withGcsArtifactUpload(config.name) { - kibanaPipeline.withPostBuildReporting { + kibanaPipeline.withPostBuildReporting(config) { closure() } } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index bdd0fbea35fa8..a700781438706 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -49,7 +49,7 @@ "xpack.server": "legacy/server", "xpack.securitySolution": "plugins/security_solution", "xpack.snapshotRestore": "plugins/snapshot_restore", - "xpack.spaces": ["legacy/plugins/spaces", "plugins/spaces"], + "xpack.spaces": "plugins/spaces", "xpack.taskManager": "legacy/plugins/task_manager", "xpack.transform": "plugins/transform", "xpack.triggersActionsUI": "plugins/triggers_actions_ui", diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index a693e008db6ea..e6f160ce8c654 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -18,7 +18,6 @@ export function createJestConfig({ kibanaDirectory, rootDir, xPackKibanaDirector '^src/core/(.*)': `${kibanaDirectory}/src/core/$1`, '^src/legacy/(.*)': `${kibanaDirectory}/src/legacy/$1`, '^src/plugins/(.*)': `${kibanaDirectory}/src/plugins/$1`, - '^plugins/([^/.]*)(.*)': `${kibanaDirectory}/src/legacy/core_plugins/$1/public$2`, '^legacy/plugins/xpack_main/(.*);': `${xPackKibanaDirectory}/legacy/plugins/xpack_main/public/$1`, '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': fileMockPath, '\\.module.(css|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/css_module_mock.js`, diff --git a/x-pack/examples/ui_actions_enhanced_examples/.eslintrc.json b/x-pack/examples/ui_actions_enhanced_examples/.eslintrc.json new file mode 100644 index 0000000000000..2aab6c2d9093b --- /dev/null +++ b/x-pack/examples/ui_actions_enhanced_examples/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "@typescript-eslint/consistent-type-definitions": 0 + } +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx index fd782f5468c85..cac5f0b29dc6e 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -17,9 +17,9 @@ import { export type ActionContext = ChartActionContext; -export interface Config { +export type Config = { name: string; -} +}; const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx index 7394690a61eae..fa2f0825f9335 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_only_range_select_drilldown/index.tsx @@ -13,9 +13,9 @@ import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/publ import { SELECT_RANGE_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; import { BaseActionFactoryContext } from '../../../../plugins/ui_actions_enhanced/public/dynamic_actions'; -export interface Config { +export type Config = { name: string; -} +}; const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT'; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts index a10e8ad707e97..692de571e8a00 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -9,7 +9,7 @@ import { ApplyGlobalFilterActionContext } from '../../../../../src/plugins/data/ export type ActionContext = ApplyGlobalFilterActionContext; -export interface Config { +export type Config = { /** * Whether to use a user selected index pattern, stored in `indexPatternId` field. */ @@ -30,6 +30,6 @@ export interface Config { * Whether to carry over source dashboard time range. */ carryTimeRange: boolean; -} +}; export type CollectConfigProps = CollectConfigPropsBase; diff --git a/x-pack/index.js b/x-pack/index.js index 074b8e6859dc2..cb68004c26d65 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -5,9 +5,7 @@ */ import { xpackMain } from './legacy/plugins/xpack_main'; -import { security } from './legacy/plugins/security'; -import { spaces } from './legacy/plugins/spaces'; module.exports = function (kibana) { - return [xpackMain(kibana), spaces(kibana), security(kibana)]; + return [xpackMain(kibana)]; }; diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts deleted file mode 100644 index c3596d3745e57..0000000000000 --- a/x-pack/legacy/plugins/security/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Root } from 'joi'; -import { resolve } from 'path'; - -export const security = (kibana: Record) => - new kibana.Plugin({ - id: 'security', - publicDir: resolve(__dirname, 'public'), - require: ['elasticsearch'], - configPrefix: 'xpack.security', - config: (Joi: Root) => - Joi.object({ enabled: Joi.boolean().default(true) }) - .unknown() - .default(), - init() {}, - }); diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts deleted file mode 100644 index 725d022879e0d..0000000000000 --- a/x-pack/legacy/plugins/spaces/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { resolve } from 'path'; -import KbnServer, { Server } from 'src/legacy/server/kbn_server'; -import { Legacy } from 'kibana'; -import { KibanaRequest } from '../../../../src/core/server'; -import { SpacesPluginSetup } from '../../../plugins/spaces/server'; -import { wrapError } from './server/lib/errors'; - -export const spaces = (kibana: Record) => - new kibana.Plugin({ - id: 'spaces', - configPrefix: 'xpack.spaces', - publicDir: resolve(__dirname, 'public'), - require: ['elasticsearch', 'xpack_main'], - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - }) - .unknown() - .default(); - }, - uiExports: { - injectDefaultVars(server: Server) { - return { - serverBasePath: server.config().get('server.basePath'), - activeSpace: null, - }; - }, - async replaceInjectedVars( - vars: Record, - request: Legacy.Request, - server: Server - ) { - // NOTICE: use of `activeSpace` is deprecated and will not be made available in the New Platform. - // Known usages: - // - x-pack/plugins/infra/public/utils/use_kibana_space_id.ts - const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup; - if (!spacesPlugin) { - throw new Error('New Platform XPack Spaces plugin is not available.'); - } - const kibanaRequest = KibanaRequest.from(request); - const spaceId = spacesPlugin.spacesService.getSpaceId(kibanaRequest); - const spacesClient = await spacesPlugin.spacesService.scopedClient(kibanaRequest); - try { - vars.activeSpace = { - valid: true, - space: await spacesClient.get(spaceId), - }; - } catch (e) { - vars.activeSpace = { - valid: false, - error: wrapError(e).output.payload, - }; - } - - return vars; - }, - }, - - async init(server: Server) { - const kbnServer = (server as unknown) as KbnServer; - - const spacesPlugin = kbnServer.newPlatform.setup.plugins.spaces as SpacesPluginSetup; - if (!spacesPlugin) { - throw new Error('New Platform XPack Spaces plugin is not available.'); - } - - server.expose('getSpaceId', (request: Legacy.Request) => - spacesPlugin.spacesService.getSpaceId(request) - ); - server.expose('getActiveSpace', (request: Legacy.Request) => - spacesPlugin.spacesService.getActiveSpace(request) - ); - server.expose('spaceIdToNamespace', spacesPlugin.spacesService.spaceIdToNamespace); - server.expose('namespaceToSpaceId', spacesPlugin.spacesService.namespaceToSpaceId); - server.expose('getBasePath', spacesPlugin.spacesService.getBasePath); - }, - }); diff --git a/x-pack/legacy/plugins/xpack_main/index.js b/x-pack/legacy/plugins/xpack_main/index.js index 854fba6624719..a3bd66e744fda 100644 --- a/x-pack/legacy/plugins/xpack_main/index.js +++ b/x-pack/legacy/plugins/xpack_main/index.js @@ -5,16 +5,15 @@ */ import { resolve } from 'path'; -import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; import { setupXPackMain } from './server/lib/setup_xpack_main'; -import { xpackInfoRoute, settingsRoute } from './server/routes/api/v1'; +import { xpackInfoRoute } from './server/routes/api/v1'; export const xpackMain = (kibana) => { return new kibana.Plugin({ id: 'xpack_main', configPrefix: 'xpack.xpack_main', publicDir: resolve(__dirname, 'public'), - require: ['elasticsearch'], + require: [], config(Joi) { return Joi.object({ @@ -23,13 +22,10 @@ export const xpackMain = (kibana) => { }, init(server) { - mirrorPluginStatus(server.plugins.elasticsearch, this, 'yellow', 'red'); - setupXPackMain(server); // register routes xpackInfoRoute(server); - settingsRoute(server, this.kbnServer); }, }); }; diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/setup_xpack_main.js b/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/setup_xpack_main.js index c34e27642d2ce..f49f44bed97a7 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/setup_xpack_main.js +++ b/x-pack/legacy/plugins/xpack_main/server/lib/__tests__/setup_xpack_main.js @@ -13,33 +13,35 @@ describe('setupXPackMain()', () => { const sandbox = sinon.createSandbox(); let mockServer; + let mockStatusObservable; let mockElasticsearchPlugin; - let mockXPackMainPlugin; beforeEach(() => { sandbox.useFakeTimers(); mockElasticsearchPlugin = { getCluster: sinon.stub(), - status: sinon.stub({ - on() {}, - }), }; - mockXPackMainPlugin = { - status: sinon.stub({ - green() {}, - red() {}, - }), - }; + mockStatusObservable = sinon.stub({ subscribe() {} }); mockServer = sinon.stub({ plugins: { elasticsearch: mockElasticsearchPlugin, - xpack_main: mockXPackMainPlugin, }, newPlatform: { - setup: { plugins: { features: {}, licensing: { license$: new BehaviorSubject() } } }, + setup: { + core: { + status: { + core$: { + pipe() { + return mockStatusObservable; + }, + }, + }, + }, + plugins: { features: {}, licensing: { license$: new BehaviorSubject() } }, + }, }, events: { on() {} }, log() {}, @@ -61,55 +63,6 @@ describe('setupXPackMain()', () => { setupXPackMain(mockServer); sinon.assert.calledWithExactly(mockServer.expose, 'info', sinon.match.instanceOf(XPackInfo)); - sinon.assert.calledWithExactly(mockElasticsearchPlugin.status.on, 'change', sinon.match.func); - }); - - describe('Elasticsearch plugin state changes cause XPackMain plugin state change.', () => { - let xPackInfo; - let onElasticsearchPluginStatusChange; - beforeEach(() => { - setupXPackMain(mockServer); - - onElasticsearchPluginStatusChange = mockElasticsearchPlugin.status.on.withArgs('change') - .firstCall.args[1]; - xPackInfo = mockServer.expose.firstCall.args[1]; - }); - - it('if `XPackInfo` is available status will become `green`.', async () => { - sinon.stub(xPackInfo, 'isAvailable').returns(false); - // We need this to make sure the code waits for `refreshNow` to complete before it tries - // to access its properties. - sinon.stub(xPackInfo, 'refreshNow').callsFake(() => { - return new Promise((resolve) => { - xPackInfo.isAvailable.returns(true); - resolve(); - }); - }); - - await onElasticsearchPluginStatusChange(); - - sinon.assert.calledWithExactly(mockXPackMainPlugin.status.green, 'Ready'); - sinon.assert.notCalled(mockXPackMainPlugin.status.red); - }); - - it('if `XPackInfo` is not available status will become `red`.', async () => { - sinon.stub(xPackInfo, 'isAvailable').returns(true); - sinon.stub(xPackInfo, 'unavailableReason').returns(''); - - // We need this to make sure the code waits for `refreshNow` to complete before it tries - // to access its properties. - sinon.stub(xPackInfo, 'refreshNow').callsFake(() => { - return new Promise((resolve) => { - xPackInfo.isAvailable.returns(false); - xPackInfo.unavailableReason.returns('Some weird error.'); - resolve(); - }); - }); - - await onElasticsearchPluginStatusChange(); - - sinon.assert.calledWithExactly(mockXPackMainPlugin.status.red, 'Some weird error.'); - sinon.assert.notCalled(mockXPackMainPlugin.status.green); - }); + sinon.assert.calledWithExactly(mockStatusObservable.subscribe, sinon.match.func); }); }); diff --git a/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js b/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js index 33b551bbe864f..fd4e3c86d0ca7 100644 --- a/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js +++ b/x-pack/legacy/plugins/xpack_main/server/lib/setup_xpack_main.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pairwise } from 'rxjs/operators'; import { XPackInfo } from './xpack_info'; /** @@ -19,23 +20,14 @@ export function setupXPackMain(server) { server.expose('info', info); - const setPluginStatus = () => { - if (info.isAvailable()) { - server.plugins.xpack_main.status.green('Ready'); - } else { - server.plugins.xpack_main.status.red(info.unavailableReason()); - } - }; - // trigger an xpack info refresh whenever the elasticsearch plugin status changes - server.plugins.elasticsearch.status.on('change', async () => { - await info.refreshNow(); - setPluginStatus(); - }); - - // whenever the license info is updated, regardless of the elasticsearch plugin status - // changes, reflect the change in our plugin status. See https://github.com/elastic/kibana/issues/20017 - info.onLicenseInfoChange(setPluginStatus); + server.newPlatform.setup.core.status.core$ + .pipe(pairwise()) + .subscribe(async ([coreLast, coreCurrent]) => { + if (coreLast.elasticsearch.level !== coreCurrent.elasticsearch.level) { + await info.refreshNow(); + } + }); return info; } diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js index c0e59b4ea4ab2..80baf7bf1a64d 100644 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js +++ b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/index.js @@ -5,4 +5,3 @@ */ export { xpackInfoRoute } from './xpack_info'; -export { settingsRoute } from './settings'; diff --git a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js b/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js deleted file mode 100644 index 34fc4d97c1328..0000000000000 --- a/x-pack/legacy/plugins/xpack_main/server/routes/api/v1/settings.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { boomify } from 'boom'; -import { get } from 'lodash'; -import { KIBANA_SETTINGS_TYPE } from '../../../../../../../plugins/monitoring/common/constants'; - -const getClusterUuid = async (callCluster) => { - const { cluster_uuid: uuid } = await callCluster('info', { filterPath: 'cluster_uuid' }); - return uuid; -}; - -export function settingsRoute(server, kbnServer) { - server.route({ - path: '/api/settings', - method: 'GET', - async handler(req) { - const { server } = req; - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - const callCluster = (...args) => callWithRequest(req, ...args); // All queries from HTTP API must use authentication headers from the request - - try { - const { usageCollection } = server.newPlatform.setup.plugins; - const settingsCollector = usageCollection.getCollectorByType(KIBANA_SETTINGS_TYPE); - - let settings = await settingsCollector.fetch(callCluster); - if (!settings) { - settings = settingsCollector.getEmailValueStructure(null); - } - const uuid = await getClusterUuid(callCluster); - - const snapshotRegex = /-snapshot/i; - const config = server.config(); - const status = kbnServer.status.toJSON(); - const kibana = { - uuid: config.get('server.uuid'), - name: config.get('server.name'), - index: config.get('kibana.index'), - host: config.get('server.host'), - port: config.get('server.port'), - locale: config.get('i18n.locale'), - transport_address: `${config.get('server.host')}:${config.get('server.port')}`, - version: kbnServer.version.replace(snapshotRegex, ''), - snapshot: snapshotRegex.test(kbnServer.version), - status: get(status, 'overall.state'), - }; - - return { - cluster_uuid: uuid, - settings: { - ...settings, - kibana, - }, - }; - } catch (err) { - req.log(['error'], err); // FIXME doesn't seem to log anything useful if ES times out - if (err.isBoom) { - return err; - } else { - return boomify(err, { statusCode: err.statusCode, message: err.message }); - } - } - }, - }); -} diff --git a/x-pack/legacy/server/lib/__tests__/mirror_plugin_status.js b/x-pack/legacy/server/lib/__tests__/mirror_plugin_status.js deleted file mode 100644 index c7cae0785c9eb..0000000000000 --- a/x-pack/legacy/server/lib/__tests__/mirror_plugin_status.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import EventEmitter from 'events'; -import expect from '@kbn/expect'; -import { mirrorPluginStatus } from '../mirror_plugin_status'; - -describe('mirror_plugin_status', () => { - class MockPluginStatus extends EventEmitter { - constructor() { - super(); - this.state = 'uninitialized'; - } - - _changeState(newState, newMessage) { - if (this.state === newState) { - return; - } - const prevState = this.state; - const prevMessage = this.message; - - this.state = newState; - this.message = newMessage; - - this.emit(newState, prevState, prevMessage, this.state, this.message); - this.emit('change', prevState, prevMessage, this.state, this.message); - } - - red(message) { - this._changeState('red', message); - } - yellow(message) { - this._changeState('yellow', message); - } - green(message) { - this._changeState('green', message); - } - uninitialized(message) { - this._changeState('uninitialized', message); - } - } - - class MockPlugin { - constructor() { - this.status = new MockPluginStatus(); - } - } - - let upstreamPlugin; - let downstreamPlugin; - let eventNotEmittedTimeout; - - beforeEach(() => { - upstreamPlugin = new MockPlugin(); - downstreamPlugin = new MockPlugin(); - eventNotEmittedTimeout = setTimeout(() => { - throw new Error('Event should have been emitted'); - }, 100); - }); - - it('should mirror all downstream plugin statuses to upstream plugin statuses', (done) => { - mirrorPluginStatus(upstreamPlugin, downstreamPlugin); - downstreamPlugin.status.on('change', () => { - clearTimeout(eventNotEmittedTimeout); - expect(downstreamPlugin.status.state).to.be('red'); - expect(downstreamPlugin.status.message).to.be('test message'); - done(); - }); - upstreamPlugin.status.red('test message'); - }); - - describe('should only mirror specific downstream plugin statuses to corresponding upstream plugin statuses: ', () => { - beforeEach(() => { - mirrorPluginStatus(upstreamPlugin, downstreamPlugin, 'yellow', 'red'); - }); - - it('yellow', (done) => { - downstreamPlugin.status.on('change', () => { - clearTimeout(eventNotEmittedTimeout); - expect(downstreamPlugin.status.state).to.be('yellow'); - expect(downstreamPlugin.status.message).to.be('test yellow message'); - done(); - }); - upstreamPlugin.status.yellow('test yellow message'); - }); - - it('red', (done) => { - downstreamPlugin.status.on('change', () => { - clearTimeout(eventNotEmittedTimeout); - expect(downstreamPlugin.status.state).to.be('red'); - expect(downstreamPlugin.status.message).to.be('test red message'); - done(); - }); - upstreamPlugin.status.red('test red message'); - }); - - it('not green', () => { - clearTimeout(eventNotEmittedTimeout); // because event should not be emitted in this test - downstreamPlugin.status.on('change', () => { - throw new Error('Event should NOT have been emitted'); - }); - upstreamPlugin.status.green('test green message'); - }); - }); -}); diff --git a/x-pack/legacy/server/lib/create_router/call_with_request_factory/index.d.ts b/x-pack/legacy/server/lib/create_router/call_with_request_factory/index.d.ts index 4ec5bc13eea81..3537d1bf42079 100644 --- a/x-pack/legacy/server/lib/create_router/call_with_request_factory/index.d.ts +++ b/x-pack/legacy/server/lib/create_router/call_with_request_factory/index.d.ts @@ -5,9 +5,9 @@ */ import { Legacy } from 'kibana'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { LegacyAPICaller } from '../../../../../../src/core/server'; -export type CallWithRequest = (...args: any[]) => CallCluster; +export type CallWithRequest = (...args: any[]) => LegacyAPICaller; export declare function callWithRequestFactory( server: Legacy.Server, diff --git a/x-pack/legacy/server/lib/mirror_plugin_status.js b/x-pack/legacy/server/lib/mirror_plugin_status.js deleted file mode 100644 index 7b1ac215f5e4c..0000000000000 --- a/x-pack/legacy/server/lib/mirror_plugin_status.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function mirrorPluginStatus(upstreamPlugin, downstreamPlugin, ...statesToMirror) { - upstreamPlugin.status.setMaxListeners(21); // We need more than the default, which is 10 - - function mirror(previousState, previousMsg, newState, newMsg) { - if (newState) { - downstreamPlugin.status[newState](newMsg); - } - } - - if (statesToMirror.length === 0) { - statesToMirror.push('change'); - } - - statesToMirror.map((state) => upstreamPlugin.status.on(state, mirror)); - mirror(null, null, upstreamPlugin.status.state, upstreamPlugin.status.message); // initial mirroring -} diff --git a/x-pack/legacy/server/lib/register_license_checker/register_license_checker.js b/x-pack/legacy/server/lib/register_license_checker/register_license_checker.js index eabe375eefd02..57cbe30c25cb2 100644 --- a/x-pack/legacy/server/lib/register_license_checker/register_license_checker.js +++ b/x-pack/legacy/server/lib/register_license_checker/register_license_checker.js @@ -4,21 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { mirrorPluginStatus } from '../mirror_plugin_status'; +import { pairwise } from 'rxjs/operators'; + +import { ServiceStatusLevels } from '../../../../../src/core/server'; import { checkLicense } from '../check_license'; export function registerLicenseChecker(server, pluginId, pluginName, minimumLicenseRequired) { const xpackMainPlugin = server.plugins.xpack_main; - const thisPlugin = server.plugins[pluginId]; + const subscription = server.newPlatform.setup.core.status.core$ + .pipe(pairwise()) + .subscribe(([coreLast, coreCurrent]) => { + if ( + !subscription.closed && + coreLast.elasticsearch.level !== ServiceStatusLevels.available && + coreCurrent.elasticsearch.level === ServiceStatusLevels.available + ) { + // Unsubscribe as soon as ES becomes available so this function only runs once + subscription.unsubscribe(); - mirrorPluginStatus(xpackMainPlugin, thisPlugin); - xpackMainPlugin.status.once('green', () => { - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackMainPlugin.info - .feature(pluginId) - .registerLicenseCheckResultsGenerator((xpackLicenseInfo) => { - return checkLicense(pluginName, minimumLicenseRequired, xpackLicenseInfo); - }); - }); + // Register a function that is called whenever the xpack info changes, + // to re-compute the license check results for this plugin + xpackMainPlugin.info + .feature(pluginId) + .registerLicenseCheckResultsGenerator((xpackLicenseInfo) => { + return checkLicense(pluginName, minimumLicenseRequired, xpackLicenseInfo); + }); + } + }); } diff --git a/x-pack/package.json b/x-pack/package.json index b94133bcfd2ca..1ef7eecda9481 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -39,7 +39,7 @@ "@kbn/storybook": "1.0.0", "@kbn/test": "1.0.0", "@kbn/utility-types": "1.0.0", - "@mapbox/geojson-rewind": "^0.4.1", + "@mapbox/geojson-rewind": "^0.5.0", "@mapbox/mapbox-gl-draw": "^1.2.0", "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@scant/router": "^0.1.0", @@ -100,13 +100,13 @@ "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", - "@types/node-fetch": "^2.5.0", + "@types/node-fetch": "^2.5.7", "@types/nodemailer": "^6.2.1", "@types/object-hash": "^1.3.0", "@types/papaparse": "^5.0.3", "@types/pngjs": "^3.3.2", "@types/pretty-ms": "^5.0.0", - "@types/prop-types": "^15.5.3", + "@types/prop-types": "^15.7.3", "@types/proper-lockfile": "^3.0.1", "@types/puppeteer": "^1.20.1", "@types/react": "^16.9.36", @@ -159,6 +159,7 @@ "cronstrue": "^1.51.0", "cypress": "5.0.0", "cypress-multi-reporters": "^1.2.3", + "cypress-promise": "^1.1.0", "d3": "3.5.17", "d3-scale": "1.0.7", "dragselect": "1.13.1", @@ -208,7 +209,6 @@ "mochawesome-merge": "^4.1.0", "mustache": "^2.3.0", "mutation-observer": "^1.0.3", - "node-fetch": "^2.6.0", "null-loader": "^3.0.0", "oboe": "^2.1.4", "pixelmatch": "^5.1.0", @@ -274,7 +274,7 @@ "@babel/runtime": "^7.11.2", "@elastic/datemath": "5.0.3", "@elastic/ems-client": "7.9.3", - "@elastic/eui": "28.2.0", + "@elastic/eui": "29.0.0", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.0", @@ -344,7 +344,7 @@ "moment-timezone": "^0.5.27", "ngreact": "^0.5.1", "nock": "12.0.3", - "node-fetch": "^2.6.0", + "node-fetch": "^2.6.1", "nodemailer": "^4.7.0", "object-hash": "^1.3.1", "object-path-immutable": "^3.1.1", @@ -353,7 +353,7 @@ "papaparse": "^5.2.0", "pdfmake": "^0.1.65", "pngjs": "3.4.0", - "prop-types": "^15.6.0", + "prop-types": "^15.7.2", "proper-lockfile": "^3.2.0", "puid": "1.0.7", "puppeteer-core": "^1.19.0", diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index 49e8f3e80b14a..41ec4d2a88e9f 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -24,3 +24,13 @@ export interface ActionResult { config: Record; isPreconfigured: boolean; } + +// the result returned from an action type executor function +export interface ActionTypeExecutorResult { + actionId: string; + status: 'ok' | 'error'; + message?: string; + serviceMessage?: string; + data?: Data; + retry?: null | boolean | Date; +} diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 573fb0e1be580..adef12454f2d5 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -893,7 +893,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -946,7 +946,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -972,17 +972,21 @@ describe('update()', () => { name: 'my name', config: {}, }); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", - "my-action", Object { "actionTypeId": "my-action-type", "config": Object {}, "name": "my name", "secrets": Object {}, }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, ] `); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); @@ -1043,7 +1047,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -1081,11 +1085,10 @@ describe('update()', () => { c: true, }, }); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", - "my-action", Object { "actionTypeId": "my-action-type", "config": Object { @@ -1096,6 +1099,11 @@ describe('update()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "my-action", + "overwrite": true, + "references": Array [], + }, ] `); }); @@ -1118,7 +1126,7 @@ describe('update()', () => { }, references: [], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 06c9555f3a18d..4079a6ddeeb8a 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -13,6 +13,7 @@ import { } from 'src/core/server'; import { i18n } from '@kbn/i18n'; +import { omitBy, isUndefined } from 'lodash'; import { ActionTypeRegistry } from './action_type_registry'; import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; import { @@ -30,7 +31,10 @@ import { } from './create_execute_function'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { ActionType } from '../common'; -import { shouldLegacyRbacApplyBySource } from './authorization/should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './authorization/get_authorization_mode_by_source'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -151,8 +155,10 @@ export class ActionsClient { 'update' ); } - const existingObject = await this.unsecuredSavedObjectsClient.get('action', id); - const { actionTypeId } = existingObject.attributes; + const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get< + RawAction + >('action', id); + const { actionTypeId } = attributes; const { name, config, secrets } = action; const actionType = this.actionTypeRegistry.get(actionTypeId); const validatedActionTypeConfig = validateConfig(actionType, config); @@ -160,12 +166,25 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.unsecuredSavedObjectsClient.update('action', id, { - actionTypeId, - name, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }); + const result = await this.unsecuredSavedObjectsClient.create( + 'action', + { + ...attributes, + actionTypeId, + name, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); return { id, @@ -301,7 +320,10 @@ export class ActionsClient { params, source, }: Omit): Promise> { - if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) { + if ( + (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === + AuthorizationMode.RBAC + ) { await this.authorization.ensureAuthorized('execute'); } return this.actionExecutor.execute({ actionId, params, source, request: this.request }); @@ -309,7 +331,10 @@ export class ActionsClient { public async enqueueExecution(options: EnqueueExecutionOptions): Promise { const { source } = options; - if (!(await shouldLegacyRbacApplyBySource(this.unsecuredSavedObjectsClient, source))) { + if ( + (await getAuthorizationModeBySource(this.unsecuredSavedObjectsClient, source)) === + AuthorizationMode.RBAC + ) { await this.authorization.ensureAuthorized('execute'); } return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts index 08c4472f8007b..a19a662f8323c 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -10,6 +10,7 @@ import { actionsAuthorizationAuditLoggerMock } from './audit_logger.mock'; import { ActionsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; import { AuthenticatedUser } from '../../../security/server'; +import { AuthorizationMode } from './get_authorization_mode_by_source'; const request = {} as KibanaRequest; @@ -195,7 +196,7 @@ describe('ensureAuthorized', () => { `); }); - test('exempts users from requiring privileges to execute actions when shouldUseLegacyRbac is true', async () => { + test('exempts users from requiring privileges to execute actions when authorizationMode is Legacy', async () => { const { authorization, authentication } = mockSecurity(); const checkPrivileges: jest.MockedFunction { authorization, authentication, auditLogger, - shouldUseLegacyRbac: true, + authorizationMode: AuthorizationMode.Legacy, }); authentication.getCurrentUser.mockReturnValueOnce(({ diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts index bd6e355c2cf9d..cad58bed50981 100644 --- a/x-pack/plugins/actions/server/authorization/actions_authorization.ts +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -9,6 +9,7 @@ import { KibanaRequest } from 'src/core/server'; import { SecurityPluginSetup } from '../../../security/server'; import { ActionsAuthorizationAuditLogger } from './audit_logger'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { AuthorizationMode } from './get_authorization_mode_by_source'; export interface ConstructorOptions { request: KibanaRequest; @@ -22,7 +23,7 @@ export interface ConstructorOptions { // actions to continue to execute - which requires that we exempt auth on // `get` for Connectors and `execute` for Action execution when used by // these legacy alerts - shouldUseLegacyRbac?: boolean; + authorizationMode?: AuthorizationMode; } const operationAlias: Record< @@ -43,20 +44,19 @@ export class ActionsAuthorization { private readonly authorization?: SecurityPluginSetup['authz']; private readonly authentication?: SecurityPluginSetup['authc']; private readonly auditLogger: ActionsAuthorizationAuditLogger; - private readonly shouldUseLegacyRbac: boolean; - + private readonly authorizationMode: AuthorizationMode; constructor({ request, authorization, authentication, auditLogger, - shouldUseLegacyRbac = false, + authorizationMode = AuthorizationMode.RBAC, }: ConstructorOptions) { this.request = request; this.authorization = authorization; this.authentication = authentication; this.auditLogger = auditLogger; - this.shouldUseLegacyRbac = shouldUseLegacyRbac; + this.authorizationMode = authorizationMode; } public async ensureAuthorized(operation: string, actionTypeId?: string) { @@ -87,6 +87,9 @@ export class ActionsAuthorization { } private isOperationExemptDueToLegacyRbac(operation: string) { - return this.shouldUseLegacyRbac && LEGACY_RBAC_EXEMPT_OPERATIONS.has(operation); + return ( + this.authorizationMode === AuthorizationMode.Legacy && + LEGACY_RBAC_EXEMPT_OPERATIONS.has(operation) + ); } } diff --git a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts similarity index 67% rename from x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts rename to x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts index 03062994adeb6..4980c476e60ea 100644 --- a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.test.ts +++ b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.test.ts @@ -3,88 +3,93 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { shouldLegacyRbacApplyBySource } from './should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './get_authorization_mode_by_source'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import uuid from 'uuid'; import { asSavedObjectExecutionSource } from '../lib'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); -describe(`#shouldLegacyRbacApplyBySource`, () => { - test('should return false if no source is provided', async () => { - expect(await shouldLegacyRbacApplyBySource(unsecuredSavedObjectsClient)).toEqual(false); +describe(`#getAuthorizationModeBySource`, () => { + test('should return RBAC if no source is provided', async () => { + expect(await getAuthorizationModeBySource(unsecuredSavedObjectsClient)).toEqual( + AuthorizationMode.RBAC + ); }); - test('should return false if source is not an alert', async () => { + test('should return RBAC if source is not an alert', async () => { expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'action', id: uuid.v4(), }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return false if source alert is not marked as legacy', async () => { + test('should return RBAC if source alert is not marked as legacy', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id })); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return true if source alert is marked as legacy', async () => { + test('should return Legacy if source alert is marked as legacy', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue( mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: 'pre-7.10.0' } } }) ); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(true); + ).toEqual(AuthorizationMode.Legacy); }); - test('should return false if source alert is marked as modern', async () => { + test('should return RBAC if source alert is marked as modern', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue( mockAlert({ id, attributes: { meta: { versionApiKeyLastmodified: '7.10.0' } } }) ); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); - test('should return false if source alert is marked with a last modified version', async () => { + test('should return RBAC if source alert doesnt have a last modified version', async () => { const id = uuid.v4(); unsecuredSavedObjectsClient.get.mockResolvedValue(mockAlert({ id, attributes: { meta: {} } })); expect( - await shouldLegacyRbacApplyBySource( + await getAuthorizationModeBySource( unsecuredSavedObjectsClient, asSavedObjectExecutionSource({ type: 'alert', id, }) ) - ).toEqual(false); + ).toEqual(AuthorizationMode.RBAC); }); }); diff --git a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts similarity index 55% rename from x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts rename to x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts index 06d5776003ede..85d646c75defa 100644 --- a/x-pack/plugins/actions/server/authorization/should_legacy_rbac_apply_by_source.ts +++ b/x-pack/plugins/actions/server/authorization/get_authorization_mode_by_source.ts @@ -10,18 +10,24 @@ import { ALERT_SAVED_OBJECT_TYPE } from '../saved_objects'; const LEGACY_VERSION = 'pre-7.10.0'; -export async function shouldLegacyRbacApplyBySource( +export enum AuthorizationMode { + Legacy, + RBAC, +} + +export async function getAuthorizationModeBySource( unsecuredSavedObjectsClient: SavedObjectsClientContract, executionSource?: ActionExecutionSource -): Promise { +): Promise { return isSavedObjectExecutionSource(executionSource) && - executionSource?.source?.type === ALERT_SAVED_OBJECT_TYPE - ? ( - await unsecuredSavedObjectsClient.get<{ - meta?: { - versionApiKeyLastmodified?: string; - }; - }>(ALERT_SAVED_OBJECT_TYPE, executionSource.source.id) - ).attributes.meta?.versionApiKeyLastmodified === LEGACY_VERSION - : false; + executionSource?.source?.type === ALERT_SAVED_OBJECT_TYPE && + ( + await unsecuredSavedObjectsClient.get<{ + meta?: { + versionApiKeyLastmodified?: string; + }; + }>(ALERT_SAVED_OBJECT_TYPE, executionSource.source.id) + ).attributes.meta?.versionApiKeyLastmodified === LEGACY_VERSION + ? AuthorizationMode.Legacy + : AuthorizationMode.RBAC; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index 7a0e24521a1c6..3d92d5ebf33fc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -284,4 +284,47 @@ describe('execute()', () => { ] `); }); + + test('resolves with an error when an error occurs in the indexing operation', async () => { + const secrets = {}; + // minimal params + const config = { index: 'index-value', refresh: false, executionTimeField: null }; + const params = { + documents: [{ '': 'bob' }], + }; + + const actionId = 'some-id'; + + services.callCluster.mockResolvedValue({ + took: 0, + errors: true, + items: [ + { + index: { + _index: 'indexme', + _id: '7buTjHQB0SuNSiS9Hayt', + status: 400, + error: { + type: 'mapper_parsing_exception', + reason: 'failed to parse', + caused_by: { + type: 'illegal_argument_exception', + reason: 'field name cannot be an empty string', + }, + }, + }, + }, + ], + }); + + expect(await actionType.executor({ actionId, config, secrets, params, services })) + .toMatchInlineSnapshot(` + Object { + "actionId": "some-id", + "message": "error indexing documents", + "serviceMessage": "failed to parse (field name cannot be an empty string)", + "status": "error", + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index 53bf75651b1e5..868c07b775c78 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { curry } from 'lodash'; +import { curry, find } from 'lodash'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; @@ -85,21 +85,39 @@ async function executor( refresh: config.refresh, }; - let result; try { - result = await services.callCluster('bulk', bulkParams); + const result = await services.callCluster('bulk', bulkParams); + + const err = find(result.items, 'index.error.reason'); + if (err) { + return wrapErr( + `${err.index.error!.reason}${ + err.index.error?.caused_by ? ` (${err.index.error?.caused_by?.reason})` : '' + }`, + actionId, + logger + ); + } + + return { status: 'ok', data: result, actionId }; } catch (err) { - const message = i18n.translate('xpack.actions.builtin.esIndex.errorIndexingErrorMessage', { - defaultMessage: 'error indexing documents', - }); - logger.error(`error indexing documents: ${err.message}`); - return { - status: 'error', - actionId, - message, - serviceMessage: err.message, - }; + return wrapErr(err.message, actionId, logger); } +} - return { status: 'ok', data: result, actionId }; +function wrapErr( + errMessage: string, + actionId: string, + logger: Logger +): ActionTypeExecutorResult { + const message = i18n.translate('xpack.actions.builtin.esIndex.errorIndexingErrorMessage', { + defaultMessage: 'error indexing documents', + }); + logger.error(`error indexing documents: ${errMessage}`); + return { + status: 'error', + actionId, + message, + serviceMessage: errMessage, + }; } diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts index 321509a7b9de6..abe5921fda7f1 100644 --- a/x-pack/plugins/actions/server/feature.ts +++ b/x-pack/plugins/actions/server/feature.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './saved_objects'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export const ACTIONS_FEATURE = { id: 'actions', @@ -14,6 +15,7 @@ export const ACTIONS_FEATURE = { }), icon: 'bell', navLinkId: 'actions', + category: DEFAULT_APP_CATEGORIES.management, app: [], management: { insightsAndAlerting: ['triggersActions'], diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index a607dc0de0bda..73434d5c1eaa2 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -27,7 +27,7 @@ export interface ActionExecutorContext { getServices: GetServicesFunction; getActionsClientWithRequest: ( request: KibanaRequest, - executionSource?: ActionExecutionSource + authorizationContext?: ActionExecutionSource ) => Promise>; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actionTypeRegistry: ActionTypeRegistryContract; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 97cefafad4385..dca1114f0ae44 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -71,7 +71,10 @@ import { ACTIONS_FEATURE } from './feature'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { ActionsAuthorizationAuditLogger } from './authorization/audit_logger'; import { ActionExecutionSource } from './lib/action_execution_source'; -import { shouldLegacyRbacApplyBySource } from './authorization/should_legacy_rbac_apply_by_source'; +import { + getAuthorizationModeBySource, + AuthorizationMode, +} from './authorization/get_authorization_mode_by_source'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -281,7 +284,7 @@ export class ActionsPlugin implements Plugin, Plugi const getActionsClientWithRequest = async ( request: KibanaRequest, - source?: ActionExecutionSource + authorizationContext?: ActionExecutionSource ) => { if (isESOUsingEphemeralEncryptionKey === true) { throw new Error( @@ -303,7 +306,7 @@ export class ActionsPlugin implements Plugin, Plugi request, authorization: instantiateAuthorization( request, - await shouldLegacyRbacApplyBySource(unsecuredSavedObjectsClient, source) + await getAuthorizationModeBySource(unsecuredSavedObjectsClient, authorizationContext) ), actionExecutor: actionExecutor!, executionEnqueuer: createExecutionEnqueuerFunction({ @@ -316,7 +319,8 @@ export class ActionsPlugin implements Plugin, Plugi }; // Ensure the public API cannot be used to circumvent authorization - // using our legacy exemption mechanism + // using our legacy exemption mechanism by passing in a legacy SO + // as authorizationContext which would then set a Legacy AuthorizationMode const secureGetActionsClientWithRequest = (request: KibanaRequest) => getActionsClientWithRequest(request); @@ -389,11 +393,11 @@ export class ActionsPlugin implements Plugin, Plugi private instantiateAuthorization = ( request: KibanaRequest, - shouldUseLegacyRbac: boolean = false + authorizationMode?: AuthorizationMode ) => { return new ActionsAuthorization({ request, - shouldUseLegacyRbac, + authorizationMode, authorization: this.security?.authz, authentication: this.security?.authc, auditLogger: new ActionsAuthorizationAuditLogger( diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 3e92ca331bb93..a23a2b0893261 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -15,6 +15,8 @@ import { SavedObjectsClientContract, SavedObjectAttributes, } from '../../../../src/core/server'; +import { ActionTypeExecutorResult } from '../common'; +export { ActionTypeExecutorResult } from '../common'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; @@ -80,16 +82,6 @@ export interface FindActionResult extends ActionResult { referencedByCount: number; } -// the result returned from an action type executor function -export interface ActionTypeExecutorResult { - actionId: string; - status: 'ok' | 'error'; - message?: string; - serviceMessage?: string; - data?: Data; - retry?: null | boolean | Date; -} - // signature of the action type executor function export type ExecutorType = ( options: ActionTypeExecutorOptions diff --git a/x-pack/plugins/alerting_builtins/server/feature.ts b/x-pack/plugins/alerting_builtins/server/feature.ts index 316bae98bf8c1..a7c8b940fbf06 100644 --- a/x-pack/plugins/alerting_builtins/server/feature.ts +++ b/x-pack/plugins/alerting_builtins/server/feature.ts @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; import { BUILT_IN_ALERTS_FEATURE_ID } from '../common'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export const BUILT_IN_ALERTS_FEATURE = { id: BUILT_IN_ALERTS_FEATURE_ID, @@ -15,6 +16,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }), icon: 'bell', app: [], + category: DEFAULT_APP_CATEGORIES.management, management: { insightsAndAlerting: ['triggersActions'], }, diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index 4b5af942024c0..a6cffb0284815 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -220,7 +220,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -270,27 +270,33 @@ describe('create()', () => { test('creates an alert', async () => { const data = getMockData(); + const createdAttributes = { + ...data, + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + updatedBy: 'elastic', + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }; unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', - attributes: { - alertTypeId: '123', - schedule: { interval: '10s' }, - params: { - bar: true, - }, - createdAt: '2019-02-12T21:01:22.479Z', - actions: [ - { - group: 'default', - actionRef: 'action_0', - actionTypeId: 'test', - params: { - foo: true, - }, - }, - ], - }, + attributes: createdAttributes, references: [ { name: 'action_0', @@ -312,11 +318,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { - actions: [], + ...createdAttributes, scheduledTaskId: 'task-123', }, references: [ @@ -342,8 +348,14 @@ describe('create()', () => { }, ], "alertTypeId": "123", + "consumer": "bar", "createdAt": 2019-02-12T21:01:22.479Z, + "createdBy": "elastic", + "enabled": true, "id": "1", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "abc", "params": Object { "bar": true, }, @@ -351,7 +363,12 @@ describe('create()', () => { "interval": "10s", }, "scheduledTaskId": "task-123", + "tags": Array [ + "foo", + ], + "throttle": null, "updatedAt": 2019-02-12T21:01:22.479Z, + "updatedBy": "elastic", } `); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); @@ -531,7 +548,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -965,7 +982,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1081,7 +1098,7 @@ describe('create()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1175,6 +1192,16 @@ describe('enable()', () => { alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + ...existingAlert, + attributes: { + ...existingAlert.attributes, + enabled: true, + apiKey: null, + apiKeyOwner: null, + updatedBy: 'elastic', + }, + }); taskManager.schedule.mockResolvedValue({ id: 'task-123', scheduledAt: new Date(), @@ -1233,6 +1260,17 @@ describe('enable()', () => { }); test('enables an alert', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + ...existingAlert, + attributes: { + ...existingAlert.attributes, + enabled: true, + apiKey: null, + apiKeyOwner: null, + updatedBy: 'elastic', + }, + }); + await alertsClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -1317,7 +1355,7 @@ describe('enable()', () => { await alertsClient.enable({ id: '1' }); expect(alertsClientParams.getUserName).not.toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -1384,6 +1422,7 @@ describe('enable()', () => { }); test('throws error when failing to update the first time', async () => { + unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( @@ -1396,6 +1435,7 @@ describe('enable()', () => { }); test('throws error when failing to update the second time', async () => { + unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -1460,6 +1500,8 @@ describe('disable()', () => { ...existingAlert.attributes, apiKey: Buffer.from('123:abc').toString('base64'), }, + version: '123', + references: [], }; beforeEach(() => { @@ -1501,13 +1543,13 @@ describe('disable()', () => { consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', - apiKey: null, - apiKeyOwner: null, enabled: false, meta: { versionApiKeyLastmodified: kibanaVersion, }, scheduledTaskId: null, + apiKey: null, + apiKeyOwner: null, updatedBy: 'elastic', actions: [ { @@ -1544,13 +1586,13 @@ describe('disable()', () => { consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', - apiKey: null, - apiKeyOwner: null, enabled: false, meta: { versionApiKeyLastmodified: kibanaVersion, }, scheduledTaskId: null, + apiKey: null, + apiKeyOwner: null, updatedBy: 'elastic', actions: [ { @@ -1739,6 +1781,7 @@ describe('unmuteAll()', () => { muteAll: true, }, references: [], + version: '123', }); await alertsClient.unmuteAll({ id: '1' }); @@ -1829,7 +1872,9 @@ describe('muteInstance()', () => { mutedInstanceIds: ['2'], updatedBy: 'elastic', }, - { version: '123' } + { + version: '123', + } ); }); @@ -1850,7 +1895,7 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('skips muting when alert is muted', async () => { @@ -1871,7 +1916,7 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); describe('authorization', () => { @@ -1983,7 +2028,7 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('skips unmuting when alert is muted', async () => { @@ -2004,7 +2049,7 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); describe('authorization', () => { @@ -3052,7 +3097,7 @@ describe('update()', () => { }); test('updates given parameters', async () => { - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3189,11 +3234,10 @@ describe('update()', () => { namespace: 'default', }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3244,8 +3288,10 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` Object { + "id": "1", + "overwrite": true, "references": Array [ Object { "id": "1", @@ -3286,7 +3332,7 @@ describe('update()', () => { apiKeysEnabled: true, result: { id: '123', name: '123', api_key: 'abc' }, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3365,11 +3411,10 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3404,18 +3449,20 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "overwrite": true, + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it(`doesn't call the createAPIKey function when alert is disabled`, async () => { @@ -3439,7 +3486,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3519,11 +3566,10 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -3558,18 +3604,20 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "overwrite": true, + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it('should validate params', async () => { @@ -3627,7 +3675,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3686,7 +3734,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3765,7 +3813,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -3919,7 +3967,7 @@ describe('update()', () => { params: {}, ownerId: null, }); - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: alertId, type: 'alert', attributes: { @@ -4091,7 +4139,7 @@ describe('update()', () => { describe('authorization', () => { beforeEach(() => { - unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index 0a08ca848c73d..671b1d6411d7f 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -251,7 +251,7 @@ export class AlertsClient { } throw e; } - await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { + await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { scheduledTaskId: scheduledTask.id, }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; @@ -488,9 +488,8 @@ export class AlertsClient { : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); - const updatedObject = await this.unsecuredSavedObjectsClient.update( + const updatedObject = await this.unsecuredSavedObjectsClient.create( 'alert', - id, this.updateMeta({ ...attributes, ...data, @@ -500,6 +499,8 @@ export class AlertsClient { updatedBy: username, }), { + id, + overwrite: true, version, references, } @@ -798,6 +799,7 @@ export class AlertsClient { 'alert', alertId ); + await this.authorization.ensureAuthorized( attributes.alertTypeId, attributes.consumer, @@ -809,7 +811,7 @@ export class AlertsClient { const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { - await this.unsecuredSavedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', alertId, this.updateMeta({ diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts index 9515987af8dd9..b3c7ada26c456 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts @@ -44,6 +44,7 @@ function mockFeature(appName: string, typeName?: string) { id: appName, name: appName, app: [], + category: { id: 'foo', label: 'foo' }, ...(typeName ? { alerting: [typeName], @@ -87,6 +88,7 @@ function mockFeatureWithSubFeature(appName: string, typeName: string) { id: appName, name: appName, app: [], + category: { id: 'foo', label: 'foo' }, ...(typeName ? { alerting: [typeName], diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index 026aa0c5238dc..b13a1c62f6602 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -164,6 +164,7 @@ function mockFeatures() { id: 'appName', name: 'appName', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index f873b0178ece9..aca447b6adedd 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -19,6 +19,7 @@ import { AlertInstanceContext, AlertType, AlertTypeParams, + RawAlert, } from '../types'; interface CreateExecutionHandlerOptions { @@ -28,7 +29,7 @@ interface CreateExecutionHandlerOptions { actionsPlugin: ActionsPluginStartContract; actions: AlertAction[]; spaceId: string; - apiKey: string | null; + apiKey: RawAlert['apiKey']; alertType: AlertType; logger: Logger; eventLogger: IEventLogger; @@ -99,7 +100,7 @@ export function createExecutionHandler({ id: action.id, params: action.params, spaceId, - apiKey, + apiKey: apiKey ?? null, source: asSavedObjectExecutionSource({ id: alertId, type: 'alert', diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 4c16d23b485b5..7ea3f83d747c0 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pickBy, mapValues, omit, without } from 'lodash'; +import { pickBy, mapValues, without } from 'lodash'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../task_manager/server'; @@ -73,7 +73,7 @@ export class TaskRunner { return apiKey; } - private getFakeKibanaRequest(spaceId: string, apiKey: string | null) { + private getFakeKibanaRequest(spaceId: string, apiKey: RawAlert['apiKey']) { const requestHeaders: Record = {}; if (apiKey) { @@ -98,7 +98,7 @@ export class TaskRunner { private getServicesWithSpaceLevelPermissions( spaceId: string, - apiKey: string | null + apiKey: RawAlert['apiKey'] ): [Services, PublicMethodsOf] { const request = this.getFakeKibanaRequest(spaceId, apiKey); return [this.context.getServices(request), this.context.getAlertsClientWithRequest(request)]; @@ -109,7 +109,7 @@ export class TaskRunner { alertName: string, tags: string[] | undefined, spaceId: string, - apiKey: string | null, + apiKey: RawAlert['apiKey'], actions: Alert['actions'], alertParams: RawAlert['params'] ) { @@ -228,12 +228,13 @@ export class TaskRunner { }); if (!muteAll) { - const enabledAlertInstances = omit(instancesWithScheduledActions, ...mutedInstanceIds); + const mutedInstanceIdsSet = new Set(mutedInstanceIds); await Promise.all( - Object.entries(enabledAlertInstances) + Object.entries(instancesWithScheduledActions) .filter( - ([, alertInstance]: [string, AlertInstance]) => !alertInstance.isThrottled(throttle) + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => + !alertInstance.isThrottled(throttle) && !mutedInstanceIdsSet.has(alertInstanceName) ) .map(([id, alertInstance]: [string, AlertInstance]) => this.executeAlertInstance(id, alertInstance, executionHandler) @@ -250,7 +251,11 @@ export class TaskRunner { }; } - async validateAndExecuteAlert(services: Services, apiKey: string | null, alert: SanitizedAlert) { + async validateAndExecuteAlert( + services: Services, + apiKey: RawAlert['apiKey'], + alert: SanitizedAlert + ) { const { params: { alertId, spaceId }, } = this.taskInstance; 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 3524d41646d50..8c233d3691c7f 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 @@ -52,6 +52,10 @@ exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error LCP_FIELD 1`] = `undefined`; +exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Error METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Error METRIC_JAVA_GC_TIME 1`] = `undefined`; @@ -220,6 +224,10 @@ exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span LCP_FIELD 1`] = `undefined`; +exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Span METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Span METRIC_JAVA_GC_TIME 1`] = `undefined`; @@ -388,6 +396,10 @@ exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction LCP_FIELD 1`] = `undefined`; +exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; + +exports[`Transaction METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; + exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`; exports[`Transaction METRIC_JAVA_GC_TIME 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap index 2044053e049f1..2962a5fd2df3b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -172,7 +172,7 @@ Array [ }, Object { "key": "transaction_max_spans", - "max": 32000, + "max": undefined, "min": 0, "type": "integer", "validationName": "integerRt", diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index 95892f435e8f9..e777e1fd09d0b 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -177,7 +177,6 @@ export const generalSettings: RawSettingDefinition[] = [ key: 'transaction_max_spans', type: 'integer', min: 0, - max: 32000, defaultValue: '500', label: i18n.translate('xpack.apm.agentConfig.transactionMaxSpans.label', { defaultMessage: 'Transaction max spans', diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index a1161354e04f4..a234226d18034 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -5,44 +5,37 @@ */ import { i18n } from '@kbn/i18n'; +import { ValuesType } from 'utility-types'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../../ml/common'; export enum AlertType { - ErrorRate = 'apm.error_rate', + ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. + TransactionErrorRate = 'apm.transaction_error_rate', TransactionDuration = 'apm.transaction_duration', TransactionDurationAnomaly = 'apm.transaction_duration_anomaly', } +const THRESHOLD_MET_GROUP = { + id: 'threshold_met', + name: i18n.translate('xpack.apm.a.thresholdMet', { + defaultMessage: 'Threshold met', + }), +}; + export const ALERT_TYPES_CONFIG = { - [AlertType.ErrorRate]: { - name: i18n.translate('xpack.apm.errorRateAlert.name', { - defaultMessage: 'Error rate', + [AlertType.ErrorCount]: { + name: i18n.translate('xpack.apm.errorCountAlert.name', { + defaultMessage: 'Error count threshold', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate('xpack.apm.errorRateAlert.thresholdMet', { - defaultMessage: 'Threshold met', - }), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, [AlertType.TransactionDuration]: { name: i18n.translate('xpack.apm.transactionDurationAlert.name', { - defaultMessage: 'Transaction duration', + defaultMessage: 'Transaction duration threshold', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate( - 'xpack.apm.transactionDurationAlert.thresholdMet', - { - defaultMessage: 'Threshold met', - } - ), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, @@ -50,39 +43,59 @@ export const ALERT_TYPES_CONFIG = { name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', { defaultMessage: 'Transaction duration anomaly', }), - actionGroups: [ - { - id: 'threshold_met', - name: i18n.translate( - 'xpack.apm.transactionDurationAlert.thresholdMet', - { - defaultMessage: 'Threshold met', - } - ), - }, - ], + actionGroups: [THRESHOLD_MET_GROUP], + defaultActionGroupId: 'threshold_met', + producer: 'apm', + }, + [AlertType.TransactionErrorRate]: { + name: i18n.translate('xpack.apm.transactionErrorRateAlert.name', { + defaultMessage: 'Transaction error rate threshold', + }), + actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: 'threshold_met', producer: 'apm', }, }; -export const TRANSACTION_ALERT_AGGREGATION_TYPES = { - avg: i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.avg', - { - defaultMessage: 'Average', - } - ), - '95th': i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.95th', - { - defaultMessage: '95th percentile', - } - ), - '99th': i18n.translate( - 'xpack.apm.transactionDurationAlert.aggregationType.99th', - { - defaultMessage: '99th percentile', - } - ), -}; +export const ANOMALY_ALERT_SEVERITY_TYPES = [ + { + type: ANOMALY_SEVERITY.CRITICAL, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.criticalLabel', { + defaultMessage: 'critical', + }), + threshold: ANOMALY_THRESHOLD.CRITICAL, + }, + { + type: ANOMALY_SEVERITY.MAJOR, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.majorLabel', { + defaultMessage: 'major', + }), + threshold: ANOMALY_THRESHOLD.MAJOR, + }, + { + type: ANOMALY_SEVERITY.MINOR, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.minor', { + defaultMessage: 'minor', + }), + threshold: ANOMALY_THRESHOLD.MINOR, + }, + { + type: ANOMALY_SEVERITY.WARNING, + label: i18n.translate('xpack.apm.alerts.anomalySeverity.warningLabel', { + defaultMessage: 'warning', + }), + threshold: ANOMALY_THRESHOLD.WARNING, + }, +] as const; + +export type AnomalyAlertSeverityType = ValuesType< + typeof ANOMALY_ALERT_SEVERITY_TYPES +>['type']; + +// Server side registrations +// x-pack/plugins/apm/server/lib/alerts/.ts +// x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts + +// Client side registrations: +// x-pack/plugins/apm/public/components/alerting//index.tsx +// x-pack/plugins/apm/public/components/alerting/register_apm_alerts diff --git a/x-pack/plugins/apm/common/anomaly_detection.test.ts b/x-pack/plugins/apm/common/anomaly_detection.test.ts deleted file mode 100644 index 21963b5300f83..0000000000000 --- a/x-pack/plugins/apm/common/anomaly_detection.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSeverity, Severity } from './anomaly_detection'; - -describe('getSeverity', () => { - describe('when score is undefined', () => { - it('returns undefined', () => { - expect(getSeverity(undefined)).toEqual(undefined); - }); - }); - - describe('when score < 25', () => { - it('returns warning', () => { - expect(getSeverity(10)).toEqual(Severity.warning); - }); - }); - - describe('when score is between 25 and 50', () => { - it('returns minor', () => { - expect(getSeverity(40)).toEqual(Severity.minor); - }); - }); - - describe('when score is between 50 and 75', () => { - it('returns major', () => { - expect(getSeverity(60)).toEqual(Severity.major); - }); - }); - - describe('when score is 75 or more', () => { - it('returns critical', () => { - expect(getSeverity(100)).toEqual(Severity.critical); - }); - }); -}); diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 5d80ee6381267..dc5731e88083c 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -5,89 +5,31 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiTheme } from '../../../legacy/common/eui_styled_components'; +import { ANOMALY_SEVERITY } from '../../ml/common'; +import { + getSeverityType, + getSeverityColor as mlGetSeverityColor, +} from '../../ml/common'; +import { ServiceHealthStatus } from './service_health_status'; export interface ServiceAnomalyStats { transactionType?: string; anomalyScore?: number; actualValue?: number; jobId?: string; + healthStatus: ServiceHealthStatus; } -export enum Severity { - critical = 'critical', - major = 'major', - minor = 'minor', - warning = 'warning', -} - -// TODO: Replace with `getSeverity` from: -// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 -export function getSeverity(score?: number) { - if (typeof score !== 'number') { - return undefined; - } else if (score < 25) { - return Severity.warning; - } else if (score >= 25 && score < 50) { - return Severity.minor; - } else if (score >= 50 && score < 75) { - return Severity.major; - } else if (score >= 75) { - return Severity.critical; - } else { - return undefined; +export function getSeverity(score: number | undefined) { + if (score === undefined) { + return ANOMALY_SEVERITY.UNKNOWN; } -} -export function getSeverityColor(theme: EuiTheme, severity?: Severity) { - switch (severity) { - case Severity.warning: - return theme.eui.euiColorVis0; - case Severity.minor: - case Severity.major: - return theme.eui.euiColorVis5; - case Severity.critical: - return theme.eui.euiColorVis9; - default: - return; - } + return getSeverityType(score); } -export function getSeverityLabel(severity?: Severity) { - switch (severity) { - case Severity.critical: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.critical', - { - defaultMessage: 'Critical', - } - ); - - case Severity.major: - case Severity.minor: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.warning', - { - defaultMessage: 'Warning', - } - ); - - case Severity.warning: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.healthy', - { - defaultMessage: 'Healthy', - } - ); - - default: - return i18n.translate( - 'xpack.apm.servicesTable.serviceHealthStatus.unknown', - { - defaultMessage: 'Unknown', - } - ); - } +export function getSeverityColor(score: number) { + return mlGetSeverityColor(score); } export const ML_ERRORS = { diff --git a/x-pack/plugins/apm/common/custom_link/index.ts b/x-pack/plugins/apm/common/custom_link/index.ts new file mode 100644 index 0000000000000..bc0ffefd79c4d --- /dev/null +++ b/x-pack/plugins/apm/common/custom_link/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const INVALID_LICENSE = i18n.translate( + 'xpack.apm.settings.customizeUI.customLink.license.text', + { + defaultMessage: + "To create custom links, you must be subscribed to an Elastic Gold license or above. With it, you'll have the ability to create custom links to improve your workflow when analyzing your services.", + } +); diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 612cb18bbe190..cc6a1fffb2288 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -79,6 +79,10 @@ export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; export const METRIC_SYSTEM_TOTAL_MEMORY = 'system.memory.total'; export const METRIC_SYSTEM_CPU_PERCENT = 'system.cpu.total.norm.pct'; export const METRIC_PROCESS_CPU_PERCENT = 'system.process.cpu.total.norm.pct'; +export const METRIC_CGROUP_MEMORY_LIMIT_BYTES = + 'system.process.cgroup.memory.mem.limit.bytes'; +export const METRIC_CGROUP_MEMORY_USAGE_BYTES = + 'system.process.cgroup.memory.mem.usage.bytes'; export const METRIC_JAVA_HEAP_MEMORY_MAX = 'jvm.memory.heap.max'; export const METRIC_JAVA_HEAP_MEMORY_COMMITTED = 'jvm.memory.heap.committed'; diff --git a/x-pack/plugins/apm/common/service_health_status.ts b/x-pack/plugins/apm/common/service_health_status.ts new file mode 100644 index 0000000000000..468f06ab97af8 --- /dev/null +++ b/x-pack/plugins/apm/common/service_health_status.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ANOMALY_SEVERITY } from '../../ml/common'; + +import { EuiTheme } from '../../../legacy/common/eui_styled_components'; + +export enum ServiceHealthStatus { + healthy = 'healthy', + critical = 'critical', + warning = 'warning', + unknown = 'unknown', +} + +export function getServiceHealthStatus({ + severity, +}: { + severity: ANOMALY_SEVERITY; +}) { + switch (severity) { + case ANOMALY_SEVERITY.CRITICAL: + case ANOMALY_SEVERITY.MAJOR: + return ServiceHealthStatus.critical; + + case ANOMALY_SEVERITY.MINOR: + case ANOMALY_SEVERITY.WARNING: + return ServiceHealthStatus.warning; + + case ANOMALY_SEVERITY.LOW: + return ServiceHealthStatus.healthy; + + case ANOMALY_SEVERITY.UNKNOWN: + return ServiceHealthStatus.unknown; + } +} + +export function getServiceHealthStatusColor( + theme: EuiTheme, + status: ServiceHealthStatus +) { + switch (status) { + case ServiceHealthStatus.healthy: + return theme.eui.euiColorVis0; + case ServiceHealthStatus.warning: + return theme.eui.euiColorVis5; + case ServiceHealthStatus.critical: + return theme.eui.euiColorVis9; + case ServiceHealthStatus.unknown: + return theme.eui.euiColorMediumShade; + } +} + +export function getServiceHealthStatusLabel(status: ServiceHealthStatus) { + switch (status) { + case ServiceHealthStatus.critical: + return i18n.translate('xpack.apm.serviceHealthStatus.critical', { + defaultMessage: 'Critical', + }); + + case ServiceHealthStatus.warning: + return i18n.translate('xpack.apm.serviceHealthStatus.warning', { + defaultMessage: 'Warning', + }); + + case ServiceHealthStatus.healthy: + return i18n.translate('xpack.apm.serviceHealthStatus.healthy', { + defaultMessage: 'Healthy', + }); + + case ServiceHealthStatus.unknown: + return i18n.translate('xpack.apm.serviceHealthStatus.unknown', { + defaultMessage: 'Unknown', + }); + } +} diff --git a/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts index 1956f1c2d9f0d..462304a959102 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts @@ -11,14 +11,19 @@ export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage( url: string, - dateRange: { to: string; from: string } + dateRange: { to: string; from: string }, + selectedService?: string ) { const username = Cypress.env('elasticsearch_username'); const password = Cypress.env('elasticsearch_password'); cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + let fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + + if (selectedService) { + fullUrl += `&serviceName=${selectedService}`; + } cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index a57241a197ca4..461e2960c5e02 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -15,10 +15,14 @@ Given(`a user browses the APM UI application for RUM Data`, () => { // open service overview page const RANGE_FROM = 'now-24h'; const RANGE_TO = 'now'; - loginAndWaitForPage(`/app/csm`, { - from: RANGE_FROM, - to: RANGE_TO, - }); + loginAndWaitForPage( + `/app/csm`, + { + from: RANGE_FROM, + to: RANGE_TO, + }, + 'client' + ); }); Then(`should have correct client metrics`, () => { diff --git a/x-pack/plugins/apm/e2e/yarn.lock b/x-pack/plugins/apm/e2e/yarn.lock index 936294052aa7b..fc63189e97ea3 100644 --- a/x-pack/plugins/apm/e2e/yarn.lock +++ b/x-pack/plugins/apm/e2e/yarn.lock @@ -5494,10 +5494,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.9.6: - version "3.9.6" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.6.tgz#8f3e0198a34c3ae17091b35571d3afd31999365a" - integrity sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw== +typescript@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" + integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== umd@^3.0.0: version "3.0.3" diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 8aa4417580337..bdef0f9786a3f 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -23,13 +23,19 @@ ], "server": true, "ui": true, - "configPath": ["xpack", "apm"], - "extraPublicDirs": ["public/style/variables"], + "configPath": [ + "xpack", + "apm" + ], + "extraPublicDirs": [ + "public/style/variables" + ], "requiredBundles": [ "kibanaReact", "kibanaUtils", "observability", "home", - "maps" + "maps", + "ml" ] } diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx similarity index 83% rename from x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx index 632d53a9c63b6..c30cef7210a43 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.stories.tsx @@ -6,14 +6,14 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; -import { ErrorRateAlertTrigger } from '.'; +import { ErrorCountAlertTrigger } from '.'; import { ApmPluginContextValue } from '../../../context/ApmPluginContext'; import { mockApmPluginContextValue, MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; -storiesOf('app/ErrorRateAlertTrigger', module).add( +storiesOf('app/ErrorCountAlertTrigger', module).add( 'example', () => { const params = { @@ -26,7 +26,7 @@ storiesOf('app/ErrorRateAlertTrigger', module).add( value={(mockApmPluginContextValue as unknown) as ApmPluginContextValue} >
- undefined} setAlertProperty={() => undefined} @@ -37,7 +37,7 @@ storiesOf('app/ErrorRateAlertTrigger', module).add( }, { info: { - propTablesExclude: [ErrorRateAlertTrigger, MockApmPluginContextWrapper], + propTablesExclude: [ErrorCountAlertTrigger, MockApmPluginContextWrapper], source: false, }, } diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx similarity index 54% rename from x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx index 7b284696477f3..a465b90e7bf05 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ErrorCountAlertTrigger/index.tsx @@ -3,36 +3,33 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiFieldNumber, EuiSelect } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; -import { isFinite } from 'lodash'; import React from 'react'; import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; -import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; -import { - ENVIRONMENT_ALL, - getEnvironmentLabel, -} from '../../../../common/environment_filter_values'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { useEnvironments } from '../../../hooks/useEnvironments'; import { useUrlParams } from '../../../hooks/useUrlParams'; +import { EnvironmentField, ServiceField, IsAboveField } from '../fields'; import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; -import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; -export interface ErrorRateAlertTriggerParams { +export interface AlertParams { windowSize: number; windowUnit: string; threshold: number; + serviceName: string; environment: string; } interface Props { - alertParams: ErrorRateAlertTriggerParams; + alertParams: AlertParams; setAlertParams: (key: string, value: any) => void; setAlertProperty: (key: string, value: any) => void; } -export function ErrorRateAlertTrigger(props: Props) { +export function ErrorCountAlertTrigger(props: Props) { const { setAlertParams, setAlertProperty, alertParams } = props; const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams } = useUrlParams(); @@ -51,45 +48,20 @@ export function ErrorRateAlertTrigger(props: Props) { ...alertParams, }; - const threshold = isFinite(params.threshold) ? params.threshold : ''; - const fields = [ - - - setAlertParams( - 'environment', - e.target.value as ErrorRateAlertTriggerParams['environment'] - ) - } - compressed - /> - , - , + setAlertParams('environment', e.target.value)} + />, + - - setAlertParams('threshold', parseInt(e.target.value, 10)) - } - compressed - append={i18n.translate('xpack.apm.errorRateAlertTrigger.errors', { - defaultMessage: 'errors', - })} - /> - , + onChange={(value) => setAlertParams('threshold', value)} + />, setAlertParams('windowSize', windowSize || '') @@ -108,7 +80,7 @@ export function ErrorRateAlertTrigger(props: Props) { return ( void; setAlertProperty: (key: string, value: any) => void; } export function TransactionDurationAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; - const { serviceName } = alertParams; const { urlParams } = useUrlParams(); - const transactionTypes = useServiceTransactionTypes(urlParams); - - const { start, end } = urlParams; + const { serviceName } = useParams<{ serviceName?: string }>(); + const { start, end, transactionType } = urlParams; const { environmentOptions } = useEnvironments({ serviceName, start, end }); - if (!transactionTypes.length) { + if (!transactionTypes.length || !serviceName) { return null; } @@ -57,7 +77,9 @@ export function TransactionDurationAlertTrigger(props: Props) { aggregationType: 'avg', windowSize: 5, windowUnit: 'm', - transactionType: transactionTypes[0], + + // use the current transaction type or default to the first in the list + transactionType: transactionType || transactionTypes[0], environment: urlParams.environment || ENVIRONMENT_ALL.value, }; @@ -67,47 +89,17 @@ export function TransactionDurationAlertTrigger(props: Props) { }; const fields = [ - - - setAlertParams('environment', e.target.value as Params['environment']) - } - compressed - /> - , - - { - return { - text: key, - value: key, - }; - })} - onChange={(e) => - setAlertParams( - 'transactionType', - e.target.value as Params['transactionType'] - ) - } - compressed - /> - , + , + setAlertParams('environment', e.target.value)} + />, + ({ text: key, value: key }))} + onChange={(e) => setAlertParams('transactionType', e.target.value)} + />, - setAlertParams( - 'aggregationType', - e.target.value as Params['aggregationType'] - ) - } - compressed - /> - , - - setAlertParams('threshold', e.target.value)} - append={i18n.translate('xpack.apm.transactionDurationAlertTrigger.ms', { - defaultMessage: 'ms', - })} + onChange={(e) => setAlertParams('aggregationType', e.target.value)} compressed /> , + setAlertParams('threshold', value)} + />, setAlertParams('windowSize', timeWindowSize || '') diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx new file mode 100644 index 0000000000000..468d08339431c --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { getSeverityColor } from '../../../../common/anomaly_detection'; +import { + AnomalyAlertSeverityType, + ANOMALY_ALERT_SEVERITY_TYPES, +} from '../../../../common/alert_types'; + +export function AnomalySeverity({ type }: { type: AnomalyAlertSeverityType }) { + const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( + (option) => option.type === type + )!; + return ( + + {selectedOption.label} + + ); +} + +interface Props { + onChange: (value: AnomalyAlertSeverityType) => void; + value: AnomalyAlertSeverityType; +} + +export function SelectAnomalySeverity({ onChange, value }: Props) { + return ( + ({ + value: option.type, + inputDisplay: , + dropdownDisplay: ( + <> + + + +

+ +

+
+ + ), + }))} + valueOfSelected={value} + onChange={(selectedValue: AnomalyAlertSeverityType) => { + onChange(selectedValue); + }} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx similarity index 70% rename from x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx rename to x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx index 20e0a3f27c4a4..ca1f55e9d391a 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/TransactionDurationAnomalyAlertTrigger/index.tsx @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiExpression, EuiSelect } from '@elastic/eui'; + +import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { ANOMALY_SEVERITY } from '../../../../../ml/common'; import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; import { useEnvironments } from '../../../hooks/useEnvironments'; import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; @@ -16,14 +18,16 @@ import { AnomalySeverity, SelectAnomalySeverity, } from './SelectAnomalySeverity'; -import { - ENVIRONMENT_ALL, - getEnvironmentLabel, -} from '../../../../common/environment_filter_values'; +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; +import { + EnvironmentField, + ServiceField, + TransactionTypeField, +} from '../fields'; interface Params { windowSize: number; @@ -31,7 +35,11 @@ interface Params { serviceName: string; transactionType: string; environment: string; - anomalyScore: 0 | 25 | 50 | 75; + anomalySeverityType: + | ANOMALY_SEVERITY.CRITICAL + | ANOMALY_SEVERITY.MAJOR + | ANOMALY_SEVERITY.MINOR + | ANOMALY_SEVERITY.WARNING; } interface Props { @@ -42,9 +50,9 @@ interface Props { export function TransactionDurationAnomalyAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; - const { serviceName } = alertParams; const { urlParams } = useUrlParams(); const transactionTypes = useServiceTransactionTypes(urlParams); + const { serviceName } = useParams<{ serviceName?: string }>(); const { start, end } = urlParams; const { environmentOptions } = useEnvironments({ serviceName, start, end }); const supportedTransactionTypes = transactionTypes.filter((transactionType) => @@ -55,13 +63,16 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { return null; } + // 'page-load' for RUM, 'request' otherwise + const transactionType = supportedTransactionTypes[0]; + const defaults: Params = { windowSize: 15, windowUnit: 'm', - transactionType: supportedTransactionTypes[0], // 'page-load' for RUM, 'request' otherwise + transactionType, serviceName, environment: urlParams.environment || ENVIRONMENT_ALL.value, - anomalyScore: 75, + anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, }; const params = { @@ -70,33 +81,15 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { }; const fields = [ - , + , + setAlertParams('environment', e.target.value)} />, - setAlertParams('environment', e.target.value)} - compressed - /> - , - } + value={} title={i18n.translate( 'xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity', { @@ -105,9 +98,9 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { )} > { - setAlertParams('anomalyScore', value); + setAlertParams('anomalySeverityType', value); }} /> , diff --git a/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx new file mode 100644 index 0000000000000..4dbf4dc10a907 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/TransactionErrorRateAlertTrigger/index.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useParams } from 'react-router-dom'; +import React from 'react'; +import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; +import { ALERT_TYPES_CONFIG, AlertType } from '../../../../common/alert_types'; +import { useEnvironments } from '../../../hooks/useEnvironments'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; + +import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { + ServiceField, + TransactionTypeField, + EnvironmentField, + IsAboveField, +} from '../fields'; + +interface AlertParams { + windowSize: number; + windowUnit: string; + threshold: number; + serviceName: string; + transactionType: string; + environment: string; +} + +interface Props { + alertParams: AlertParams; + setAlertParams: (key: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; +} + +export function TransactionErrorRateAlertTrigger(props: Props) { + const { setAlertParams, alertParams, setAlertProperty } = props; + const { urlParams } = useUrlParams(); + const transactionTypes = useServiceTransactionTypes(urlParams); + const { serviceName } = useParams<{ serviceName?: string }>(); + const { start, end, transactionType } = urlParams; + const { environmentOptions } = useEnvironments({ serviceName, start, end }); + + if (!transactionTypes.length || !serviceName) { + return null; + } + + const defaultParams = { + threshold: 30, + windowSize: 5, + windowUnit: 'm', + transactionType: transactionType || transactionTypes[0], + environment: urlParams.environment || ENVIRONMENT_ALL.value, + }; + + const params = { + ...defaultParams, + ...alertParams, + }; + + const fields = [ + , + setAlertParams('environment', e.target.value)} + />, + ({ text: key, value: key }))} + onChange={(e) => setAlertParams('transactionType', e.target.value)} + />, + setAlertParams('threshold', value)} + />, + + setAlertParams('windowSize', timeWindowSize || '') + } + onChangeWindowUnit={(timeWindowUnit) => + setAlertParams('windowUnit', timeWindowUnit) + } + timeWindowSize={params.windowSize} + timeWindowUnit={params.windowUnit} + errors={{ + timeWindowSize: [], + timeWindowUnit: [], + }} + />, + ]; + + return ( + + ); +} + +// Default export is required for React.lazy loading +// +// eslint-disable-next-line import/no-default-export +export default TransactionErrorRateAlertTrigger; diff --git a/x-pack/plugins/apm/public/components/alerting/fields.tsx b/x-pack/plugins/apm/public/components/alerting/fields.tsx new file mode 100644 index 0000000000000..e145d03671a18 --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/fields.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSelect, EuiExpression, EuiFieldNumber } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelectOption } from '@elastic/eui'; +import { getEnvironmentLabel } from '../../../common/environment_filter_values'; +import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression'; + +export function ServiceField({ value }: { value?: string }) { + return ( + + ); +} + +export function EnvironmentField({ + currentValue, + options, + onChange, +}: { + currentValue: string; + options: EuiSelectOption[]; + onChange: (event: React.ChangeEvent) => void; +}) { + return ( + + + + ); +} + +export function TransactionTypeField({ + currentValue, + options, + onChange, +}: { + currentValue: string; + options?: EuiSelectOption[]; + onChange?: (event: React.ChangeEvent) => void; +}) { + const label = i18n.translate('xpack.apm.alerting.fields.type', { + defaultMessage: 'Type', + }); + + if (!options || options.length === 1) { + return ; + } + + return ( + + + + ); +} + +export function IsAboveField({ + value, + unit, + onChange, + step, +}: { + value: number; + unit: string; + onChange: (value: number) => void; + step?: number; +}) { + return ( + + onChange(parseInt(e.target.value, 10))} + append={unit} + compressed + step={step} + /> + + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts new file mode 100644 index 0000000000000..c0a1955e2cc8a --- /dev/null +++ b/x-pack/plugins/apm/public/components/alerting/register_apm_alerts.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { lazy } from 'react'; +import { AlertType } from '../../../common/alert_types'; +import { ApmPluginStartDeps } from '../../plugin'; + +export function registerApmAlerts( + alertTypeRegistry: ApmPluginStartDeps['triggers_actions_ui']['alertTypeRegistry'] +) { + alertTypeRegistry.register({ + id: AlertType.ErrorCount, + name: i18n.translate('xpack.apm.alertTypes.errorCount', { + defaultMessage: 'Error count threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy(() => import('./ErrorCountAlertTrigger')), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionDuration, + name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { + defaultMessage: 'Transaction duration threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionDurationAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionErrorRate, + name: i18n.translate('xpack.apm.alertTypes.transactionErrorRate', { + defaultMessage: 'Transaction error rate threshold', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionErrorRateAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); + + alertTypeRegistry.register({ + id: AlertType.TransactionDurationAnomaly, + name: i18n.translate('xpack.apm.alertTypes.transactionDurationAnomaly', { + defaultMessage: 'Transaction duration anomaly', + }), + iconClass: 'bell', + alertParamsExpression: lazy( + () => import('./TransactionDurationAnomalyAlertTrigger') + ), + validate: () => ({ + errors: [], + }), + requiresAppContext: true, + }); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx index 91ce57c78b993..626c2124d421e 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx @@ -75,8 +75,9 @@ export function BreakdownFilter({ const onOptionChange = (value: string) => { if (value === NO_BREAKDOWN) { onBreakdownChange(null); + } else { + onBreakdownChange(items.find(({ fieldName }) => fieldName === value)!); } - onBreakdownChange(items.find(({ fieldName }) => fieldName === value)!); }; return ( diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index c832ec9fcc0d0..79cd1c5753ae5 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -18,6 +18,7 @@ import { TooltipValueFormatter, DARK_THEME, LIGHT_THEME, + Fit, } from '@elastic/charts'; import { EUI_CHARTS_THEME_DARK, @@ -115,12 +116,14 @@ export function PageLoadDistChart({ tickFormat={(d) => numeral(d).format('0.0') + '%'} /> {breakdown && ( >; + data?: { + topItems: string[]; + items: Array>; + }; loading: boolean; } @@ -68,15 +71,9 @@ export function PageViewsChart({ data, loading }: Props) { }); }; - let breakdownAccessors: Set = new Set(); - if (data && data.length > 0) { - data.forEach((item) => { - breakdownAccessors = new Set([ - ...Array.from(breakdownAccessors), - ...Object.keys(item).filter((key) => key !== 'x'), - ]); - }); - } + const breakdownAccessors = data?.topItems?.length ? data?.topItems : ['y']; + + const [darkMode] = useUiSetting$('theme:darkMode'); const customSeriesNaming: SeriesNameFn = ({ yAccessor }) => { if (yAccessor === 'y') { @@ -86,8 +83,6 @@ export function PageViewsChart({ data, loading }: Props) { return yAccessor; }; - const [darkMode] = useUiSetting$('theme:darkMode'); - return ( {(!loading || data) && ( @@ -115,7 +110,8 @@ export function PageViewsChart({ data, loading }: Props) { id="page_views" title={I18LABELS.pageViews} position={Position.Left} - tickFormat={(d) => numeral(d).format('0a')} + tickFormat={(d) => numeral(d).format('0')} + labelFormat={(d) => numeral(d).format('0a')} /> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index f54a54211359c..1edfd724dadd7 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -26,7 +26,8 @@ export function ClientMetrics() { const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + const { serviceName } = uiFilters; + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum/client-metrics', params: { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx index b027609fd3a7f..0135f0b369537 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/CoreVitals/index.tsx @@ -28,7 +28,7 @@ export function CoreVitals({ data, loading }: Props) { ))} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index b4a350d41557f..5c9a636adec8f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -24,7 +24,7 @@ export function formatToSec( const valueInMs = Number(value ?? 0) / (fromUnit === 'MicroSec' ? 1000 : 1); if (valueInMs < 1000) { - return valueInMs + ' ms'; + return valueInMs.toFixed(0) + ' ms'; } return (valueInMs / 1000).toFixed(2) + ' s'; } @@ -69,7 +69,7 @@ export function KeyUXMetrics({ data, loading }: Props) { diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx index bb5d37a10fb33..94c3acfaa9727 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/index.tsx @@ -23,7 +23,7 @@ export interface UXMetrics { cls: string; fid: string; lcp: string; - tbt: string; + tbt: number; fcp: number; lcpRanks: number[]; fidRanks: number[]; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx index 790be81bb65c0..388a8824bc73d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/__tests__/EmbeddedMap.test.tsx @@ -8,7 +8,7 @@ import { render } from 'enzyme'; import React from 'react'; import { EmbeddedMap } from '../EmbeddedMap'; -import { KibanaContextProvider } from '../../../../../../../security_solution/public/common/lib/kibana'; +import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public'; import { embeddablePluginMock } from '../../../../../../../../../src/plugins/embeddable/public/mocks'; describe('Embedded Map', () => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx index 27c4a37e09c00..c11bfdeae945b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -24,9 +24,13 @@ const transactionDurationLabel = i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.transactionDuration', { defaultMessage: 'Transaction duration' } ); -const errorRateLabel = i18n.translate( - 'xpack.apm.serviceDetails.alertsMenu.errorRate', - { defaultMessage: 'Error rate' } +const transactionErrorRateLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionErrorRate', + { defaultMessage: 'Transaction error rate' } +); +const errorCountLabel = i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.errorCount', + { defaultMessage: 'Error count' } ); const createThresholdAlertLabel = i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert', @@ -38,8 +42,10 @@ const createAnomalyAlertAlertLabel = i18n.translate( ); const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID = - 'create_transaction_duration'; -const CREATE_ERROR_RATE_ALERT_PANEL_ID = 'create_error_rate'; + 'create_transaction_duration_panel'; +const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID = + 'create_transaction_error_rate_panel'; +const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel'; interface Props { canReadAlerts: boolean; @@ -77,7 +83,14 @@ export function AlertIntegrations(props: Props) { name: transactionDurationLabel, panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, }, - { name: errorRateLabel, panel: CREATE_ERROR_RATE_ALERT_PANEL_ID }, + { + name: transactionErrorRateLabel, + panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + }, + { + name: errorCountLabel, + panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + }, ] : []), ...(canReadAlerts @@ -96,10 +109,13 @@ export function AlertIntegrations(props: Props) { : []), ], }, + + // transaction duration panel { id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID, title: transactionDurationLabel, items: [ + // threshold alerts { name: createThresholdAlertLabel, onClick: () => { @@ -107,6 +123,8 @@ export function AlertIntegrations(props: Props) { setPopoverOpen(false); }, }, + + // anomaly alerts ...(canReadAnomalies ? [ { @@ -120,14 +138,32 @@ export function AlertIntegrations(props: Props) { : []), ], }, + + // transaction error rate panel + { + id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID, + title: transactionErrorRateLabel, + items: [ + // threshold alerts + { + name: createThresholdAlertLabel, + onClick: () => { + setAlertType(AlertType.TransactionErrorRate); + setPopoverOpen(false); + }, + }, + ], + }, + + // error alerts panel { - id: CREATE_ERROR_RATE_ALERT_PANEL_ID, - title: errorRateLabel, + id: CREATE_ERROR_COUNT_ALERT_PANEL_ID, + title: errorCountLabel, items: [ { name: createThresholdAlertLabel, onClick: () => { - setAlertType(AlertType.ErrorRate); + setAlertType(AlertType.ErrorCount); setPopoverOpen(false); }, }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx deleted file mode 100644 index b468470e3a17d..0000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiBetaBadge } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import styled from 'styled-components'; - -const BetaBadgeContainer = styled.div` - right: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - position: absolute; - top: ${({ theme }) => theme.eui.gutterTypes.gutterSmall}; - z-index: 1; /* The element containing the cytoscape canvas has z-index = 0. */ -`; - -export function BetaBadge() { - return ( - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index 5699d0b56219b..c1192f5f18274 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -14,6 +14,10 @@ import { EuiIconTip, EuiHealth, } from '@elastic/eui'; +import { + getServiceHealthStatus, + getServiceHealthStatusColor, +} from '../../../../../common/service_health_status'; import { useTheme } from '../../../../hooks/useTheme'; import { fontSize, px } from '../../../../style/variables'; import { asInteger, asDuration } from '../../../../utils/formatters'; @@ -22,7 +26,6 @@ import { popoverWidth } from '../cytoscapeOptions'; import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { getSeverity, - getSeverityColor, ServiceAnomalyStats, } from '../../../../../common/anomaly_detection'; @@ -59,13 +62,15 @@ export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { const theme = useTheme(); const anomalyScore = serviceAnomalyStats?.anomalyScore; - const anomalySeverity = getSeverity(anomalyScore); + const severity = getSeverity(anomalyScore); const actualValue = serviceAnomalyStats?.actualValue; const mlJobId = serviceAnomalyStats?.jobId; const transactionType = serviceAnomalyStats?.transactionType ?? TRANSACTION_REQUEST; const hasAnomalyDetectionScore = anomalyScore !== undefined; + const healthStatus = getServiceHealthStatus({ severity }); + return ( <>
@@ -81,7 +86,9 @@ export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { - + {ANOMALY_DETECTION_SCORE_METRIC} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 1ac7157cc2aad..61ac9bd7cd54c 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -5,26 +5,26 @@ */ import cytoscape from 'cytoscape'; import { CSSProperties } from 'react'; +import { + getServiceHealthStatusColor, + ServiceHealthStatus, +} from '../../../../common/service_health_status'; import { SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE, } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; import { defaultIcon, iconForNode } from './icons'; -import { - getSeverity, - getSeverityColor, - ServiceAnomalyStats, - Severity, -} from '../../../../common/anomaly_detection'; +import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; export const popoverWidth = 280; -function getNodeSeverity(el: cytoscape.NodeSingular) { +function getServiceAnomalyStats(el: cytoscape.NodeSingular) { const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data( 'serviceAnomalyStats' ); - return getSeverity(serviceAnomalyStats?.anomalyScore); + + return serviceAnomalyStats; } function getBorderColorFn( @@ -32,10 +32,11 @@ function getBorderColorFn( ): cytoscape.Css.MapperFunction { return (el: cytoscape.NodeSingular) => { const hasAnomalyDetectionJob = el.data('serviceAnomalyStats') !== undefined; - const nodeSeverity = getNodeSeverity(el); + const anomalyStats = getServiceAnomalyStats(el); if (hasAnomalyDetectionJob) { - return ( - getSeverityColor(theme, nodeSeverity) || theme.eui.euiColorMediumShade + return getServiceHealthStatusColor( + theme, + anomalyStats?.healthStatus ?? ServiceHealthStatus.unknown ); } if (el.hasClass('primary') || el.selected()) { @@ -49,8 +50,8 @@ const getBorderStyle: cytoscape.Css.MapperFunction< cytoscape.NodeSingular, cytoscape.Css.LineStyle > = (el: cytoscape.NodeSingular) => { - const nodeSeverity = getNodeSeverity(el); - if (nodeSeverity === Severity.critical) { + const status = getServiceAnomalyStats(el)?.healthStatus; + if (status === ServiceHealthStatus.critical) { return 'double'; } else { return 'solid'; @@ -58,11 +59,11 @@ const getBorderStyle: cytoscape.Css.MapperFunction< }; function getBorderWidth(el: cytoscape.NodeSingular) { - const nodeSeverity = getNodeSeverity(el); + const status = getServiceAnomalyStats(el)?.healthStatus; - if (nodeSeverity === Severity.minor || nodeSeverity === Severity.major) { + if (status === ServiceHealthStatus.warning) { return 4; - } else if (nodeSeverity === Severity.critical) { + } else if (status === ServiceHealthStatus.critical) { return 8; } else { return 4; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index cb5a57e9ab9fb..bb450131bdfb8 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useTheme } from '../../../hooks/useTheme'; +import React from 'react'; +import { useTrackPageview } from '../../../../../observability/public'; import { invalidLicenseMessage, isActivePlatinumLicense, } from '../../../../common/service_map'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; +import { useTheme } from '../../../hooks/useTheme'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; import { LicensePrompt } from '../../shared/LicensePrompt'; @@ -22,8 +23,6 @@ import { getCytoscapeDivStyle } from './cytoscapeOptions'; import { EmptyBanner } from './EmptyBanner'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; -import { BetaBadge } from './BetaBadge'; -import { useTrackPageview } from '../../../../../observability/public'; interface ServiceMapProps { serviceName?: string; @@ -80,7 +79,6 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { style={getCytoscapeDivStyle(theme)} > - {serviceName && } @@ -96,7 +94,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { grow={false} style={{ width: 600, textAlign: 'center' as const }} > - + ); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx index 94353080bc7d5..c6be0a352ef66 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/HealthBadge.tsx @@ -6,20 +6,22 @@ import React from 'react'; import { EuiBadge } from '@elastic/eui'; import { - getSeverityColor, - getSeverityLabel, - Severity, -} from '../../../../../common/anomaly_detection'; + getServiceHealthStatusColor, + getServiceHealthStatusLabel, + ServiceHealthStatus, +} from '../../../../../common/service_health_status'; import { useTheme } from '../../../../hooks/useTheme'; -export function HealthBadge({ severity }: { severity?: Severity }) { +export function HealthBadge({ + healthStatus, +}: { + healthStatus: ServiceHealthStatus; +}) { const theme = useTheme(); - const unknownColor = theme.eui.euiColorLightShade; - return ( - - {getSeverityLabel(severity)} + + {getServiceHealthStatusLabel(healthStatus)} ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js index 519d74827097b..7c306c16cca1f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/List.test.js @@ -9,6 +9,7 @@ import { shallow } from 'enzyme'; import { ServiceList, SERVICE_COLUMNS } from '../index'; import props from './props.json'; import { mockMoment } from '../../../../../utils/testHelpers'; +import { ServiceHealthStatus } from '../../../../../../common/service_health_status'; describe('ServiceOverview -> List', () => { beforeAll(() => { @@ -52,25 +53,28 @@ describe('ServiceOverview -> List', () => { describe('without ML data', () => { it('does not render health column', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); const columns = wrapper.props().columns; - expect(columns[0].field).not.toBe('severity'); + expect(columns[0].field).not.toBe('healthStatus'); }); }); describe('with ML data', () => { it('renders health column', () => { const wrapper = shallow( - + ({ + ...item, + healthStatus: ServiceHealthStatus.warning, + }))} + /> ); const columns = wrapper.props().columns; - expect(columns[0].field).toBe('severity'); + expect(columns[0].field).toBe('healthStatus'); }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap index da3f6ae89940a..e6a9823f3ee28 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/__test__/__snapshots__/List.test.js.snap @@ -1,6 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ServiceOverview -> List renders columns correctly 1`] = ``; +exports[`ServiceOverview -> List renders columns correctly 1`] = ` + +`; exports[`ServiceOverview -> List renders empty state 1`] = ` List renders empty state 1`] = ` } initialPageSize={50} initialSortDirection="desc" - initialSortField="severity" + initialSortField="healthStatus" items={Array []} sortFn={[Function]} /> @@ -106,7 +110,7 @@ exports[`ServiceOverview -> List renders with data 1`] = ` } initialPageSize={50} initialSortDirection="desc" - initialSortField="severity" + initialSortField="healthStatus" items={ Array [ Object { diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index ce256137481cb..4c7c0824a7c49 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import styled from 'styled-components'; import { ValuesType } from 'utility-types'; import { orderBy } from 'lodash'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { asPercent } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_services'; @@ -20,14 +21,12 @@ import { ManagedTable, ITableColumn } from '../../../shared/ManagedTable'; import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; import { TransactionOverviewLink } from '../../../shared/Links/apm/TransactionOverviewLink'; import { AgentIcon } from '../../../shared/AgentIcon'; -import { Severity } from '../../../../../common/anomaly_detection'; import { HealthBadge } from './HealthBadge'; import { ServiceListMetric } from './ServiceListMetric'; interface Props { items: ServiceListAPIResponse['items']; noItemsMessage?: React.ReactNode; - displayHealthStatus: boolean; } type ServiceListItem = ValuesType; @@ -53,14 +52,18 @@ const AppLink = styled(TransactionOverviewLink)` export const SERVICE_COLUMNS: Array> = [ { - field: 'severity', + field: 'healthStatus', name: i18n.translate('xpack.apm.servicesTable.healthColumnLabel', { defaultMessage: 'Health', }), width: px(unit * 6), sortable: true, - render: (_, { severity }) => { - return ; + render: (_, { healthStatus }) => { + return ( + + ); }, }, { @@ -172,40 +175,38 @@ export const SERVICE_COLUMNS: Array> = [ }, ]; -const SEVERITY_ORDER = [ - Severity.warning, - Severity.minor, - Severity.major, - Severity.critical, +const SERVICE_HEALTH_STATUS_ORDER = [ + ServiceHealthStatus.unknown, + ServiceHealthStatus.healthy, + ServiceHealthStatus.warning, + ServiceHealthStatus.critical, ]; -export function ServiceList({ - items, - displayHealthStatus, - noItemsMessage, -}: Props) { +export function ServiceList({ items, noItemsMessage }: Props) { + const displayHealthStatus = items.some((item) => 'healthStatus' in item); + const columns = displayHealthStatus ? SERVICE_COLUMNS - : SERVICE_COLUMNS.filter((column) => column.field !== 'severity'); + : SERVICE_COLUMNS.filter((column) => column.field !== 'healthStatus'); return ( { - // For severity, sort items by severity first, then by TPM + // For healthStatus, sort items by healthStatus first, then by TPM - return sortField === 'severity' + return sortField === 'healthStatus' ? orderBy( itemsToSort, [ (item) => { - return item.severity - ? SEVERITY_ORDER.indexOf(item.severity) + return item.healthStatus + ? SERVICE_HEALTH_STATUS_ORDER.indexOf(item.healthStatus) : -1; }, (item) => item.transactionsPerMinute?.value ?? 0, diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index e4ba1e36378d9..d8c8f25616560 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -10,6 +10,7 @@ import { merge } from 'lodash'; import React, { FunctionComponent, ReactChild } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { createKibanaReactContext } from 'src/plugins/kibana_react/public'; +import { ServiceHealthStatus } from '../../../../../common/service_health_status'; import { ServiceOverview } from '..'; import { EuiThemeProvider } from '../../../../../../observability/public'; import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; @@ -114,7 +115,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 200, avgResponseTime: 300, environments: ['test', 'dev'], - severity: 1, + healthStatus: ServiceHealthStatus.warning, }, { serviceName: 'My Go Service', @@ -123,7 +124,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 500, avgResponseTime: 600, environments: [], - severity: 10, + severity: ServiceHealthStatus.healthy, }, ], }); @@ -252,7 +253,7 @@ describe('Service Overview -> View', () => { errorsPerMinute: 200, avgResponseTime: 300, environments: ['test', 'dev'], - severity: 1, + healthStatus: ServiceHealthStatus.warning, }, ], }); diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index b56f7d6820274..40a2b6a5fa81b 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -153,8 +153,8 @@ NodeList [ > - Unknown + Warning @@ -223,30 +223,21 @@ NodeList [ class="euiTableCellContent euiTableCellContent--overflowingContent" > - - - test - - - - - dev + + 2 environments + @@ -435,7 +426,7 @@ NodeList [ > 'severity' in item); - return ( <> @@ -134,7 +132,6 @@ export function ServiceOverview() { ) ) : ( - + )} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 05ea585108c69..6e95df0dddd84 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -97,7 +97,7 @@ export function JobsList({ data, status, onAddEnvironments }: Props) { defaultMessage="To add anomaly detection to a new environment, create a machine learning job. Existing machine learning jobs can be managed in {mlJobsLink}." values={{ mlJobsLink: ( - + {i18n.translate( 'xpack.apm.settings.anomalyDetection.jobList.mlDescriptionText.mlJobsLinkText', { diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx index d166656db0672..3bf4807877428 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/index.tsx @@ -132,7 +132,7 @@ export function Waterfall({
{ setIsAccordionOpen((isOpen) => !isOpen); }} diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/ClientSideMonitoringCallout.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/ClientSideMonitoringCallout.tsx new file mode 100644 index 0000000000000..b6938b211994d --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/ClientSideMonitoringCallout.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; + +export function ClientSideMonitoringCallout() { + const { core } = useApmPluginContext(); + const clientSideMonitoringHref = core.http.basePath.prepend(`/app/csm`); + + return ( + + + {i18n.translate( + 'xpack.apm.transactionOverview.clientSideMonitoring.calloutText', + { + defaultMessage: + 'We are beyond excited to introduce a new experience for analyzing the user experience metrics specifically for your RUM services. It provides insights into the core vitals and visitor breakdown by browser and location. The app is always available in the left sidebar among the other Observability views.', + } + )} + + + + {i18n.translate( + 'xpack.apm.transactionOverview.clientSideMonitoring.linkLabel', + { defaultMessage: 'Take me there' } + )} + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 3e32b0ec23b13..7c887da6dc5e6 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -35,6 +35,8 @@ import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; +import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; +import { ClientSideMonitoringCallout } from './ClientSideMonitoringCallout'; function getRedirectLocation({ urlParams, @@ -125,6 +127,12 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { + {transactionType === TRANSACTION_PAGE_LOAD && ( + <> + + + + )} {environments.map((env) => ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 9ba4aab0e23d9..e6888c4cb60a2 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -41,7 +41,7 @@ describe('MLJobLink', () => { ); expect(href).toMatchInlineSnapshot( - `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now/w,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request),zoom:(from:now/w,to:now-4h)))"` + `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:!t,value:0),time:(from:now/w,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index da345e35c10b1..4eb886509805d 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -21,6 +21,6 @@ test('MLLink produces the correct URL', async () => { ); expect(href).toMatchInlineSnapshot( - `"/basepath/app/ml#/some/path?_g=(ml:(jobIds:!(something)),refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` + `"/basepath/app/ml#/some/path?_g=(ml:(jobIds:!(something)),refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))&mlManagement=(groupIds:!(apm))"` ); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx index d8ec212515c6f..93ee9e0db4864 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx @@ -39,7 +39,9 @@ export function MLLink({ children, path = '', query = {}, external }: Props) { const href = url.format({ pathname: core.http.basePath.prepend('/app/ml'), - hash: `${path}?_g=${rison.encode(risonQuery as RisonValue)}`, + hash: `${path}?_g=${rison.encode( + risonQuery as RisonValue + )}&mlManagement=${rison.encode({ groupIds: ['apm'] })}`, }); return ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts index 66f3903ba849b..d84f55af993aa 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.test.ts @@ -28,7 +28,7 @@ describe('useTimeSeriesExplorerHref', () => { }); expect(href).toMatchInlineSnapshot( - `"/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request),zoom:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z')))"` + `"/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(apm-production-485b-high_mean_transaction_duration)),refreshInterval:(pause:!t,value:10000),time:(from:'2020-07-29T17:27:29.000Z',to:'2020-07-29T18:45:00.000Z'))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-java,transaction.type:request)))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts index 3b60962d797ed..0cb87a4f515b6 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts @@ -39,7 +39,6 @@ export function useTimeSeriesExplorerHref({ 'service.name': serviceName, 'transaction.type': transactionType, }, - zoom: time, }, }), } diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx index 3deba69a25df2..cbf9ba009dce2 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx @@ -24,7 +24,7 @@ interface Props { function ServiceNameFilter({ loading, serviceNames }: Props) { const history = useHistory(); const { - urlParams: { serviceName }, + urlParams: { serviceName: selectedServiceName }, } = useUrlParams(); const options = serviceNames.map((type) => ({ @@ -47,10 +47,22 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { ); useEffect(() => { - if (!serviceName && serviceNames.length > 0) { - updateServiceName(serviceNames[0]); + if (serviceNames?.length > 0) { + // select first from the list + if (!selectedServiceName) { + updateServiceName(serviceNames[0]); + } + + // in case serviceName is cached from url and isn't present in current list + if (selectedServiceName && !serviceNames.includes(selectedServiceName)) { + updateServiceName(serviceNames[0]); + } + } + + if (selectedServiceName && serviceNames.length === 0 && !loading) { + updateServiceName(''); } - }, [serviceNames, serviceName, updateServiceName]); + }, [serviceNames, selectedServiceName, updateServiceName, loading]); return ( <> @@ -68,7 +80,7 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { isLoading={loading} data-cy="serviceNameFilter" options={options} - value={serviceName} + value={selectedServiceName} compressed={true} onChange={(event) => { updateServiceName(event.target.value); diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx index c502237235578..589b2dd27fbc4 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/Stackframe.test.tsx @@ -11,6 +11,12 @@ import { mountWithTheme } from '../../../../utils/testHelpers'; import { Stackframe as StackframeComponent } from '../Stackframe'; import stacktracesMock from './stacktraces.json'; +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { + return { + htmlIdGenerator: () => () => `generated-id`, + }; +}); + describe('Stackframe', () => { describe('when stackframe has source lines', () => { let wrapper: ReactWrapper; diff --git a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap index f87cc62d7809d..a5f8c40876540 100644 --- a/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/Stacktrace/__test__/__snapshots__/Stackframe.test.tsx.snap @@ -177,6 +177,7 @@ exports[`Stackframe when stackframe has source lines should render correctly 1`] aria-controls="test" aria-expanded={false} className="euiAccordion__button" + id="generated-id" onClick={[Function]} type="button" > diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx deleted file mode 100644 index 5bddfc67200b1..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; -import { - getSeverityColor, - Severity, -} from '../../../../common/anomaly_detection'; -import { useTheme } from '../../../hooks/useTheme'; - -type SeverityScore = 0 | 25 | 50 | 75; -const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75]; - -const anomalyScoreSeverityMap: { - [key in SeverityScore]: { label: string; severity: Severity }; -} = { - 0: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.warningLabel', { - defaultMessage: 'warning', - }), - severity: Severity.warning, - }, - 25: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.minorLabel', { - defaultMessage: 'minor', - }), - severity: Severity.minor, - }, - 50: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.majorLabel', { - defaultMessage: 'major', - }), - severity: Severity.major, - }, - 75: { - label: i18n.translate('xpack.apm.alerts.anomalySeverity.criticalLabel', { - defaultMessage: 'critical', - }), - severity: Severity.critical, - }, -}; - -export function AnomalySeverity({ - severityScore, -}: { - severityScore: SeverityScore; -}) { - const theme = useTheme(); - const { label, severity } = anomalyScoreSeverityMap[severityScore]; - const defaultColor = theme.eui.euiColorMediumShade; - const color = getSeverityColor(theme, severity) || defaultColor; - return ( - - {label} - - ); -} - -const getOption = (value: SeverityScore) => { - return { - value: value.toString(10), - inputDisplay: , - dropdownDisplay: ( - <> - - - -

- -

-
- - ), - }; -}; - -interface Props { - onChange: (value: SeverityScore) => void; - value: SeverityScore; -} - -export function SelectAnomalySeverity({ onChange, value }: Props) { - const options = ANOMALY_SCORES.map((anomalyScore) => getOption(anomalyScore)); - - return ( - { - const selectedAnomalyScore = parseInt( - selectedValue, - 10 - ) as SeverityScore; - onChange(selectedAnomalyScore); - }} - /> - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index 85d975870d9bc..e64357c085209 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -6,6 +6,7 @@ import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { max } from 'lodash'; import React, { useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { asPercent } from '../../../../../common/utils/formatters'; @@ -56,6 +57,7 @@ export function ErroneousTransactionsRateChart() { ); const errorRates = data?.erroneousTransactionsRate || []; + const maxRate = max(errorRates.map((errorRate) => errorRate.y)); return ( @@ -70,6 +72,7 @@ export function ErroneousTransactionsRateChart() { { - describe('render', () => { - it('renders', () => { - expect(() => - shallow( - - - - ) - ).not.toThrowError(); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx deleted file mode 100644 index 40caf35155918..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/BrowserLineChart.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { useAvgDurationByBrowser } from '../../../../hooks/useAvgDurationByBrowser'; -import { getDurationFormatter } from '../../../../utils/formatters'; -import { - getResponseTimeTickFormatter, - getResponseTimeTooltipFormatter, - getMaxY, -} from './helper'; -import { TransactionLineChart } from './TransactionLineChart'; - -export function BrowserLineChart() { - const { data } = useAvgDurationByBrowser(); - const maxY = getMaxY(data); - const formatter = getDurationFormatter(maxY); - const formatTooltipValue = getResponseTimeTooltipFormatter(formatter); - const tickFormatY = getResponseTimeTickFormatter(formatter); - - return ( - <> - - - {i18n.translate( - 'xpack.apm.metrics.pageLoadCharts.avgPageLoadByBrowser', - { - defaultMessage: 'Avg. page load duration distribution by browser', - } - )} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx deleted file mode 100644 index 69d4e8109dfbf..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/ChoroplethToolTip.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { asDuration, asInteger } from '../../../../../utils/formatters'; -import { fontSizes } from '../../../../../style/variables'; - -export function ChoroplethToolTip({ - name, - value, - docCount, -}: { - name: string; - value: number; - docCount: number; -}) { - return ( -
-
{name}
-
- {i18n.translate( - 'xpack.apm.metrics.durationByCountryMap.RegionMapChart.ToolTip.avgPageLoadDuration', - { - defaultMessage: 'Avg. page load duration:', - } - )} -
-
- {asDuration(value)} -
-
- ( - {i18n.translate( - 'xpack.apm.metrics.durationByCountryMap.RegionMapChart.ToolTip.countPageLoads', - { - values: { docCount: asInteger(docCount) }, - defaultMessage: '{docCount} page loads', - } - )} - ) -
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx deleted file mode 100644 index 965cb2ae4f50a..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/ChoroplethMap/index.tsx +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { - useState, - useEffect, - useRef, - useCallback, - useMemo, -} from 'react'; -import { Map, NavigationControl, Popup } from 'mapbox-gl'; -import 'mapbox-gl/dist/mapbox-gl.css'; -import { shade, tint } from 'polished'; -import { EuiTheme } from '../../../../../../../observability/public'; -import { useTheme } from '../../../../../hooks/useTheme'; -import { ChoroplethToolTip } from './ChoroplethToolTip'; - -interface ChoroplethItem { - key: string; - value: number; - docCount: number; -} - -interface Tooltip { - name: string; - value: number; - docCount: number; -} - -interface WorldCountryFeatureProperties { - name: string; - iso2: string; - iso3: string; -} - -interface Props { - items: ChoroplethItem[]; -} - -const CHOROPLETH_LAYER_ID = 'choropleth_layer'; -const CHOROPLETH_POLYGONS_SOURCE_ID = 'choropleth_polygons'; -const GEOJSON_KEY_PROPERTY = 'iso2'; -const MAPBOX_STYLE = - 'https://tiles.maps.elastic.co/styles/osm-bright-desaturated/style.json'; -const GEOJSON_SOURCE = - 'https://vector.maps.elastic.co/files/world_countries_v1.geo.json?elastic_tile_service_tos=agree&my_app_name=ems-landing&my_app_version=7.2.0'; - -export function getProgressionColor(scale: number, theme: EuiTheme) { - const baseColor = theme.eui.euiColorPrimary; - const adjustedScale = 0.75 * scale + 0.05; // prevents pure black & white as min/max colors. - if (adjustedScale < 0.5) { - return tint(adjustedScale * 2, baseColor); - } - if (adjustedScale > 0.5) { - return shade(1 - (adjustedScale - 0.5) * 2, baseColor); - } - return baseColor; -} - -const getMin = (items: ChoroplethItem[]) => - Math.min(...items.map((item) => item.value)); - -const getMax = (items: ChoroplethItem[]) => - Math.max(...items.map((item) => item.value)); - -export function ChoroplethMap(props: Props) { - const theme = useTheme(); - const { items } = props; - const containerRef = useRef(null); - const [map, setMap] = useState(null); - const popupRef = useRef(null); - const popupContainerRef = useRef(null); - const [tooltipState, setTooltipState] = useState(null); - const [min, max] = useMemo(() => [getMin(items), getMax(items)], [items]); - - // converts an item value to a scaled value between 0 and 1 - const getValueScale = useCallback( - (value: number) => (value - min) / (max - min), - [max, min] - ); - - const controlScrollZoomOnWheel = useCallback((event: WheelEvent) => { - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - } else { - event.stopPropagation(); - } - }, []); - - // side effect creates a new mouseover handler referencing new component state - // and replaces the old one stored in `updateTooltipStateOnMousemoveRef` - useEffect(() => { - const updateTooltipStateOnMousemove = (event: mapboxgl.MapMouseEvent) => { - const isMapQueryable = - map && - popupRef.current && - items.length && - map.getLayer(CHOROPLETH_LAYER_ID); - - if (!isMapQueryable) { - return; - } - (popupRef.current as Popup).setLngLat(event.lngLat); - const hoverFeatures = (map as Map).queryRenderedFeatures(event.point, { - layers: [CHOROPLETH_LAYER_ID], - }); - - if (tooltipState && hoverFeatures.length === 0) { - return setTooltipState(null); - } - - const featureProperties = hoverFeatures[0] - .properties as WorldCountryFeatureProperties; - - if (tooltipState && tooltipState.name === featureProperties.name) { - return; - } - - const item = items.find( - ({ key }) => - featureProperties && key === featureProperties[GEOJSON_KEY_PROPERTY] - ); - - if (item) { - return setTooltipState({ - name: featureProperties.name, - value: item.value, - docCount: item.docCount, - }); - } - - setTooltipState(null); - }; - updateTooltipStateOnMousemoveRef.current = updateTooltipStateOnMousemove; - }, [map, items, tooltipState]); - - const updateTooltipStateOnMousemoveRef = useRef( - (_event: mapboxgl.MapMouseEvent & mapboxgl.EventData) => {} - ); - - // initialization side effect, only runs once - useEffect(() => { - if (containerRef.current === null) { - return; - } - - // set up Map object - const mapboxMap = new Map({ - attributionControl: false, - container: containerRef.current, - dragRotate: false, - touchZoomRotate: false, - zoom: 0.85, - center: { lng: 0, lat: 30 }, - style: MAPBOX_STYLE, - }); - - mapboxMap.addControl( - new NavigationControl({ showCompass: false }), - 'top-left' - ); - - // set up Popup object - popupRef.current = new Popup({ - closeButton: false, - closeOnClick: false, - }); - - // always use the current handler which changes with component state - mapboxMap.on('mousemove', (...args) => - updateTooltipStateOnMousemoveRef.current(...args) - ); - mapboxMap.on('mouseout', () => { - setTooltipState(null); - }); - - // only scroll zoom when key is pressed - const canvasElement = mapboxMap.getCanvas(); - canvasElement.addEventListener('wheel', controlScrollZoomOnWheel); - - mapboxMap.on('load', () => { - mapboxMap.addSource(CHOROPLETH_POLYGONS_SOURCE_ID, { - type: 'geojson', - data: GEOJSON_SOURCE, - }); - setMap(mapboxMap); - }); - - // cleanup function called when component unmounts - return () => { - canvasElement.removeEventListener('wheel', controlScrollZoomOnWheel); - }; - }, [controlScrollZoomOnWheel]); - - // side effect replaces choropleth layer with new one on items changes - useEffect(() => { - if (!map) { - return; - } - - // find first symbol layer to place new layer in correct order - const symbolLayer = (map.getStyle().layers || []).find( - ({ type }) => type === 'symbol' - ); - - if (map.getLayer(CHOROPLETH_LAYER_ID)) { - map.removeLayer(CHOROPLETH_LAYER_ID); - } - - if (items.length === 0) { - return; - } - - const stops = items.map(({ key, value }) => [ - key, - getProgressionColor(getValueScale(value), theme), - ]); - - const fillColor: mapboxgl.FillPaint['fill-color'] = { - property: GEOJSON_KEY_PROPERTY, - stops, - type: 'categorical', - default: 'transparent', - }; - - map.addLayer( - { - id: CHOROPLETH_LAYER_ID, - type: 'fill', - source: CHOROPLETH_POLYGONS_SOURCE_ID, - layout: {}, - paint: { - 'fill-opacity': 0.75, - 'fill-color': fillColor, - }, - }, - symbolLayer ? symbolLayer.id : undefined - ); - }, [map, items, theme, getValueScale]); - - // side effect to only render the Popup when hovering a region with a matching item - useEffect(() => { - if (!(popupContainerRef.current && map && popupRef.current)) { - return; - } - if (tooltipState) { - popupRef.current.setDOMContent(popupContainerRef.current).addTo(map); - if (popupContainerRef.current.parentElement) { - popupContainerRef.current.parentElement.style.pointerEvents = 'none'; - } - } else { - popupRef.current.remove(); - } - }, [map, tooltipState]); - - // render map container and tooltip in a hidden container - return ( -
-
-
-
- {tooltipState ? : null} -
-
-
- ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx deleted file mode 100644 index 2dd3d058e98b8..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/DurationByCountryMap/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { useAvgDurationByCountry } from '../../../../../hooks/useAvgDurationByCountry'; - -import { ChoroplethMap } from '../ChoroplethMap'; - -export function DurationByCountryMap() { - const { data } = useAvgDurationByCountry(); - - return ( - <> - {' '} - - - {i18n.translate( - 'xpack.apm.metrics.durationByCountryMap.avgPageLoadByCountryLabel', - { - defaultMessage: 'Avg. page load duration distribution by country', - } - )} - - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index 6ba080a07b9d3..30ee0ba3eaa1f 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -26,8 +26,6 @@ import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { ITransactionChartData } from '../../../../selectors/chartSelectors'; import { asDecimal, tpmUnit } from '../../../../utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { BrowserLineChart } from './BrowserLineChart'; -import { DurationByCountryMap } from './DurationByCountryMap'; import { ErroneousTransactionsRateChart } from '../ErroneousTransactionsRateChart'; import { TransactionBreakdown } from '../../TransactionBreakdown'; import { @@ -120,24 +118,6 @@ export function TransactionCharts({ - - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - - - - - - - - - - - - )} ); } diff --git a/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.test.tsx b/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.test.tsx deleted file mode 100644 index bb947e307437e..0000000000000 --- a/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { renderHook } from '@testing-library/react-hooks'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import * as useFetcherModule from './useFetcher'; -import { useAvgDurationByBrowser } from './useAvgDurationByBrowser'; -import React, { ReactNode } from 'react'; -import { MemoryRouter } from 'react-router-dom'; - -function Wrapper({ children }: { children?: ReactNode }) { - return {children}; -} - -describe('useAvgDurationByBrowser', () => { - it('returns data', () => { - const data = [ - { title: 'Other', data: [{ x: 1572530100000, y: 130010.8947368421 }] }, - ]; - jest.spyOn(useFetcherModule, 'useFetcher').mockReturnValueOnce({ - data, - refetch: () => {}, - status: 'success' as useFetcherModule.FETCH_STATUS, - }); - const { result } = renderHook(() => useAvgDurationByBrowser(), { - wrapper: Wrapper, - }); - - expect(result.current.data).toEqual([ - { - color: theme.euiColorVis0, - data: [{ x: 1572530100000, y: 130010.8947368421 }], - title: 'Other', - type: 'linemark', - }, - ]); - }); -}); diff --git a/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts b/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts deleted file mode 100644 index 78dc4210711ef..0000000000000 --- a/x-pack/plugins/apm/public/hooks/useAvgDurationByBrowser.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { useParams } from 'react-router-dom'; -import { getVizColorForIndex } from '../../common/viz_colors'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AvgDurationByBrowserAPIResponse } from '../../server/lib/transactions/avg_duration_by_browser'; -import { TimeSeries } from '../../typings/timeseries'; -import { useFetcher } from './useFetcher'; -import { useUrlParams } from './useUrlParams'; - -function toTimeSeries(data?: AvgDurationByBrowserAPIResponse): TimeSeries[] { - if (!data) { - return []; - } - - return data.map((item, index) => { - return { - ...item, - color: getVizColorForIndex(index, theme), - type: 'linemark', - }; - }); -} - -export function useAvgDurationByBrowser() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { - urlParams: { start, end, transactionName }, - uiFilters, - } = useUrlParams(); - - const { data, error, status } = useFetcher( - (callApmApi) => { - if (serviceName && start && end) { - return callApmApi({ - pathname: - '/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_browser', - params: { - path: { serviceName }, - query: { - start, - end, - transactionName, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, - [serviceName, start, end, transactionName, uiFilters] - ); - - return { - data: toTimeSeries(data), - status, - error, - }; -} diff --git a/x-pack/plugins/apm/public/hooks/useAvgDurationByCountry.ts b/x-pack/plugins/apm/public/hooks/useAvgDurationByCountry.ts deleted file mode 100644 index 983f949b72961..0000000000000 --- a/x-pack/plugins/apm/public/hooks/useAvgDurationByCountry.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; - * you may not use this file except in compliance with the Elastic License. - */ -import { useParams } from 'react-router-dom'; -import { useFetcher } from './useFetcher'; -import { useUrlParams } from './useUrlParams'; - -export function useAvgDurationByCountry() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { - urlParams: { start, end, transactionName }, - uiFilters, - } = useUrlParams(); - - const { data = [], error, status } = useFetcher( - (callApmApi) => { - if (serviceName && start && end) { - return callApmApi({ - pathname: - '/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_country', - params: { - path: { serviceName }, - query: { - start, - end, - uiFilters: JSON.stringify(uiFilters), - transactionName, - }, - }, - }); - } - }, - [serviceName, start, end, uiFilters, transactionName] - ); - - return { - data, - status, - error, - }; -} diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 51ac6673251fb..ab3f1026a92dd 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; -import { lazy } from 'react'; import { ConfigSchema } from '.'; import { FetchDataParams, @@ -34,10 +32,10 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; -import { AlertType } from '../common/alert_types'; import { featureCatalogueEntry } from './featureCatalogueEntry'; import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { registerApmAlerts } from './components/alerting/register_apm_alerts'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -147,51 +145,6 @@ export class ApmPlugin implements Plugin { } public start(core: CoreStart, plugins: ApmPluginStartDeps) { toggleAppLinkInNav(core, this.initializerContext.config.get()); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.ErrorRate, - name: i18n.translate('xpack.apm.alertTypes.errorRate', { - defaultMessage: 'Error rate', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => import('./components/shared/ErrorRateAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.TransactionDuration, - name: i18n.translate('xpack.apm.alertTypes.transactionDuration', { - defaultMessage: 'Transaction duration', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => import('./components/shared/TransactionDurationAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); - - plugins.triggers_actions_ui.alertTypeRegistry.register({ - id: AlertType.TransactionDurationAnomaly, - name: i18n.translate('xpack.apm.alertTypes.transactionDurationAnomaly', { - defaultMessage: 'Transaction duration anomaly', - }), - iconClass: 'bell', - alertParamsExpression: lazy( - () => - import('./components/shared/TransactionDurationAnomalyAlertTrigger') - ), - validate: () => ({ - errors: [], - }), - requiresAppContext: true, - }); + registerApmAlerts(plugins.triggers_actions_ui.alertTypeRegistry); } } diff --git a/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts index ef85112918712..c1cb903a0bb3e 100644 --- a/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts +++ b/x-pack/plugins/apm/scripts/aggregate-latency-metrics/index.ts @@ -389,7 +389,7 @@ export async function aggregateLatencyMetrics() { return; } - const response = await destClient?.bulk({ + const response = await (destClient as any)?.bulk({ refresh: 'wait_for', body: flatten( docs.map((doc) => [ diff --git a/x-pack/plugins/apm/scripts/eslint.js b/x-pack/plugins/apm/scripts/eslint.js new file mode 100644 index 0000000000000..f221fc6dab23d --- /dev/null +++ b/x-pack/plugins/apm/scripts/eslint.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +//eslint-disable-next-line import/no-extraneous-dependencies +const { CLIEngine } = require('eslint'); +const { resolve } = require('path'); +//eslint-disable-next-line import/no-extraneous-dependencies +const { argv } = require('yargs'); + +async function run() { + const fix = !!argv.fix; + + const engine = new CLIEngine({ + fix, + cache: true, + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }); + + const report = engine.executeOnFiles(resolve(__dirname, '..')); + + const formatter = engine.getFormatter(); + + return formatter(report.results); +} + +run() + .then((text) => { + //eslint-disable-next-line no-console + console.log(text); + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/x-pack/plugins/apm/scripts/jest.js b/x-pack/plugins/apm/scripts/jest.js new file mode 100644 index 0000000000000..5c29dd9126937 --- /dev/null +++ b/x-pack/plugins/apm/scripts/jest.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.js'], + plugins: [], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }], + ], +}); + +// eslint-disable-next-line import/no-extraneous-dependencies +const { run } = require('jest'); + +process.env.NODE_ENV = process.env.NODE_ENV || 'test'; + +const config = require('../jest.config.js'); + +const argv = [...process.argv.slice(2), '--config', JSON.stringify(config)]; + +run(argv); diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js new file mode 100644 index 0000000000000..87da3c1db8b28 --- /dev/null +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console*/ +/* eslint-disable import/no-extraneous-dependencies*/ + +const execa = require('execa'); +const Listr = require('listr'); +const { resolve } = require('path'); + +const cwd = resolve(__dirname, '../../../..'); + +const execaOpts = { cwd, stderr: 'pipe' }; + +const tasks = new Listr( + [ + { + title: 'Jest', + task: () => + execa( + 'node', + [ + resolve(__dirname, './jest.js'), + '--reporters', + resolve(__dirname, './node_modules/jest-silent-reporter'), + '--collect-coverage', + 'false', + ], + execaOpts + ), + }, + { + title: 'Typescript', + task: () => + execa('node', [resolve(__dirname, 'optimize-tsconfig.js')]).then(() => + execa( + require.resolve('typescript/bin/tsc'), + [ + '--project', + resolve(__dirname, '../../../tsconfig.json'), + '--pretty', + '--noEmit', + '--skipLibCheck', + ], + execaOpts + ) + ), + }, + { + title: 'Lint', + task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts), + }, + ], + { exitOnError: false, concurrent: true } +); + +tasks.run().catch((error) => { + // from src/dev/typescript/exec_in_projects.ts + process.exitCode = 1; + + const errors = error.errors || [error]; + + for (const e of errors) { + process.stderr.write(e.stdout); + } +}); diff --git a/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts b/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts index 01fa5b0509bcd..83de83ace07b3 100644 --- a/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts +++ b/x-pack/plugins/apm/scripts/shared/create-or-update-index.ts @@ -39,7 +39,7 @@ export async function createOrUpdateIndex({ await client.indices.exists({ index: indexName, }) - ).body as boolean; + ).body as unknown; if (!indexExists) { await client.indices.create({ diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index fd628f77eb519..ca47540b04d82 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -83,7 +83,7 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return client.search(body as any).then((res) => res.body); + return client.search(body as any).then((res) => res.body as any); }, indicesStats: (body) => { return client.indices.stats(body as any).then((res) => res.body); diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 0f6061653f352..14d8e2c3a4d50 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -5,14 +5,21 @@ */ import { i18n } from '@kbn/i18n'; +import { LicenseType } from '../../licensing/common/types'; import { AlertType } from '../common/alert_types'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; +import { + LicensingPluginSetup, + LicensingRequestHandlerContext, +} from '../../licensing/server'; export const APM_FEATURE = { id: 'apm', name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { - defaultMessage: 'APM', + defaultMessage: 'APM and Client Side Monitoring', }), order: 900, + category: DEFAULT_APP_CATEGORIES.observability, icon: 'apmApp', navLinkId: 'apm', app: ['apm', 'csm', 'kibana'], @@ -58,5 +65,43 @@ export const APM_FEATURE = { }, }; -export const APM_SERVICE_MAPS_FEATURE_NAME = 'APM service maps'; -export const APM_SERVICE_MAPS_LICENSE_TYPE = 'platinum'; +interface Feature { + name: string; + license: LicenseType; +} +type FeatureName = 'serviceMaps' | 'ml' | 'customLinks'; +export const features: Record = { + serviceMaps: { + name: 'APM service maps', + license: 'platinum', + }, + ml: { + name: 'APM machine learning', + license: 'platinum', + }, + customLinks: { + name: 'APM custom links', + license: 'gold', + }, +}; + +export function registerFeaturesUsage({ + licensingPlugin, +}: { + licensingPlugin: LicensingPluginSetup; +}) { + Object.values(features).forEach(({ name, license }) => { + licensingPlugin.featureUsage.register(name, license); + }); +} + +export function notifyFeatureUsage({ + licensingPlugin, + featureName, +}: { + licensingPlugin: LicensingRequestHandlerContext; + featureName: FeatureName; +}) { + const feature = features[featureName]; + licensingPlugin.featureUsage.notifyUsage(feature.name); +} diff --git a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts new file mode 100644 index 0000000000000..f2558da3a30e4 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const apmActionVariables = { + serviceName: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.serviceName', + { defaultMessage: 'The service the alert is created for' } + ), + name: 'serviceName', + }, + transactionType: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.transactionType', + { defaultMessage: 'The transaction type the alert is created for' } + ), + name: 'transactionType', + }, + environment: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.environment', + { defaultMessage: 'The transaction type the alert is created for' } + ), + name: 'environment', + }, + threshold: { + description: i18n.translate('xpack.apm.alerts.action_variables.threshold', { + defaultMessage: + 'Any trigger value above this value will cause the alert to fire', + }), + name: 'threshold', + }, + triggerValue: { + description: i18n.translate( + 'xpack.apm.alerts.action_variables.triggerValue', + { + defaultMessage: + 'The value that breached the threshold and triggered the alert', + } + ), + name: 'triggerValue', + }, +}; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts index 44ca80143bcd9..fcbb4cc5950e0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts @@ -9,9 +9,10 @@ import { AlertingPlugin } from '../../../../alerts/server'; import { ActionsPlugin } from '../../../../actions/server'; import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type'; import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type'; -import { registerErrorRateAlertType } from './register_error_rate_alert_type'; +import { registerErrorCountAlertType } from './register_error_count_alert_type'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; +import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type'; interface Params { alerts: AlertingPlugin['setup']; @@ -30,7 +31,11 @@ export function registerApmAlerts(params: Params) { ml: params.ml, config$: params.config$, }); - registerErrorRateAlertType({ + registerErrorCountAlertType({ + alerts: params.alerts, + config$: params.config$, + }); + registerTransactionErrorRateAlertType({ alerts: params.alerts, config$: params.config$, }); diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts similarity index 66% rename from x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts rename to x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 61e3dfee420a5..5455cd9f6a495 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { ESSearchResponse, @@ -17,11 +17,11 @@ import { import { PROCESSOR_EVENT, SERVICE_NAME, - SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { APMConfig } from '../..'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -29,21 +29,21 @@ interface RegisterAlertParams { } const paramsSchema = schema.object({ - serviceName: schema.string(), windowSize: schema.number(), windowUnit: schema.string(), threshold: schema.number(), + serviceName: schema.string(), environment: schema.string(), }); -const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorRate]; +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorCount]; -export function registerErrorRateAlertType({ +export function registerErrorCountAlertType({ alerts, config$, }: RegisterAlertParams) { alerts.registerType({ - id: AlertType.ErrorRate, + id: AlertType.ErrorCount, name: alertTypeConfig.name, actionGroups: alertTypeConfig.actionGroups, defaultActionGroupId: alertTypeConfig.defaultActionGroupId, @@ -52,37 +52,26 @@ export function registerErrorRateAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerErrorRateAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, ], }, producer: 'apm', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); - - const alertParams = params as TypeOf; - + const alertParams = params; const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const environmentTerm = - alertParams.environment === ENVIRONMENT_ALL.value - ? [] - : [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }]; - const searchParams = { index: indices['apm_oss.errorIndices'], size: 0, body: { + track_total_hits: true, query: { bool: { filter: [ @@ -93,21 +82,12 @@ export function registerErrorRateAlertType({ }, }, }, - { - term: { - [PROCESSOR_EVENT]: 'error', - }, - }, - { - term: { - [SERVICE_NAME]: alertParams.serviceName, - }, - }, - ...environmentTerm, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + ...getEnvironmentUiFilterES(alertParams.environment), ], }, }, - track_total_hits: true, }, }; @@ -116,18 +96,19 @@ export function registerErrorRateAlertType({ ESSearchRequest > = await services.callCluster('search', searchParams); - const value = response.hits.total.value; + const errorCount = response.hits.total.value; - if (value && value > alertParams.threshold) { + if (errorCount > alertParams.threshold) { const alertInstance = services.alertInstanceFactory( - AlertType.ErrorRate + AlertType.ErrorCount ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { serviceName: alertParams.serviceName, + environment: alertParams.environment, + threshold: alertParams.threshold, + triggerValue: errorCount, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index ead28c325692d..373d4bd4da832 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; +import { ProcessorEvent } from '../../../common/processor_event'; import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; import { ESSearchResponse } from '../../../typings/elasticsearch'; import { @@ -16,11 +15,12 @@ import { SERVICE_NAME, TRANSACTION_TYPE, TRANSACTION_DURATION, - SERVICE_ENVIRONMENT, } from '../../../common/elasticsearch_fieldnames'; import { AlertingPlugin } from '../../../../alerts/server'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { APMConfig } from '../..'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -57,42 +57,22 @@ export function registerTransactionDurationAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAlertType.variables.transactionType', - { - defaultMessage: 'Transaction type', - } - ), - name: 'transactionType', - }, + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, ], }, producer: 'apm', executor: async ({ services, params }) => { const config = await config$.pipe(take(1)).toPromise(); - - const alertParams = params as TypeOf; - + const alertParams = params; const indices = await getApmIndices({ config, savedObjectsClient: services.savedObjectsClient, }); - const environmentTerm = - alertParams.environment === ENVIRONMENT_ALL.value - ? [] - : [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }]; - const searchParams = { index: indices['apm_oss.transactionIndices'], size: 0, @@ -107,33 +87,17 @@ export function registerTransactionDurationAlertType({ }, }, }, - { - term: { - [PROCESSOR_EVENT]: 'transaction', - }, - }, - { - term: { - [SERVICE_NAME]: alertParams.serviceName, - }, - }, - { - term: { - [TRANSACTION_TYPE]: alertParams.transactionType, - }, - }, - ...environmentTerm, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...getEnvironmentUiFilterES(alertParams.environment), ], }, }, aggs: { agg: alertParams.aggregationType === 'avg' - ? { - avg: { - field: TRANSACTION_DURATION, - }, - } + ? { avg: { field: TRANSACTION_DURATION } } : { percentiles: { field: TRANSACTION_DURATION, @@ -157,19 +121,23 @@ export function registerTransactionDurationAlertType({ const { agg } = response.aggregations; - const value = 'values' in agg ? Object.values(agg.values)[0] : agg?.value; + const transactionDuration = + 'values' in agg ? Object.values(agg.values)[0] : agg?.value; - if (value && value > alertParams.threshold * 1000) { + const threshold = alertParams.threshold * 1000; + + if (transactionDuration && transactionDuration > threshold) { const alertInstance = services.alertInstanceFactory( AlertType.TransactionDuration ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { transactionType: alertParams.transactionType, serviceName: alertParams.serviceName, + environment: alertParams.environment, + threshold, + triggerValue: transactionDuration, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts index 93af51b572aa5..61cd79b672735 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -4,15 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { Observable } from 'rxjs'; -import { i18n } from '@kbn/i18n'; +import { ANOMALY_SEVERITY } from '../../../../ml/common'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + AlertType, + ALERT_TYPES_CONFIG, + ANOMALY_ALERT_SEVERITY_TYPES, +} from '../../../common/alert_types'; import { AlertingPlugin } from '../../../../alerts/server'; import { APMConfig } from '../..'; import { MlPluginSetup } from '../../../../ml/server'; import { getMLJobIds } from '../service_map/get_service_anomalies'; +import { apmActionVariables } from './action_variables'; interface RegisterAlertParams { alerts: AlertingPlugin['setup']; @@ -26,7 +31,12 @@ const paramsSchema = schema.object({ windowSize: schema.number(), windowUnit: schema.string(), environment: schema.string(), - anomalyScore: schema.number(), + anomalySeverityType: schema.oneOf([ + schema.literal(ANOMALY_SEVERITY.CRITICAL), + schema.literal(ANOMALY_SEVERITY.MAJOR), + schema.literal(ANOMALY_SEVERITY.MINOR), + schema.literal(ANOMALY_SEVERITY.WARNING), + ]), }); const alertTypeConfig = @@ -47,24 +57,9 @@ export function registerTransactionDurationAnomalyAlertType({ }, actionVariables: { context: [ - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.serviceName', - { - defaultMessage: 'Service name', - } - ), - name: 'serviceName', - }, - { - description: i18n.translate( - 'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.transactionType', - { - defaultMessage: 'Transaction type', - } - ), - name: 'transactionType', - }, + apmActionVariables.serviceName, + apmActionVariables.transactionType, + apmActionVariables.environment, ], }, producer: 'apm', @@ -72,7 +67,7 @@ export function registerTransactionDurationAnomalyAlertType({ if (!ml) { return; } - const alertParams = params as TypeOf; + const alertParams = params; const request = {} as KibanaRequest; const { mlAnomalySearch } = ml.mlSystemProvider(request); const anomalyDetectors = ml.anomalyDetectorsProvider(request); @@ -82,12 +77,25 @@ export function registerTransactionDurationAnomalyAlertType({ alertParams.environment ); + const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find( + (option) => option.type === alertParams.anomalySeverityType + ); + + if (!selectedOption) { + throw new Error( + `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.` + ); + } + + const threshold = selectedOption.threshold; + if (mlJobIds.length === 0) { return {}; } const anomalySearchParams = { body: { + terminateAfter: 1, size: 0, query: { bool: { @@ -110,7 +118,7 @@ export function registerTransactionDurationAnomalyAlertType({ { range: { record_score: { - gte: alertParams.anomalyScore, + gte: threshold, }, }, }, @@ -131,10 +139,10 @@ export function registerTransactionDurationAnomalyAlertType({ ); alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { serviceName: alertParams.serviceName, + transactionType: alertParams.transactionType, + environment: alertParams.environment, }); } - - return {}; }, }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts new file mode 100644 index 0000000000000..a6ed40fc15ec6 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { EventOutcome } from '../../../common/event_outcome'; +import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { ESSearchResponse } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE, + EVENT_OUTCOME, +} from '../../../common/elasticsearch_fieldnames'; +import { AlertingPlugin } from '../../../../alerts/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; +import { APMConfig } from '../..'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { apmActionVariables } from './action_variables'; + +interface RegisterAlertParams { + alerts: AlertingPlugin['setup']; + config$: Observable; +} + +const paramsSchema = schema.object({ + windowSize: schema.number(), + windowUnit: schema.string(), + threshold: schema.number(), + transactionType: schema.string(), + serviceName: schema.string(), + environment: schema.string(), +}); + +const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionErrorRate]; + +export function registerTransactionErrorRateAlertType({ + alerts, + config$, +}: RegisterAlertParams) { + alerts.registerType({ + id: AlertType.TransactionErrorRate, + name: alertTypeConfig.name, + actionGroups: alertTypeConfig.actionGroups, + defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + validate: { + params: paramsSchema, + }, + actionVariables: { + context: [ + apmActionVariables.transactionType, + apmActionVariables.serviceName, + apmActionVariables.environment, + apmActionVariables.threshold, + apmActionVariables.triggerValue, + ], + }, + producer: 'apm', + executor: async ({ services, params: alertParams }) => { + const config = await config$.pipe(take(1)).toPromise(); + const indices = await getApmIndices({ + config, + savedObjectsClient: services.savedObjectsClient, + }); + + const searchParams = { + index: indices['apm_oss.transactionIndices'], + size: 0, + body: { + track_total_hits: true, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`, + }, + }, + }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { term: { [SERVICE_NAME]: alertParams.serviceName } }, + { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, + ...getEnvironmentUiFilterES(alertParams.environment), + ], + }, + }, + aggs: { + erroneous_transactions: { + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + }, + }, + }, + }; + + const response: ESSearchResponse< + unknown, + typeof searchParams + > = await services.callCluster('search', searchParams); + + if (!response.aggregations) { + return; + } + + const errornousTransactionsCount = + response.aggregations.erroneous_transactions.doc_count; + const totalTransactionCount = response.hits.total.value; + const transactionErrorRate = + (errornousTransactionsCount / totalTransactionCount) * 100; + + if (transactionErrorRate > alertParams.threshold) { + const alertInstance = services.alertInstanceFactory( + AlertType.TransactionErrorRate + ); + + alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, { + serviceName: alertParams.serviceName, + transactionType: alertParams.transactionType, + environment: alertParams.environment, + threshold: alertParams.threshold, + triggerValue: transactionErrorRate, + }); + } + }, + }); +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index 7bcd945d890ad..d0673335387c6 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -8,6 +8,7 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; import { snakeCase } from 'lodash'; import Boom from 'boom'; +import { ProcessorEvent } from '../../../common/processor_event'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; @@ -79,7 +80,7 @@ async function createAnomalyDetectionJob({ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { exists: { field: TRANSACTION_DURATION } }, ...getEnvironmentUiFilterES(environment), ], diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index a53068d152d03..fcd4f468d4367 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -85,7 +85,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { range: { '@timestamp': { gte: start, lt: end } } }, ], }, @@ -606,7 +606,10 @@ export const tasks: TelemetryTask[] = [ timeout, query: { bool: { - filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d], + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, + range1d, + ], }, }, aggs: { @@ -640,7 +643,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, range1d, ], }, @@ -674,7 +677,7 @@ export const tasks: TelemetryTask[] = [ query: { bool: { filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, range1d, ], must_not: { diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts index 6ff98a9be75f9..ea8d02eb833cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts @@ -5,11 +5,14 @@ */ import { ESFilter } from '../../../../typings/elasticsearch'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; +import { + ENVIRONMENT_NOT_DEFINED, + ENVIRONMENT_ALL, +} from '../../../../common/environment_filter_values'; import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; export function getEnvironmentUiFilterES(environment?: string): ESFilter[] { - if (!environment) { + if (!environment || environment === ENVIRONMENT_ALL.value) { return []; } if (environment === ENVIRONMENT_NOT_DEFINED.value) { diff --git a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap index b88c90a213c67..2868dcfda97b6 100644 --- a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap @@ -203,16 +203,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -221,16 +255,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -275,12 +343,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], @@ -682,16 +745,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -700,16 +797,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -760,12 +891,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], @@ -1157,16 +1283,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -1175,16 +1335,50 @@ Object { "memoryUsedAvg": Object { "avg": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, "memoryUsedMax": Object { "max": Object { "script": Object { - "lang": "expression", - "source": "1 - doc['system.memory.actual.free'] / doc['system.memory.total']", + "lang": "painless", + "source": " + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = 'system.process.cgroup.memory.mem.limit.bytes'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['system.memory.total'].value; + + double used = doc['system.process.cgroup.memory.mem.usage.bytes'].value; + + return used / total; + ", }, }, }, @@ -1224,12 +1418,7 @@ Object { }, Object { "exists": Object { - "field": "system.memory.actual.free", - }, - }, - Object { - "exists": Object { - "field": "system.memory.total", + "field": "system.process.cgroup.memory.mem.usage.bytes", }, }, ], diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 316b0d59d2c5b..a60576ca0c175 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -6,6 +6,8 @@ import { i18n } from '@kbn/i18n'; import { + METRIC_CGROUP_MEMORY_LIMIT_BYTES, + METRIC_CGROUP_MEMORY_USAGE_BYTES, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, } from '../../../../../../common/elasticsearch_fieldnames'; @@ -14,8 +16,8 @@ import { SetupTimeRange, SetupUIFilters, } from '../../../../helpers/setup_request'; -import { ChartBase } from '../../../types'; import { fetchAndTransformMetrics } from '../../../fetch_and_transform_metrics'; +import { ChartBase } from '../../../types'; const series = { memoryUsedMax: { @@ -43,36 +45,68 @@ const chartBase: ChartBase = { series, }; -export const percentMemoryUsedScript = { +export const percentSystemMemoryUsedScript = { lang: 'expression', source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']`, }; +export const percentCgroupMemoryUsedScript = { + lang: 'painless', + source: ` + /* + When no limit is specified in the container, docker allows the app as much memory / swap memory as it wants. + This number represents the max possible value for the limit field. + */ + double CGROUP_LIMIT_MAX_VALUE = 9223372036854771712L; + + String limitKey = '${METRIC_CGROUP_MEMORY_LIMIT_BYTES}'; + + //Should use cgropLimit when value is not empty and not equals to the max limit value. + boolean useCgroupLimit = doc.containsKey(limitKey) && !doc[limitKey].empty && doc[limitKey].value != CGROUP_LIMIT_MAX_VALUE; + + double total = useCgroupLimit ? doc[limitKey].value : doc['${METRIC_SYSTEM_TOTAL_MEMORY}'].value; + + double used = doc['${METRIC_CGROUP_MEMORY_USAGE_BYTES}'].value; + + return used / total; + `, +}; + export async function getMemoryChartData( setup: Setup & SetupTimeRange & SetupUIFilters, serviceName: string, serviceNodeName?: string ) { - return fetchAndTransformMetrics({ + const cgroupResponse = await fetchAndTransformMetrics({ setup, serviceName, serviceNodeName, chartBase, aggs: { - memoryUsedAvg: { avg: { script: percentMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentMemoryUsedScript } }, + memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, }, additionalFilters: [ - { - exists: { - field: METRIC_SYSTEM_FREE_MEMORY, - }, - }, - { - exists: { - field: METRIC_SYSTEM_TOTAL_MEMORY, - }, - }, + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, ], }); + + if (cgroupResponse.noHits) { + return await fetchAndTransformMetrics({ + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }); + } + + return cgroupResponse; } diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts index a871d22dd52f1..3d8ab7a72654d 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_load_distribution.ts @@ -15,10 +15,27 @@ import { export const MICRO_TO_SEC = 1000000; +const NUMBER_OF_PLD_STEPS = 100; + export function microToSec(val: number) { return Math.round((val / MICRO_TO_SEC + Number.EPSILON) * 100) / 100; } +export const getPLDChartSteps = ({ + maxDuration, + minDuration, +}: { + maxDuration: number; + minDuration: number; +}) => { + const stepValue = (maxDuration - minDuration) / NUMBER_OF_PLD_STEPS; + const stepValues = []; + for (let i = 1; i < NUMBER_OF_PLD_STEPS + 1; i++) { + stepValues.push((stepValue * i + minDuration).toFixed(2)); + } + return stepValues; +}; + export async function getPageLoadDistribution({ setup, minPercentile, @@ -105,11 +122,7 @@ const getPercentilesDistribution = async ({ minDuration: number; maxDuration: number; }) => { - const stepValue = (maxDuration - minDuration) / 100; - const stepValues = []; - for (let i = 1; i < 101; i++) { - stepValues.push((stepValue * i + minDuration).toFixed(2)); - } + const stepValues = getPLDChartSteps({ maxDuration, minDuration }); const projection = getRumPageLoadTransactionsProjection({ setup, diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index 1a7d602882395..543aa911b0b1f 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -51,6 +51,16 @@ export async function getPageViewTrends({ } : undefined, }, + ...(breakdownItem + ? { + topBreakdowns: { + terms: { + field: breakdownItem.fieldName, + size: 9, + }, + }, + } + : {}), }, }, }); @@ -59,25 +69,44 @@ export async function getPageViewTrends({ const response = await apmEventClient.search(params); + const { topBreakdowns } = response.aggregations ?? {}; + + // we are only displaying top 9 + const topItems: string[] = (topBreakdowns?.buckets ?? []).map( + ({ key }) => key as string + ); + const result = response.aggregations?.pageViews.buckets ?? []; - return result.map((bucket) => { - const { key: xVal, doc_count: bCount } = bucket; - const res: Record = { - x: xVal, - y: bCount, - }; - if (breakdownItem) { - const categoryBuckets = bucket.breakdown.buckets; - categoryBuckets.forEach(({ key, doc_count: docCount }) => { - if (key === 'Other') { - res[key + `(${breakdownItem?.name})`] = docCount; - } else { - res[key] = docCount; + return { + topItems, + items: result.map((bucket) => { + const { key: xVal, doc_count: bCount } = bucket; + const res: Record = { + x: xVal, + y: bCount, + }; + if ('breakdown' in bucket) { + let top9Count = 0; + const categoryBuckets = bucket.breakdown.buckets; + categoryBuckets.forEach(({ key, doc_count: docCount }) => { + if (topItems.includes(key as string)) { + if (res[key]) { + // if term is already in object, just add it to it + res[key] += docCount; + } else { + res[key] = docCount; + } + top9Count += docCount; + } + }); + // Top 9 plus others, get a diff from parent bucket total + if (bCount > top9Count) { + res.Other = bCount - top9Count; } - }); - } + } - return res; - }); + return res; + }), + }; } diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts index 4c9f1184c8e98..1945140e35777 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_pl_dist_breakdown.ts @@ -19,7 +19,11 @@ import { USER_AGENT_OS, TRANSACTION_DURATION, } from '../../../common/elasticsearch_fieldnames'; -import { MICRO_TO_SEC, microToSec } from './get_page_load_distribution'; +import { + getPLDChartSteps, + MICRO_TO_SEC, + microToSec, +} from './get_page_load_distribution'; export const getBreakdownField = (breakdown: string) => { switch (breakdown) { @@ -47,13 +51,10 @@ export const getPageLoadDistBreakdown = async ({ breakdown: string; }) => { // convert secs to micros - const stepValue = - (maxDuration * MICRO_TO_SEC - minDuration * MICRO_TO_SEC) / 50; - const stepValues = []; - - for (let i = 1; i < 51; i++) { - stepValues.push((stepValue * i + minDuration).toFixed(2)); - } + const stepValues = getPLDChartSteps({ + minDuration: minDuration * MICRO_TO_SEC, + maxDuration: maxDuration * MICRO_TO_SEC, + }); const projection = getRumPageLoadTransactionsProjection({ setup, diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts index f7b95696d422d..2ff0173b9ac12 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_web_core_vitals.ts @@ -124,10 +124,10 @@ export async function getWebCoreVitals({ // Divide by 1000 to convert ms into seconds return { - cls: String(cls?.values['50.0'] || 0), + cls: String(cls?.values['50.0']?.toFixed(2) || 0), fid: ((fid?.values['50.0'] || 0) / 1000).toFixed(2), lcp: ((lcp?.values['50.0'] || 0) / 1000).toFixed(2), - tbt: ((tbt?.values['50.0'] || 0) / 1000).toFixed(2), + tbt: tbt?.values['50.0'] || 0, fcp: fcp?.values['50.0'] || 0, lcpRanks: getRanksPercentages(lcpRanks?.values ?? defaultRanks), diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index ed8ae923e6e6c..44c0c96142096 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import Boom from 'boom'; +import { getServiceHealthStatus } from '../../../common/service_health_status'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseReturnType } from '../../../typings/common'; import { @@ -12,6 +13,7 @@ import { } from '../../../common/transaction_types'; import { ServiceAnomalyStats, + getSeverity, ML_ERRORS, } from '../../../common/anomaly_detection'; import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group'; @@ -58,11 +60,16 @@ export async function getServiceAnomalies({ query: { bool: { filter: [ - { term: { result_type: 'record' } }, + { terms: { result_type: ['model_plot', 'record'] } }, { terms: { job_id: mlJobIds } }, { range: { - timestamp: { gte: start, lte: end, format: 'epoch_millis' }, + timestamp: { + // fetch data for at least 30 minutes + gte: Math.min(end - 30 * 60 * 1000, start), + lte: end, + format: 'epoch_millis', + }, }, }, { @@ -81,7 +88,13 @@ export async function getServiceAnomalies({ top_score: { top_hits: { sort: { record_score: 'desc' }, - _source: { includes: ['actual', 'job_id', 'by_field_value'] }, + _source: [ + 'actual', + 'job_id', + 'by_field_value', + 'result_type', + 'record_score', + ], size: 1, }, }, @@ -111,10 +124,19 @@ interface ServiceAnomaliesAggResponse { hits: Array<{ sort: [number]; _source: { - actual: [number]; job_id: string; by_field_value: string; - }; + } & ( + | { + record_score: number | null; + result_type: 'record'; + actual: number[]; + } + | { + result_type: 'model_plot'; + actual?: number; + } + ); }>; }; }; @@ -125,23 +147,36 @@ interface ServiceAnomaliesAggResponse { function transformResponseToServiceAnomalies( response: ServiceAnomaliesAggResponse -): Record { +) { const serviceAnomaliesMap = ( response.aggregations?.services.buckets ?? [] - ).reduce( + ).reduce>( (statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => { + const mlResult = topScoreAgg.hits.hits[0]._source; + + const anomalyScore = + (mlResult.result_type === 'record' && mlResult.record_score) || 0; + + const severity = getSeverity(anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); + return { ...statsByServiceName, [serviceName]: { - transactionType: topScoreAgg.hits.hits[0]?._source?.by_field_value, - anomalyScore: topScoreAgg.hits.hits[0]?.sort?.[0], - actualValue: topScoreAgg.hits.hits[0]?._source?.actual?.[0], - jobId: topScoreAgg.hits.hits[0]?._source?.job_id, + transactionType: mlResult.by_field_value, + jobId: mlResult.job_id, + actualValue: + mlResult.result_type === 'record' + ? mlResult.actual[0] + : mlResult.actual, + anomalyScore, + healthStatus, }, }; }, {} ); + return serviceAnomaliesMap; } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 88cc26608b850..7c2137ce65d83 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -14,13 +14,17 @@ import { METRIC_SYSTEM_CPU_PERCENT, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, + METRIC_CGROUP_MEMORY_USAGE_BYTES, TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; import { ESFilter } from '../../../typings/elasticsearch'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory'; +import { + percentCgroupMemoryUsedScript, + percentSystemMemoryUsedScript, +} from '../metrics/by_agent/shared/memory'; import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, @@ -92,10 +96,12 @@ async function getErrorStats({ setup, serviceName, environment, + searchAggregatedTransactions, }: { setup: Options['setup']; serviceName: string; environment?: string; + searchAggregatedTransactions: boolean; }) { const setupWithBlankUiFilters = { ...setup, @@ -104,6 +110,7 @@ async function getErrorStats({ const { noHits, average } = await getErrorRate({ setup: setupWithBlankUiFilters, serviceName, + searchAggregatedTransactions, }); return { avgErrorRate: noHits ? null : average }; } @@ -205,26 +212,50 @@ async function getMemoryStats({ filter, }: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { const { apmEventClient } = setup; - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], + + const getAvgMemoryUsage = async ({ + additionalFilters, + script, + }: { + additionalFilters: ESFilter[]; + script: typeof percentCgroupMemoryUsedScript; + }) => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [...filter, ...additionalFilters], + }, + }, + aggs: { + avgMemoryUsage: { avg: { script } }, }, }, - aggs: { avgMemoryUsage: { avg: { script: percentMemoryUsedScript } } }, - }, - }); + }); - return { - avgMemoryUsage: response.aggregations?.avgMemoryUsage.value ?? null, + return response.aggregations?.avgMemoryUsage.value ?? null; }; + + let avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, + ], + script: percentCgroupMemoryUsedScript, + }); + + if (!avgMemoryUsage) { + avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + script: percentSystemMemoryUsedScript, + }); + } + + return { avgMemoryUsage }; } diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts index e529198e717d3..f30b80feda302 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ServiceHealthStatus } from '../../../common/service_health_status'; + import { AGENT_NAME, SERVICE_ENVIRONMENT, @@ -43,6 +45,7 @@ const anomalies = { actualValue: 10000, anomalyScore: 50, jobId: 'apm-test-1234-ml-module-name', + healthStatus: ServiceHealthStatus.warning, }, }, }; diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index a65536df37bc8..431f11066aaff 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -277,6 +277,13 @@ Array [ "services": Object { "aggs": Object { "outcomes": Object { + "aggs": Object { + "count": Object { + "value_count": Object { + "field": "transaction.duration.us", + }, + }, + }, "terms": Object { "field": "event.outcome", }, @@ -284,6 +291,13 @@ Array [ "timeseries": Object { "aggs": Object { "outcomes": Object { + "aggs": Object { + "count": Object { + "value_count": Object { + "field": "transaction.duration.us", + }, + }, + }, "terms": Object { "field": "event.outcome", }, diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts index e7e18cbff1c15..65bc3f7e47171 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items_stats.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getServiceHealthStatus } from '../../../../common/service_health_status'; import { EventOutcome } from '../../../../common/event_outcome'; import { getSeverity } from '../../../../common/anomaly_detection'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; @@ -13,7 +14,6 @@ import { EVENT_OUTCOME, } from '../../../../common/elasticsearch_fieldnames'; import { mergeProjection } from '../../../projections/util/merge_projection'; -import { ProcessorEvent } from '../../../../common/processor_event'; import { ServicesItemsSetup, ServicesItemsProjection, @@ -257,6 +257,7 @@ export const getTransactionRates = async ({ export const getTransactionErrorRates = async ({ setup, projection, + searchAggregatedTransactions, }: AggregationParams) => { const { apmEventClient, start, end } = setup; @@ -264,12 +265,25 @@ export const getTransactionErrorRates = async ({ terms: { field: EVENT_OUTCOME, }, + aggs: { + count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + }, }; const response = await apmEventClient.search( mergeProjection(projection, { apm: { - events: [ProcessorEvent.transaction], + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, body: { size: 0, @@ -318,11 +332,11 @@ export const getTransactionErrorRates = async ({ const successfulTransactions = outcomeResponse.buckets.find( (bucket) => bucket.key === EventOutcome.success - )?.doc_count ?? 0; + )?.count.value ?? 0; const failedTransactions = outcomeResponse.buckets.find( (bucket) => bucket.key === EventOutcome.failure - )?.doc_count ?? 0; + )?.count.value ?? 0; return failedTransactions / (successfulTransactions + failedTransactions); } @@ -413,10 +427,11 @@ export const getHealthStatuses = async ( const stats = anomalies.serviceAnomalies[serviceName]; const severity = getSeverity(stats.anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); return { serviceName, - severity, + healthStatus, }; }); }; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index 82595317342f1..3dc126c45d328 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -11,7 +11,6 @@ import { SERVICE_NAME, EVENT_OUTCOME, } from '../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; import { Setup, @@ -19,17 +18,23 @@ import { SetupUIFilters, } from '../helpers/setup_request'; import { getBucketSize } from '../helpers/get_bucket_size'; +import { + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; export async function getErrorRate({ serviceName, transactionType, transactionName, setup, + searchAggregatedTransactions, }: { serviceName: string; transactionType?: string; transactionName?: string; setup: Setup & SetupTimeRange & SetupUIFilters; + searchAggregatedTransactions: boolean; }) { const { start, end, uiFiltersES, apmEventClient } = setup; @@ -53,7 +58,11 @@ export async function getErrorRate({ const params = { apm: { - events: [ProcessorEvent.transaction], + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, body: { size: 0, @@ -67,8 +76,19 @@ export async function getErrorRate({ extended_bounds: { min: start, max: end }, }, aggs: { - erroneous_transactions: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + }, + aggs: { + count: { + value_count: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, + }, + }, }, }, }, @@ -81,18 +101,24 @@ export async function getErrorRate({ const noHits = resp.hits.total.value === 0; const erroneousTransactionsRate = - resp.aggregations?.total_transactions.buckets.map( - ({ - key, - doc_count: totalTransactions, - erroneous_transactions: erroneousTransactions, - }) => { - return { - x: key, - y: erroneousTransactions.doc_count / totalTransactions, - }; - } - ) || []; + resp.aggregations?.total_transactions.buckets.map((bucket) => { + const successful = + bucket[EVENT_OUTCOME].buckets.find( + (eventOutcomeBucket) => + eventOutcomeBucket.key === EventOutcome.success + )?.count.value ?? 0; + + const failed = + bucket[EVENT_OUTCOME].buckets.find( + (eventOutcomeBucket) => + eventOutcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0; + + return { + x: bucket.key, + y: failed / (successful + failed), + }; + }) || []; const average = mean( erroneousTransactionsRate diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/__fixtures__/responses.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/__fixtures__/responses.ts deleted file mode 100644 index 44878aa6c1f2e..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/__fixtures__/responses.ts +++ /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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { APMBaseDoc } from '../../../../../typings/es_schemas/raw/apm_base_doc'; -import { - ESSearchResponse, - ESSearchRequest, -} from '../../../../../typings/elasticsearch'; - -export const response = ({ - hits: { - total: 599, - max_score: 0, - hits: [], - }, - took: 4, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - aggregations: { - user_agent_keys: { - buckets: [{ key: 'Firefox' }, { key: 'Other' }], - }, - browsers: { - buckets: [ - { - key_as_string: '2019-10-21T04:38:20.000-05:00', - key: 1571650700000, - doc_count: 0, - user_agent: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [], - }, - }, - { - key_as_string: '2019-10-21T04:40:00.000-05:00', - key: 1571650800000, - doc_count: 1, - user_agent: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: 'Other', - doc_count: 1, - avg_duration: { - value: 860425.0, - }, - }, - { - key: 'Firefox', - doc_count: 10, - avg_duration: { - value: 86425.1, - }, - }, - ], - }, - }, - ], - }, - }, -} as unknown) as ESSearchResponse< - APMBaseDoc | Transaction, - ESSearchRequest, - { restTotalHitsAsInt: false } ->; diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.test.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.test.ts deleted file mode 100644 index aec124e4f4623..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../../helpers/setup_request'; -import { fetcher } from './fetcher'; - -describe('fetcher', () => { - it('performs a search', async () => { - const search = jest.fn(); - const setup = ({ - apmEventClient: { search }, - indices: {}, - uiFiltersES: [], - } as unknown) as Setup & SetupTimeRange & SetupUIFilters; - - await fetcher({ - serviceName: 'testServiceName', - setup, - searchAggregatedTransactions: false, - }); - - expect(search).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts deleted file mode 100644 index d40fcaaa02f60..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESFilter } from '../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { - SERVICE_NAME, - TRANSACTION_TYPE, - USER_AGENT_NAME, - TRANSACTION_NAME, -} from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { Options } from '.'; -import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; -import { - getDocumentTypeFilterForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, - getProcessorEventForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; - -export type ESResponse = PromiseReturnType; - -export function fetcher(options: Options) { - const { end, apmEventClient, start, uiFiltersES } = options.setup; - const { - serviceName, - searchAggregatedTransactions, - transactionName, - } = options; - const { intervalString } = getBucketSize(start, end); - - const transactionNameFilter = transactionName - ? [{ term: { [TRANSACTION_NAME]: transactionName } }] - : []; - - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }, - { range: rangeFilter(start, end) }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...uiFiltersES, - ...transactionNameFilter, - ]; - - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { bool: { filter } }, - aggs: { - user_agent_keys: { - terms: { - field: USER_AGENT_NAME, - }, - }, - browsers: { - date_histogram: { - extended_bounds: { - max: end, - min: start, - }, - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - }, - aggs: { - user_agent: { - terms: { - field: USER_AGENT_NAME, - }, - aggs: { - avg_duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }, - }, - }, - }, - }, - }; - - return apmEventClient.search(params); -} diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.test.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.test.ts deleted file mode 100644 index b8cea3a032268..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - getTransactionAvgDurationByBrowser, - Options, - AvgDurationByBrowserAPIResponse, -} from '.'; -import * as transformerModule from './transformer'; -import * as fetcherModule from './fetcher'; -import { response } from './__fixtures__/responses'; - -describe('getAvgDurationByBrowser', () => { - it('returns a transformed response', async () => { - const transformer = jest - .spyOn(transformerModule, 'transformer') - .mockReturnValueOnce(({} as unknown) as AvgDurationByBrowserAPIResponse); - const search = () => {}; - const options = ({ - setup: { client: { search }, indices: {}, uiFiltersES: [] }, - } as unknown) as Options; - jest - .spyOn<{ fetcher: any }, 'fetcher'>(fetcherModule, 'fetcher') - .mockResolvedValueOnce(response); - - await getTransactionAvgDurationByBrowser(options); - - expect(transformer).toHaveBeenCalledWith({ response }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts deleted file mode 100644 index 2c259edaa26ab..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Coordinate } from '../../../../typings/timeseries'; -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../../helpers/setup_request'; -import { fetcher } from './fetcher'; -import { transformer } from './transformer'; - -export interface Options { - serviceName: string; - setup: Setup & SetupTimeRange & SetupUIFilters; - searchAggregatedTransactions: boolean; - transactionName?: string; -} - -export type AvgDurationByBrowserAPIResponse = Array<{ - data: Coordinate[]; - title: string; -}>; - -export async function getTransactionAvgDurationByBrowser(options: Options) { - return transformer({ response: await fetcher(options) }); -} diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.test.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.test.ts deleted file mode 100644 index 91ff2698ea554..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { transformer } from './transformer'; -import { response } from './__fixtures__/responses'; - -describe('transformer', () => { - it('transforms', () => { - expect(transformer({ response })).toEqual([ - { - data: [ - { x: 1571650700000, y: undefined }, - { x: 1571650800000, y: 86425.1 }, - ], - title: 'Firefox', - }, - { - data: [ - { x: 1571650700000, y: undefined }, - { x: 1571650800000, y: 860425.0 }, - ], - title: 'Other', - }, - ]); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.ts deleted file mode 100644 index 5234af8cede66..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/transformer.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESResponse } from './fetcher'; -import { AvgDurationByBrowserAPIResponse } from '.'; -import { Coordinate } from '../../../../typings/timeseries'; - -export function transformer({ - response, -}: { - response: ESResponse; -}): AvgDurationByBrowserAPIResponse { - const allUserAgentKeys = new Set( - (response.aggregations?.user_agent_keys?.buckets ?? []).map(({ key }) => - key.toString() - ) - ); - const buckets = response.aggregations?.browsers?.buckets ?? []; - - const series = buckets.reduce<{ [key: string]: Coordinate[] }>( - (acc, next) => { - const userAgentBuckets = next.user_agent?.buckets ?? []; - const x = next.key; - const seenUserAgentKeys = new Set(); - - userAgentBuckets.map((userAgentBucket) => { - const key = userAgentBucket.key; - const y = userAgentBucket.avg_duration?.value; - - seenUserAgentKeys.add(key.toString()); - acc[key] = (acc[key] || []).concat({ x, y }); - }); - - const emptyUserAgents = new Set( - [...allUserAgentKeys].filter((key) => !seenUserAgentKeys.has(key)) - ); - - // If no user agent requests exist for this bucked, fill in the data with - // undefined - [...emptyUserAgents].map((key) => { - acc[key] = (acc[key] || []).concat({ x, y: undefined }); - }); - - return acc; - }, - {} - ); - - return Object.entries(series).map(([title, data]) => ({ title, data })); -} diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_country/index.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_country/index.ts deleted file mode 100644 index bc1e0af051ace..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_country/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - CLIENT_GEO_COUNTRY_ISO_CODE, - SERVICE_NAME, - TRANSACTION_TYPE, - TRANSACTION_NAME, -} from '../../../../common/elasticsearch_fieldnames'; -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../../helpers/setup_request'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, - getDocumentTypeFilterForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; - -export async function getTransactionAvgDurationByCountry({ - setup, - serviceName, - transactionName, - searchAggregatedTransactions, -}: { - setup: Setup & SetupTimeRange & SetupUIFilters; - serviceName: string; - transactionName?: string; - searchAggregatedTransactions: boolean; -}) { - const { uiFiltersES, apmEventClient, start, end } = setup; - const transactionNameFilter = transactionName - ? [{ term: { [TRANSACTION_NAME]: transactionName } }] - : []; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - ...transactionNameFilter, - { term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }, - { exists: { field: CLIENT_GEO_COUNTRY_ISO_CODE } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - }, - aggs: { - country_code: { - terms: { - field: CLIENT_GEO_COUNTRY_ISO_CODE, - size: 500, - }, - aggs: { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - avg_duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }, - }, - }, - }; - - const resp = await apmEventClient.search(params); - - if (!resp.aggregations) { - return []; - } - - const buckets = resp.aggregations.country_code.buckets; - const avgDurationsByCountry = buckets.map( - ({ key, count, avg_duration: { value } }) => ({ - key: key as string, - docCount: count.value, - value: value === null ? 0 : value, - }) - ); - - return avgDurationsByCountry; -} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index f25e37927f094..b417f8689b229 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -26,11 +26,7 @@ import { MlPluginSetup } from '../../ml/server'; import { ObservabilityPluginSetup } from '../../observability/server'; import { SecurityPluginSetup } from '../../security/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; -import { - APM_FEATURE, - APM_SERVICE_MAPS_FEATURE_NAME, - APM_SERVICE_MAPS_LICENSE_TYPE, -} from './feature'; +import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; @@ -128,10 +124,8 @@ export class APMPlugin implements Plugin { }); plugins.features.registerKibanaFeature(APM_FEATURE); - plugins.licensing.featureUsage.register( - APM_SERVICE_MAPS_FEATURE_NAME, - APM_SERVICE_MAPS_LICENSE_TYPE - ); + + registerFeaturesUsage({ licensingPlugin: plugins.licensing }); createApmApi().init(core, { config$: mergedConfig$, diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 1230e8aa05c9f..7d9a9ccc167e0 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -46,8 +46,6 @@ import { transactionGroupsChartsRoute, transactionGroupsDistributionRoute, transactionGroupsRoute, - transactionGroupsAvgDurationByCountry, - transactionGroupsAvgDurationByBrowser, transactionSampleForGroupRoute, transactionGroupsErrorRateRoute, } from './transaction_groups'; @@ -139,8 +137,6 @@ const createApmApi = () => { .add(transactionGroupsChartsRoute) .add(transactionGroupsDistributionRoute) .add(transactionGroupsRoute) - .add(transactionGroupsAvgDurationByBrowser) - .add(transactionGroupsAvgDurationByCountry) .add(transactionSampleForGroupRoute) .add(transactionGroupsErrorRateRoute) diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 04807cfac1cea..1996d4d4a262d 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -15,7 +15,7 @@ import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; import { rangeRt, uiFiltersRt } from './default_api_types'; -import { APM_SERVICE_MAPS_FEATURE_NAME } from '../feature'; +import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters'; @@ -37,7 +37,11 @@ export const serviceMapRoute = createRoute(() => ({ if (!isActivePlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } - context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); + + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'serviceMaps', + }); const logger = context.logger; const setup = await setupRequest(context, request); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 2cc0cdb1c2b91..f0a22356d074b 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -15,6 +15,7 @@ import { setupRequest } from '../../lib/helpers/setup_request'; import { getAllEnvironments } from '../../lib/environments/get_all_environments'; import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; +import { notifyFeatureUsage } from '../../feature'; // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute(() => ({ @@ -62,6 +63,10 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({ } await createAnomalyDetectionJobs(setup, environments, context.logger); + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'ml', + }); }, })); diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link.ts index 83c23a75e999d..7882383d78ab0 100644 --- a/x-pack/plugins/apm/server/routes/settings/custom_link.ts +++ b/x-pack/plugins/apm/server/routes/settings/custom_link.ts @@ -3,9 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +import Boom from 'boom'; import * as t from 'io-ts'; import { pick } from 'lodash'; +import { INVALID_LICENSE } from '../../../common/custom_link'; +import { ILicense } from '../../../../licensing/common/types'; import { FILTER_OPTIONS } from '../../../common/custom_link/custom_link_filter_options'; +import { notifyFeatureUsage } from '../../feature'; import { setupRequest } from '../../lib/helpers/setup_request'; import { createOrUpdateCustomLink } from '../../lib/settings/custom_link/create_or_update_custom_link'; import { @@ -17,6 +22,10 @@ import { getTransaction } from '../../lib/settings/custom_link/get_transaction'; import { listCustomLinks } from '../../lib/settings/custom_link/list_custom_links'; import { createRoute } from '../create_route'; +function isActiveGoldLicense(license: ILicense) { + return license.isActive && license.hasAtLeast('gold'); +} + export const customLinkTransactionRoute = createRoute(() => ({ path: '/api/apm/settings/custom_links/transaction', params: { @@ -37,6 +46,9 @@ export const listCustomLinksRoute = createRoute(() => ({ query: filterOptionsRt, }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { query } = context.params; // picks only the items listed in FILTER_OPTIONS @@ -55,9 +67,17 @@ export const createCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const customLink = context.params.body; const res = await createOrUpdateCustomLink({ customLink, setup }); + + notifyFeatureUsage({ + licensingPlugin: context.licensing, + featureName: 'customLinks', + }); return res; }, })); @@ -75,6 +95,9 @@ export const updateCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { id } = context.params.path; const customLink = context.params.body; @@ -99,6 +122,9 @@ export const deleteCustomLinkRoute = createRoute(() => ({ tags: ['access:apm', 'access:apm_write'], }, handler: async ({ context, request }) => { + if (!isActiveGoldLicense(context.licensing.license)) { + throw Boom.forbidden(INVALID_LICENSE); + } const setup = await setupRequest(context, request); const { id } = context.params.path; const res = await deleteCustomLink({ diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 3c512c1fe5278..10e917f385e71 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -12,8 +12,6 @@ import { getTransactionBreakdown } from '../lib/transactions/breakdown'; import { getTransactionGroupList } from '../lib/transaction_groups'; import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getTransactionAvgDurationByBrowser } from '../lib/transactions/avg_duration_by_browser'; -import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country'; import { getTransactionSampleForGroup } from '../lib/transaction_groups/get_transaction_sample_for_group'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; @@ -168,68 +166,6 @@ export const transactionGroupsBreakdownRoute = createRoute(() => ({ }, })); -export const transactionGroupsAvgDurationByBrowser = createRoute(() => ({ - path: `/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_browser`, - params: { - path: t.type({ - serviceName: t.string, - }), - query: t.intersection([ - t.partial({ - transactionName: t.string, - }), - uiFiltersRt, - rangeRt, - ]), - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { transactionName } = context.params.query; - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getTransactionAvgDurationByBrowser({ - serviceName, - setup, - searchAggregatedTransactions, - transactionName, - }); - }, -})); - -export const transactionGroupsAvgDurationByCountry = createRoute(() => ({ - path: `/api/apm/services/{serviceName}/transaction_groups/avg_duration_by_country`, - params: { - path: t.type({ - serviceName: t.string, - }), - query: t.intersection([ - uiFiltersRt, - rangeRt, - t.partial({ transactionName: t.string }), - ]), - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.path; - const { transactionName } = context.params.query; - - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getTransactionAvgDurationByCountry({ - serviceName, - transactionName, - setup, - searchAggregatedTransactions, - }); - }, -})); - export const transactionSampleForGroupRoute = createRoute(() => ({ path: `/api/apm/transaction_sample`, params: { @@ -274,11 +210,17 @@ export const transactionGroupsErrorRateRoute = createRoute(() => ({ const { params } = context; const { serviceName } = params.path; const { transactionType, transactionName } = params.query; + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + return getErrorRate({ serviceName, transactionType, transactionName, setup, + searchAggregatedTransactions, }); }, })); diff --git a/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts b/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts index 45a192e40c87b..76ca3e56fe837 100644 --- a/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts +++ b/x-pack/plugins/audit_trail/server/client/audit_trail_client.test.ts @@ -24,7 +24,9 @@ describe('AuditTrailClient', () => { beforeEach(() => { event$ = new Subject(); client = new AuditTrailClient( - httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'request id alpha' } }), + httpServerMock.createKibanaRequest({ + kibanaRequestState: { requestId: 'request id alpha', requestUuid: 'ignore-me' }, + }), event$, deps ); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__stories__/__snapshots__/palette.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__stories__/__snapshots__/palette.stories.storyshot index 385b16d3d8e8e..c0d3256eb9eb7 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__stories__/__snapshots__/palette.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/__stories__/__snapshots__/palette.stories.storyshot @@ -48,12 +48,10 @@ exports[`Storyshots arguments/Palette default 1`] = ` ; + + const unmount = renderHeaderActions(MockHeaderActions, mockHeaderEl, {} as any); + expect(mockHeaderEl.querySelector('.hello-world')).not.toBeNull(); + + unmount(); + expect(mockHeaderEl.innerHTML).toEqual(''); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index a54295548004a..0869ef7b22729 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -13,24 +13,17 @@ import { Store } from 'redux'; import { getContext, resetContext } from 'kea'; import { I18nProvider } from '@kbn/i18n/react'; -import { - AppMountParameters, - CoreStart, - ApplicationStart, - HttpSetup, - ChromeBreadcrumb, -} from 'src/core/public'; +import { AppMountParameters, CoreStart, ApplicationStart, ChromeBreadcrumb } from 'src/core/public'; import { ClientConfigType, ClientData, PluginsSetup } from '../plugin'; import { LicenseProvider } from './shared/licensing'; -import { FlashMessagesProvider } from './shared/flash_messages'; -import { HttpProvider } from './shared/http'; +import { mountHttpLogic } from './shared/http'; +import { mountFlashMessagesLogic } from './shared/flash_messages'; import { IExternalUrl } from './shared/enterprise_search_url'; import { IInitialAppData } from '../../common/types'; export interface IKibanaContext { config: { host?: string }; externalUrl: IExternalUrl; - http: HttpSetup; navigateToUrl: ApplicationStart['navigateToUrl']; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setDocTitle(title: string): void; @@ -55,13 +48,20 @@ export const renderApp = ( resetContext({ createStore: true }); const store = getContext().store as Store; + const unmountHttpLogic = mountHttpLogic({ + http: core.http, + errorConnecting, + readOnlyMode: initialData.readOnlyMode, + }); + + const unmountFlashMessagesLogic = mountFlashMessagesLogic({ history: params.history }); + ReactDOM.render( - - @@ -82,5 +80,26 @@ export const renderApp = ( ); return () => { ReactDOM.unmountComponentAtNode(params.element); + unmountHttpLogic(); + unmountFlashMessagesLogic(); }; }; + +/** + * Render function for Kibana's header action menu chrome - + * reusable by any Enterprise Search plugin simply by passing in + * a custom HeaderActions component (e.g., WorkplaceSearchHeaderActions) + * @see https://github.com/elastic/kibana/blob/master/docs/development/core/public/kibana-plugin-core-public.appmountparameters.setheaderactionmenu.md + */ +interface IHeaderActionsProps { + externalUrl: IExternalUrl; +} + +export const renderHeaderActions = ( + HeaderActions: React.FC, + kibanaHeaderEl: HTMLElement, + externalUrl: IExternalUrl +) => { + ReactDOM.render(, kibanaHeaderEl); + return () => ReactDOM.unmountComponentAtNode(kibanaHeaderEl); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts index 136912847baa9..c12011b47a472 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts @@ -6,23 +6,25 @@ import { resetContext } from 'kea'; -import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic'; +import { mockHistory } from '../../__mocks__'; + +import { FlashMessagesLogic, mountFlashMessagesLogic, IFlashMessage } from './'; describe('FlashMessagesLogic', () => { - const DEFAULT_VALUES = { - messages: [], - queuedMessages: [], - historyListener: null, - }; + const mount = () => mountFlashMessagesLogic({ history: mockHistory as any }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); - it('has expected default values', () => { - FlashMessagesLogic.mount(); - expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES); + it('has default values', () => { + mount(); + expect(FlashMessagesLogic.values).toEqual({ + messages: [], + queuedMessages: [], + historyListener: expect.any(Function), + }); }); describe('setFlashMessages()', () => { @@ -33,7 +35,7 @@ describe('FlashMessagesLogic', () => { { type: 'info', message: 'Everything is fine, nothing is ruined' }, ]; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages(messages); expect(FlashMessagesLogic.values.messages).toEqual(messages); @@ -42,7 +44,7 @@ describe('FlashMessagesLogic', () => { it('automatically converts to an array if a single message obj is passed in', () => { const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages(message); expect(FlashMessagesLogic.values.messages).toEqual([message]); @@ -51,7 +53,7 @@ describe('FlashMessagesLogic', () => { describe('clearFlashMessages()', () => { it('sets messages back to an empty array', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages('test' as any); FlashMessagesLogic.actions.clearFlashMessages(); @@ -63,7 +65,7 @@ describe('FlashMessagesLogic', () => { it('sets an array of messages', () => { const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' }; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setQueuedMessages(queuedMessage); expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]); @@ -72,7 +74,7 @@ describe('FlashMessagesLogic', () => { describe('clearQueuedMessages()', () => { it('sets queued messages back to an empty array', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setQueuedMessages('test' as any); FlashMessagesLogic.actions.clearQueuedMessages(); @@ -83,30 +85,25 @@ describe('FlashMessagesLogic', () => { describe('history listener logic', () => { describe('setHistoryListener()', () => { it('sets the historyListener value', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setHistoryListener('test' as any); expect(FlashMessagesLogic.values.historyListener).toEqual('test'); }); }); - describe('listenToHistory()', () => { + describe('on mount', () => { it('listens for history changes and clears messages on change', () => { - FlashMessagesLogic.mount(); + mount(); + expect(mockHistory.listen).toHaveBeenCalled(); + FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any); jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages'); jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages'); jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages'); jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener'); - const mockListener = jest.fn(() => jest.fn()); - const history = { listen: mockListener } as any; - FlashMessagesLogic.actions.listenToHistory(history); - - expect(mockListener).toHaveBeenCalled(); - expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled(); - - const mockHistoryChange = (mockListener.mock.calls[0] as any)[0]; + const mockHistoryChange = (mockHistory.listen.mock.calls[0] as any)[0]; mockHistoryChange(); expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled(); expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([ @@ -116,19 +113,20 @@ describe('FlashMessagesLogic', () => { }); }); - describe('beforeUnmount', () => { - it('removes history listener on unmount', () => { + describe('on unmount', () => { + it('removes history listener', () => { const mockUnlistener = jest.fn(); - const unmount = FlashMessagesLogic.mount(); + mockHistory.listen.mockReturnValueOnce(mockUnlistener); - FlashMessagesLogic.actions.setHistoryListener(mockUnlistener); + const unmount = mount(); unmount(); expect(mockUnlistener).toHaveBeenCalled(); }); it('does not crash if no listener exists', () => { - const unmount = FlashMessagesLogic.mount(); + const unmount = mount(); + FlashMessagesLogic.actions.setHistoryListener(null as any); unmount(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts index 3ae48f352b2c1..1735cc8ac7228 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts @@ -24,7 +24,6 @@ export interface IFlashMessagesActions { clearFlashMessages(): void; setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): { messages: IFlashMessage[] }; clearQueuedMessages(): void; - listenToHistory(history: History): History; setHistoryListener(historyListener: Function): { historyListener: Function }; } @@ -32,12 +31,12 @@ const convertToArray = (messages: IFlashMessage | IFlashMessage[]) => !Array.isArray(messages) ? [messages] : messages; export const FlashMessagesLogic = kea>({ + path: ['enterprise_search', 'flash_messages_logic'], actions: { setFlashMessages: (messages) => ({ messages: convertToArray(messages) }), clearFlashMessages: () => null, setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }), clearQueuedMessages: () => null, - listenToHistory: (history) => history, setHistoryListener: (historyListener) => ({ historyListener }), }, reducers: { @@ -62,21 +61,31 @@ export const FlashMessagesLogic = kea ({ - listenToHistory: (history) => { + events: ({ props, values, actions }) => ({ + afterMount: () => { // On React Router navigation, clear previous flash messages and load any queued messages - const unlisten = history.listen(() => { + const unlisten = props.history.listen(() => { actions.clearFlashMessages(); actions.setFlashMessages(values.queuedMessages); actions.clearQueuedMessages(); }); actions.setHistoryListener(unlisten); }, - }), - events: ({ values }) => ({ beforeUnmount: () => { const { historyListener: removeHistoryListener } = values; if (removeHistoryListener) removeHistoryListener(); }, }), }); + +/** + * Mount/props helper + */ +interface IFlashMessagesLogicProps { + history: History; +} +export const mountFlashMessagesLogic = (props: IFlashMessagesLogicProps) => { + FlashMessagesLogic(props); + const unmount = FlashMessagesLogic.mount(); + return unmount; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx deleted file mode 100644 index bcd7abd6d7ce2..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.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; - * you may not use this file except in compliance with the Elastic License. - */ - -import '../../__mocks__/shallow_usecontext.mock'; -import '../../__mocks__/kea.mock'; - -import React from 'react'; -import { shallow } from 'enzyme'; -import { useValues, useActions } from 'kea'; - -import { mockHistory } from '../../__mocks__'; - -import { FlashMessagesProvider } from './'; - -describe('FlashMessagesProvider', () => { - const props = { history: mockHistory as any }; - const listenToHistory = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - (useActions as jest.Mock).mockImplementationOnce(() => ({ listenToHistory })); - }); - - it('does not render', () => { - const wrapper = shallow(); - - expect(wrapper.isEmptyRender()).toBe(true); - }); - - it('listens to history on mount', () => { - shallow(); - - expect(listenToHistory).toHaveBeenCalledWith(mockHistory); - }); - - it('does not add another history listener if one already exists', () => { - (useValues as jest.Mock).mockImplementationOnce(() => ({ historyListener: 'exists' as any })); - - shallow(); - - expect(listenToHistory).not.toHaveBeenCalledWith(props); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx deleted file mode 100644 index a3ceabcf6ac8a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useEffect } from 'react'; -import { useValues, useActions } from 'kea'; -import { History } from 'history'; - -import { FlashMessagesLogic } from './flash_messages_logic'; - -interface IFlashMessagesProviderProps { - history: History; -} - -export const FlashMessagesProvider: React.FC = ({ history }) => { - const { historyListener } = useValues(FlashMessagesLogic); - const { listenToHistory } = useActions(FlashMessagesLogic); - - useEffect(() => { - if (!historyListener) listenToHistory(history); - }, []); - - return null; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts index c03954179184e..21c1a60efa6b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts @@ -10,6 +10,7 @@ export { IFlashMessage, IFlashMessagesValues, IFlashMessagesActions, + mountFlashMessagesLogic, } from './flash_messages_logic'; -export { FlashMessagesProvider } from './flash_messages_provider'; export { flashAPIErrors } from './handle_api_errors'; +export { setSuccessMessage, setErrorMessage, setQueuedSuccessMessage } from './set_message_helpers'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts new file mode 100644 index 0000000000000..f2ddd560ac9c1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockHistory } from '../../__mocks__'; + +import { + FlashMessagesLogic, + mountFlashMessagesLogic, + setSuccessMessage, + setErrorMessage, + setQueuedSuccessMessage, +} from './'; + +describe('Flash Message Helpers', () => { + const message = 'I am a message'; + + beforeEach(() => { + mountFlashMessagesLogic({ history: mockHistory as any }); + }); + + it('setSuccessMessage()', () => { + setSuccessMessage(message); + + expect(FlashMessagesLogic.values.messages).toEqual([ + { + message, + type: 'success', + }, + ]); + }); + + it('setErrorMessage()', () => { + setErrorMessage(message); + + expect(FlashMessagesLogic.values.messages).toEqual([ + { + message, + type: 'error', + }, + ]); + }); + + it('setQueuedSuccessMessage()', () => { + setQueuedSuccessMessage(message); + + expect(FlashMessagesLogic.values.queuedMessages).toEqual([ + { + message, + type: 'success', + }, + ]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts new file mode 100644 index 0000000000000..6abb540b7c14b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FlashMessagesLogic } from './'; + +export const setSuccessMessage = (message: string) => { + FlashMessagesLogic.actions.setFlashMessages({ + type: 'success', + message, + }); +}; + +export const setErrorMessage = (message: string) => { + FlashMessagesLogic.actions.setFlashMessages({ + type: 'error', + message, + }); +}; + +export const setQueuedSuccessMessage = (message: string) => { + FlashMessagesLogic.actions.setQueuedMessages({ + type: 'success', + message, + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts index c032e3b04ebe6..df32b5496c367 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts @@ -8,42 +8,39 @@ import { resetContext } from 'kea'; import { httpServiceMock } from 'src/core/public/mocks'; -import { HttpLogic } from './http_logic'; +import { HttpLogic, mountHttpLogic } from './http_logic'; describe('HttpLogic', () => { const mockHttp = httpServiceMock.createSetupContract(); - const DEFAULT_VALUES = { - http: null, - httpInterceptors: [], - errorConnecting: false, - }; + const mount = () => mountHttpLogic({ http: mockHttp }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); - it('has expected default values', () => { - HttpLogic.mount(); - expect(HttpLogic.values).toEqual(DEFAULT_VALUES); - }); - - describe('initializeHttp()', () => { - it('sets values based on passed props', () => { - HttpLogic.mount(); - HttpLogic.actions.initializeHttp({ http: mockHttp, errorConnecting: true }); + describe('mounts', () => { + it('sets values from props', () => { + mountHttpLogic({ + http: mockHttp, + errorConnecting: true, + readOnlyMode: true, + }); expect(HttpLogic.values).toEqual({ http: mockHttp, - httpInterceptors: [], + httpInterceptors: expect.any(Array), errorConnecting: true, + readOnlyMode: true, }); }); }); describe('setErrorConnecting()', () => { it('sets errorConnecting value', () => { - HttpLogic.mount(); + mount(); + expect(HttpLogic.values.errorConnecting).toEqual(false); + HttpLogic.actions.setErrorConnecting(true); expect(HttpLogic.values.errorConnecting).toEqual(true); @@ -52,54 +49,114 @@ describe('HttpLogic', () => { }); }); + describe('setReadOnlyMode()', () => { + it('sets readOnlyMode value', () => { + mount(); + expect(HttpLogic.values.readOnlyMode).toEqual(false); + + HttpLogic.actions.setReadOnlyMode(true); + expect(HttpLogic.values.readOnlyMode).toEqual(true); + + HttpLogic.actions.setReadOnlyMode(false); + expect(HttpLogic.values.readOnlyMode).toEqual(false); + }); + }); + describe('http interceptors', () => { describe('initializeHttpInterceptors()', () => { beforeEach(() => { - HttpLogic.mount(); + mount(); jest.spyOn(HttpLogic.actions, 'setHttpInterceptors'); - jest.spyOn(HttpLogic.actions, 'setErrorConnecting'); - HttpLogic.actions.initializeHttp({ http: mockHttp }); - - HttpLogic.actions.initializeHttpInterceptors(); }); it('calls http.intercept and sets an array of interceptors', () => { - mockHttp.intercept.mockImplementationOnce(() => 'removeInterceptorFn' as any); + mockHttp.intercept + .mockImplementationOnce(() => 'removeErrorInterceptorFn' as any) + .mockImplementationOnce(() => 'removeReadOnlyInterceptorFn' as any); HttpLogic.actions.initializeHttpInterceptors(); expect(mockHttp.intercept).toHaveBeenCalled(); - expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith(['removeInterceptorFn']); + expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith([ + 'removeErrorInterceptorFn', + 'removeReadOnlyInterceptorFn', + ]); }); describe('errorConnectingInterceptor', () => { + let interceptedResponse: any; + + beforeEach(() => { + interceptedResponse = mockHttp.intercept.mock.calls[0][0].responseError; + jest.spyOn(HttpLogic.actions, 'setErrorConnecting'); + }); + it('handles errors connecting to Enterprise Search', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/app_search/engines', status: 502 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + const httpResponse = { + response: { url: '/api/app_search/engines', status: 502 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).toHaveBeenCalled(); }); it('does not handle non-502 Enterprise Search errors', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/workplace_search/overview', status: 404 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + const httpResponse = { + response: { url: '/api/workplace_search/overview', status: 404 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); }); - it('does not handle errors for unrelated calls', async () => { - const { responseError } = mockHttp.intercept.mock.calls[0][0] as any; - const httpResponse = { response: { url: '/api/some_other_plugin/', status: 502 } }; - await expect(responseError(httpResponse)).rejects.toEqual(httpResponse); + it('does not handle errors for non-Enterprise Search API calls', async () => { + const httpResponse = { + response: { url: '/api/some_other_plugin/', status: 502 }, + }; + await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse); expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled(); }); }); + + describe('readOnlyModeInterceptor', () => { + let interceptedResponse: any; + + beforeEach(() => { + interceptedResponse = mockHttp.intercept.mock.calls[1][0].response; + jest.spyOn(HttpLogic.actions, 'setReadOnlyMode'); + }); + + it('sets readOnlyMode to true if the response header is true', async () => { + const httpResponse = { + response: { url: '/api/app_search/engines', headers: { get: () => 'true' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(true); + }); + + it('sets readOnlyMode to false if the response header is false', async () => { + const httpResponse = { + response: { url: '/api/workplace_search/overview', headers: { get: () => 'false' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(false); + }); + + it('does not handle headers for non-Enterprise Search API calls', async () => { + const httpResponse = { + response: { url: '/api/some_other_plugin/', headers: { get: () => 'true' } }, + }; + await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse); + + expect(HttpLogic.actions.setReadOnlyMode).not.toHaveBeenCalled(); + }); + }); }); it('sets httpInterceptors and calls all valid remove functions on unmount', () => { - const unmount = HttpLogic.mount(); + const unmount = mount(); const httpInterceptors = [jest.fn(), undefined, jest.fn()] as any; HttpLogic.actions.setHttpInterceptors(httpInterceptors); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts index ec9db30ddef3b..d16e507bfb3bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts @@ -6,40 +6,33 @@ import { kea, MakeLogicType } from 'kea'; -import { HttpSetup, HttpInterceptorResponseError } from 'src/core/public'; +import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public'; + +import { READ_ONLY_MODE_HEADER } from '../../../../common/constants'; export interface IHttpValues { http: HttpSetup; httpInterceptors: Function[]; errorConnecting: boolean; + readOnlyMode: boolean; } export interface IHttpActions { - initializeHttp({ - http, - errorConnecting, - }: { - http: HttpSetup; - errorConnecting?: boolean; - }): { http: HttpSetup; errorConnecting?: boolean }; initializeHttpInterceptors(): void; setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] }; setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean }; + setReadOnlyMode(readOnlyMode: boolean): { readOnlyMode: boolean }; } export const HttpLogic = kea>({ + path: ['enterprise_search', 'http_logic'], actions: { - initializeHttp: ({ http, errorConnecting }) => ({ http, errorConnecting }), initializeHttpInterceptors: () => null, setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }), setErrorConnecting: (errorConnecting) => ({ errorConnecting }), + setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }), }, - reducers: { - http: [ - (null as unknown) as HttpSetup, - { - initializeHttp: (_, { http }) => http, - }, - ], + reducers: ({ props }) => ({ + http: [props.http, {}], httpInterceptors: [ [], { @@ -47,26 +40,31 @@ export const HttpLogic = kea>({ }, ], errorConnecting: [ - false, + props.errorConnecting || false, { - initializeHttp: (_, { errorConnecting }) => !!errorConnecting, setErrorConnecting: (_, { errorConnecting }) => errorConnecting, }, ], - }, + readOnlyMode: [ + props.readOnlyMode || false, + { + setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode, + }, + ], + }), listeners: ({ values, actions }) => ({ initializeHttpInterceptors: () => { const httpInterceptors = []; const errorConnectingInterceptor = values.http.intercept({ responseError: async (httpResponse) => { - const { url, status } = httpResponse.response!; - const hasErrorConnecting = status === 502; - const isApiResponse = - url.includes('/api/app_search/') || url.includes('/api/workplace_search/'); + if (isEnterpriseSearchApi(httpResponse)) { + const { status } = httpResponse.response!; + const hasErrorConnecting = status === 502; - if (isApiResponse && hasErrorConnecting) { - actions.setErrorConnecting(true); + if (hasErrorConnecting) { + actions.setErrorConnecting(true); + } } // Re-throw error so that downstream catches work as expected @@ -75,11 +73,30 @@ export const HttpLogic = kea>({ }); httpInterceptors.push(errorConnectingInterceptor); - // TODO: Read only mode interceptor + const readOnlyModeInterceptor = values.http.intercept({ + response: async (httpResponse) => { + if (isEnterpriseSearchApi(httpResponse)) { + const readOnlyMode = httpResponse.response!.headers.get(READ_ONLY_MODE_HEADER); + + if (readOnlyMode === 'true') { + actions.setReadOnlyMode(true); + } else { + actions.setReadOnlyMode(false); + } + } + + return Promise.resolve(httpResponse); + }, + }); + httpInterceptors.push(readOnlyModeInterceptor); + actions.setHttpInterceptors(httpInterceptors); }, }), - events: ({ values }) => ({ + events: ({ values, actions }) => ({ + afterMount: () => { + actions.initializeHttpInterceptors(); + }, beforeUnmount: () => { values.httpInterceptors.forEach((removeInterceptorFn?: Function) => { if (removeInterceptorFn) removeInterceptorFn(); @@ -87,3 +104,25 @@ export const HttpLogic = kea>({ }, }), }); + +/** + * Mount/props helper + */ +interface IHttpLogicProps { + http: HttpSetup; + errorConnecting?: boolean; + readOnlyMode?: boolean; +} +export const mountHttpLogic = (props: IHttpLogicProps) => { + HttpLogic(props); + const unmount = HttpLogic.mount(); + return unmount; +}; + +/** + * Small helper that checks whether or not an http call is for an Enterprise Search API + */ +const isEnterpriseSearchApi = (httpResponse: HttpResponse) => { + const { url } = httpResponse.response!; + return url.includes('/api/app_search/') || url.includes('/api/workplace_search/'); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx deleted file mode 100644 index 81106235780d6..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import '../../__mocks__/shallow_usecontext.mock'; -import '../../__mocks__/kea.mock'; - -import React from 'react'; -import { shallow } from 'enzyme'; -import { useActions } from 'kea'; - -import { HttpProvider } from './'; - -describe('HttpProvider', () => { - const props = { - http: {} as any, - errorConnecting: false, - }; - const initializeHttp = jest.fn(); - const initializeHttpInterceptors = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - (useActions as jest.Mock).mockImplementationOnce(() => ({ - initializeHttp, - initializeHttpInterceptors, - })); - }); - - it('does not render', () => { - const wrapper = shallow(); - - expect(wrapper.isEmptyRender()).toBe(true); - }); - - it('calls initialization actions on mount', () => { - shallow(); - - expect(initializeHttp).toHaveBeenCalledWith(props); - expect(initializeHttpInterceptors).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx deleted file mode 100644 index 4c2160195a1af..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useEffect } from 'react'; -import { useActions } from 'kea'; - -import { HttpSetup } from 'src/core/public'; - -import { HttpLogic } from './http_logic'; - -interface IHttpProviderProps { - http: HttpSetup; - errorConnecting?: boolean; -} - -export const HttpProvider: React.FC = (props) => { - const { initializeHttp, initializeHttpInterceptors } = useActions(HttpLogic); - - useEffect(() => { - initializeHttp(props); - initializeHttpInterceptors(); - }, []); - - return null; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts index db65e80ca25c2..46a52415f8564 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { HttpLogic, IHttpValues, IHttpActions } from './http_logic'; -export { HttpProvider } from './http_provider'; +export { HttpLogic, IHttpValues, IHttpActions, mountHttpLogic } from './http_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss index c73a527147961..e867e9cf5a445 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss @@ -7,8 +7,12 @@ .enterpriseSearchLayout { $sideBarWidth: $euiSize * 15; $sideBarMobileHeight: $euiSize * 4.75; + $consoleHeaderHeight: 98px; // NOTE: Keep an eye on this for changes + $pageHeight: calc(100vh - #{$consoleHeaderHeight}); display: block; + background-color: $euiColorEmptyShade; + min-height: $pageHeight; position: relative; left: $sideBarWidth; width: calc(100% - #{$sideBarWidth}); @@ -44,8 +48,7 @@ overflow-y: auto; overflow-x: hidden; - $kibanaHeader: 49px; // NOTE: Keep an eye on this for changes - height: calc(100vh - #{$kibanaHeader}); + height: $pageHeight; width: $sideBarWidth; background-color: $euiColorLightestShade; @@ -78,4 +81,15 @@ padding: $euiSize; } } + + &__readOnlyMode { + margin: -$euiSizeM 0 $euiSizeL; + + @include euiBreakpoint('m') { + margin: 0 0 $euiSizeL; + } + @include euiBreakpoint('xs', 's') { + margin: 0; + } + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx index 623e6e47167d2..7b876d81527fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiPageSideBar, EuiButton, EuiPageBody } from '@elastic/eui'; +import { EuiPageSideBar, EuiButton, EuiPageBody, EuiCallOut } from '@elastic/eui'; import { Layout, INavContext } from './layout'; @@ -55,6 +55,12 @@ describe('Layout', () => { expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen'); }); + it('renders a read-only mode callout', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); + it('renders children', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx index e122c4d5cfdfa..ef8216e8b6711 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import classNames from 'classnames'; -import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton } from '@elastic/eui'; +import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import './layout.scss'; @@ -15,6 +15,7 @@ import './layout.scss'; interface ILayoutProps { navigation: React.ReactNode; restrictWidth?: boolean; + readOnlyMode?: boolean; } export interface INavContext { @@ -22,7 +23,12 @@ export interface INavContext { } export const NavContext = React.createContext({}); -export const Layout: React.FC = ({ children, navigation, restrictWidth }) => { +export const Layout: React.FC = ({ + children, + navigation, + restrictWidth, + readOnlyMode, +}) => { const [isNavOpen, setIsNavOpen] = useState(false); const toggleNavigation = () => setIsNavOpen(!isNavOpen); const closeNavigation = () => setIsNavOpen(false); @@ -56,6 +62,17 @@ export const Layout: React.FC = ({ children, navigation, restrictW {navigation} + {readOnlyMode && ( + + )} {children} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss index d673542ba1983..79cd7634cfaa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.scss @@ -72,4 +72,15 @@ $euiSizeML: $euiSize * 1.25; // 20px - between medium and large ¯\_(ツ)_/¯ } } } + + &__subNav { + padding-left: $euiSizeML; + + // Extends the click area of links more to the left, so that second tiers + // of subnavigation links still have the same hitbox as first tier links + .enterpriseSearchNavLinks__item { + margin-left: -$euiSizeML; + padding-left: $euiSizeXXL; + } + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx index c117fa404a16b..b006068ac0d9e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.test.tsx @@ -89,4 +89,20 @@ describe('SideNavLink', () => { expect(wrapper.find('.testing')).toHaveLength(1); expect(wrapper.find('[data-test-subj="testing"]')).toHaveLength(1); }); + + it('renders nested subnavigation', () => { + const subNav = ( + + Another link! + + ); + const wrapper = shallow( + + Link + + ); + + expect(wrapper.find('.enterpriseSearchNavLinks__subNav')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="subNav"]')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx index 72e4f2f091496..edcfc2c84e3ad 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/side_nav.tsx @@ -66,6 +66,7 @@ interface ISideNavLinkProps { isExternal?: boolean; className?: string; isRoot?: boolean; + subNav?: React.ReactNode; } export const SideNavLink: React.FC = ({ @@ -74,6 +75,7 @@ export const SideNavLink: React.FC = ({ children, className, isRoot, + subNav, ...rest }) => { const { closeNavigation } = useContext(NavContext) as INavContext; @@ -103,6 +105,7 @@ export const SideNavLink: React.FC = ({ {children} )} + {subNav &&
    {subNav}
} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 8f7cf090e2d57..073c548ba47fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../__mocks__/kea.mock'; +import '../../__mocks__/shallow_usecontext.mock'; +import { mockHttpValues } from '../../__mocks__'; + import React from 'react'; +import { shallow } from 'enzyme'; -import { httpServiceMock } from 'src/core/public/mocks'; import { JSON_HEADER as headers } from '../../../../common/constants'; -import { mountWithKibanaContext } from '../../__mocks__'; import { sendTelemetry, @@ -18,8 +21,6 @@ import { } from './'; describe('Shared Telemetry Helpers', () => { - const httpMock = httpServiceMock.createSetupContract(); - beforeEach(() => { jest.clearAllMocks(); }); @@ -27,13 +28,13 @@ describe('Shared Telemetry Helpers', () => { describe('sendTelemetry', () => { it('successfully calls the server-side telemetry endpoint', () => { sendTelemetry({ - http: httpMock, + http: mockHttpValues.http, product: 'enterprise_search', action: 'viewed', metric: 'setup_guide', }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); @@ -50,33 +51,27 @@ describe('Shared Telemetry Helpers', () => { describe('React component helpers', () => { it('SendEnterpriseSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"page"}', }); }); it('SendAppSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"app_search","action":"clicked","metric":"button"}', }); }); it('SendWorkplaceSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"workplace_search","action":"error","metric":"not_found"}', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 4df1428221de6..2f87597897b41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; +import { useValues } from 'kea'; import { HttpSetup } from 'src/core/public'; import { JSON_HEADER as headers } from '../../../../common/constants'; -import { KibanaContext, IKibanaContext } from '../../index'; +import { HttpLogic } from '../http'; interface ISendTelemetryProps { action: 'viewed' | 'error' | 'clicked'; @@ -27,7 +28,7 @@ interface ISendTelemetry extends ISendTelemetryProps { export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { try { const body = JSON.stringify({ product, action, metric }); - await http.put('/api/enterprise_search/telemetry', { headers, body }); + await http.put('/api/enterprise_search/stats', { headers, body }); } catch (error) { throw new Error('Unable to send telemetry'); } @@ -41,7 +42,7 @@ export const SendEnterpriseSearchTelemetry: React.FC = ({ action, metric, }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'enterprise_search' }); @@ -51,7 +52,7 @@ export const SendEnterpriseSearchTelemetry: React.FC = ({ }; export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'app_search' }); @@ -61,7 +62,7 @@ export const SendAppSearchTelemetry: React.FC = ({ action, }; export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'workplace_search' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts index c52eceb2d2fdd..974e07069ddba 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -50,5 +50,15 @@ describe('AppLogic', () => { expect(AppLogic.values).toEqual(expectedLogicValues); }); + + it('gracefully handles missing initial data', () => { + AppLogic.actions.initializeAppData({}); + + expect(AppLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasInitialized: true, + isFederatedAuth: false, + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f88a00f63f487..629d1969a8f59 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -21,7 +21,11 @@ export interface IAppActions { initializeAppData(props: IInitialAppData): IInitialAppData; } +const emptyOrg = {} as IOrganization; +const emptyAccount = {} as IAccount; + export const AppLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'app_logic'], actions: { initializeAppData: ({ workplaceSearch, isFederatedAuth }) => ({ workplaceSearch, @@ -42,15 +46,15 @@ export const AppLogic = kea>({ }, ], organization: [ - {} as IOrganization, + emptyOrg, { - initializeAppData: (_, { workplaceSearch }) => workplaceSearch!.organization, + initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.organization || emptyOrg, }, ], account: [ - {} as IAccount, + emptyAccount, { - initializeAppData: (_, { workplaceSearch }) => workplaceSearch!.account, + initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount, }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts index 41861a8ee2dc5..915638246c00e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/index.ts @@ -5,3 +5,4 @@ */ export { WorkplaceSearchNav } from './nav'; +export { WorkplaceSearchHeaderActions } from './kibana_header_actions'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx new file mode 100644 index 0000000000000..a006c5e3775d5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty } from '@elastic/eui'; +import { ExternalUrl } from '../../../shared/enterprise_search_url'; + +import { WorkplaceSearchHeaderActions } from './'; + +describe('WorkplaceSearchHeaderActions', () => { + const externalUrl = new ExternalUrl('http://localhost:3002'); + + it('renders a link to the search application', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButtonEmpty).prop('href')).toEqual('http://localhost:3002/ws/search'); + }); + + it('does not render without an Enterprise Search host URL set', () => { + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx new file mode 100644 index 0000000000000..fa32d598f848d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; + +import { IExternalUrl } from '../../../shared/enterprise_search_url'; + +interface IProps { + externalUrl: IExternalUrl; +} + +export const WorkplaceSearchHeaderActions: React.FC = ({ externalUrl }) => { + const { enterpriseSearchUrl, getWorkplaceSearchUrl } = externalUrl; + if (!enterpriseSearchUrl) return null; + + return ( + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.headerActions.searchApplication', { + defaultMessage: 'Go to search application', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx index 429a2c509813d..c73eb05ccec16 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx index a914000654165..a80de9fd6ac82 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -5,17 +5,19 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../../shared/telemetry'; +import { HttpLogic } from '../../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../../index'; export const ProductButton: React.FC = () => { + const { http } = useValues(HttpLogic); const { externalUrl: { getWorkplaceSearchUrl }, - http, } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { 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 39280ad6f4be4..fc1943264d72b 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 @@ -12,6 +12,7 @@ import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; import { useValues, useActions } from 'kea'; +import { Layout } from '../shared/layout'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; import { Overview } from './views/overview'; @@ -53,6 +54,7 @@ describe('WorkplaceSearchConfigured', () => { it('renders with layout', () => { const wrapper = shallow(); + expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy(); expect(wrapper.find(Overview)).toHaveLength(1); }); @@ -60,9 +62,9 @@ describe('WorkplaceSearchConfigured', () => { const initializeAppData = jest.fn(); (useActions as jest.Mock).mockImplementation(() => ({ initializeAppData })); - shallow(); + shallow(); - expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true }); + expect(initializeAppData).toHaveBeenCalledWith({ isFederatedAuth: true }); }); it('does not re-initialize app data', () => { @@ -82,4 +84,12 @@ describe('WorkplaceSearchConfigured', () => { expect(wrapper.find(ErrorState)).toHaveLength(2); }); + + it('passes readOnlyMode state', () => { + (useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true })); + + const wrapper = shallow(); + + expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 6a51b49869eaf..a68dfaf8ea471 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 @@ -31,7 +31,7 @@ export const WorkplaceSearch: React.FC = (props) => { export const WorkplaceSearchConfigured: React.FC = (props) => { const { hasInitialized } = useValues(AppLogic); const { initializeAppData } = useActions(AppLogic); - const { errorConnecting } = useValues(HttpLogic); + const { errorConnecting, readOnlyMode } = useValues(HttpLogic); useEffect(() => { if (!hasInitialized) initializeAppData(props); @@ -46,7 +46,7 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { {errorConnecting ? : } - }> + } readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx index 1d7c565935e97..c890adb8ea043 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../__mocks__/kea.mock'; import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 786357358dfa6..79be7ef1cb158 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -5,6 +5,7 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiButton, @@ -17,7 +18,9 @@ import { EuiButtonEmptyProps, EuiLinkProps, } from '@elastic/eui'; + import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; interface IOnboardingCardProps { @@ -39,8 +42,8 @@ export const OnboardingCard: React.FC = ({ actionPath, complete, }) => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index 0baadfc912ad5..079d981533e01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -23,6 +23,7 @@ import { } from '@elastic/eui'; import sharedSourcesIcon from '../../components/shared/assets/share_circle.svg'; import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; @@ -135,8 +136,8 @@ export const OnboardingSteps: React.FC = () => { }; export const OrgNameOnboarding: React.FC = () => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts index 787d5295db1cf..a156b8a8009f9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/overview_logic.ts @@ -31,6 +31,7 @@ export interface IOverviewValues extends IOverviewServerData { } export const OverviewLogic = kea>({ + path: ['enterprise_search', 'workplace_search', 'overview_logic'], actions: { setServerData: (serverData) => serverData, initializeOverview: () => null, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 0813999c9a078..dd62e6de7c046 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -14,6 +14,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ContentSection } from '../../components/shared/content_section'; import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; @@ -93,8 +94,8 @@ export const RecentActivityItem: React.FC = ({ timestamp, sourceId, }) => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 0ef58a7c03f10..c23bb23be3979 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -103,9 +103,16 @@ export class EnterpriseSearchPlugin implements Plugin { await this.getInitialData(coreStart.http); - const { renderApp } = await import('./applications'); + const { renderApp, renderHeaderActions } = await import('./applications'); const { WorkplaceSearch } = await import('./applications/workplace_search'); + const { WorkplaceSearchHeaderActions } = await import( + './applications/workplace_search/components/layout' + ); + params.setHeaderActionMenu((element) => + renderHeaderActions(WorkplaceSearchHeaderActions, element, this.data.externalUrl) + ); + return renderApp(WorkplaceSearch, params, coreStart, plugins, this.config, this.data); }, }); 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 8e3ae2cfbeb86..2bddc9f1c80bd 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 @@ -70,7 +70,6 @@ describe('callEnterpriseSearchConfigAPI', () => { role_type: 'owner', ability: { access_all_engines: true, - destroy: ['session'], manage: ['account_credentials', 'account_engines'], // etc edit: ['LocoMoco::Account'], // etc view: ['Engine'], // etc @@ -145,13 +144,12 @@ describe('callEnterpriseSearchConfigAPI', () => { }, appSearch: { accountId: undefined, - onBoardingComplete: false, + onboardingComplete: false, role: { id: undefined, roleType: undefined, ability: { accessAllEngines: false, - destroy: [], manage: [], edit: [], view: [], diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts index 10a75e59cb249..c63e3ff8ffb2b 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_config_api.ts @@ -89,13 +89,12 @@ export const callEnterpriseSearchConfigAPI = async ({ }, appSearch: { accountId: data?.current_user?.app_search?.account?.id, - onBoardingComplete: !!data?.current_user?.app_search?.account?.onboarding_complete, + onboardingComplete: !!data?.current_user?.app_search?.account?.onboarding_complete, role: { id: data?.current_user?.app_search?.role?.id, roleType: data?.current_user?.app_search?.role?.role_type, ability: { accessAllEngines: !!data?.current_user?.app_search?.role?.ability?.access_all_engines, - destroy: data?.current_user?.app_search?.role?.ability?.destroy || [], manage: data?.current_user?.app_search?.role?.ability?.manage || [], edit: data?.current_user?.app_search?.role?.ability?.edit || [], view: data?.current_user?.app_search?.role?.ability?.view || [], diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 0c1e81e3aba46..3d0a3181f8ab8 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -5,7 +5,7 @@ */ import { mockConfig, mockLogger } from '../__mocks__'; -import { JSON_HEADER } from '../../common/constants'; +import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler'; @@ -18,6 +18,9 @@ const responseMock = { custom: jest.fn(), customError: jest.fn(), }; +const mockExpectedResponseHeaders = { + [READ_ONLY_MODE_HEADER]: 'false', +}; describe('EnterpriseSearchRequestHandler', () => { const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({ @@ -58,6 +61,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.custom).toHaveBeenCalledWith({ body: responseBody, statusCode: 200, + headers: mockExpectedResponseHeaders, }); }); @@ -112,11 +116,12 @@ describe('EnterpriseSearchRequestHandler', () => { await makeAPICall(requestHandler); EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example'); - expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 201 }); + expect(responseMock.custom).toHaveBeenCalledWith({ + body: {}, + statusCode: 201, + headers: mockExpectedResponseHeaders, + }); }); - - // TODO: It's possible we may also pass back headers at some point - // from Enterprise Search, e.g. the x-read-only mode header }); }); @@ -140,6 +145,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'some error message', attributes: { errors: ['some error message'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -156,6 +162,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'one,two,three', attributes: { errors: ['one', 'two', 'three'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -171,6 +178,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Bad Request', attributes: { errors: ['Bad Request'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -186,6 +194,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Bad Request', attributes: { errors: ['Bad Request'] }, }, + headers: mockExpectedResponseHeaders, }); }); @@ -201,6 +210,7 @@ describe('EnterpriseSearchRequestHandler', () => { message: 'Not Found', attributes: { errors: ['Not Found'] }, }, + headers: mockExpectedResponseHeaders, }); }); }); @@ -215,12 +225,33 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: expect.stringContaining('Enterprise Search encountered an internal server error'), + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalledWith( 'Enterprise Search Server Error 500 at : "something crashed!"' ); }); + it('handleReadOnlyModeError()', async () => { + EnterpriseSearchAPI.mockReturn( + { errors: ['Read only mode'] }, + { status: 503, headers: { ...JSON_HEADER, [READ_ONLY_MODE_HEADER]: 'true' } } + ); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/503' }); + + await makeAPICall(requestHandler); + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/503'); + + expect(responseMock.customError).toHaveBeenCalledWith({ + statusCode: 503, + body: expect.stringContaining('Enterprise Search is in read-only mode'), + headers: { [READ_ONLY_MODE_HEADER]: 'true' }, + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot perform action: Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.' + ); + }); + it('handleInvalidDataError()', async () => { EnterpriseSearchAPI.mockReturn({ results: false }); const requestHandler = enterpriseSearchRequestHandler.createRequest({ @@ -234,6 +265,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Invalid data received from Enterprise Search', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalledWith( 'Invalid data received from : {"results":false}' @@ -250,6 +282,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Error connecting to Enterprise Search: Failed', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -265,6 +298,7 @@ describe('EnterpriseSearchRequestHandler', () => { expect(responseMock.customError).toHaveBeenCalledWith({ statusCode: 502, body: 'Cannot authenticate Enterprise Search user', + headers: mockExpectedResponseHeaders, }); expect(mockLogger.error).toHaveBeenCalled(); }); @@ -279,6 +313,18 @@ describe('EnterpriseSearchRequestHandler', () => { }); }); + it('setResponseHeaders', async () => { + EnterpriseSearchAPI.mockReturn('anything' as any, { + headers: { [READ_ONLY_MODE_HEADER]: 'true' }, + }); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' }); + await makeAPICall(requestHandler); + + expect(enterpriseSearchRequestHandler.headers).toEqual({ + [READ_ONLY_MODE_HEADER]: 'true', + }); + }); + it('isEmptyObj', async () => { expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true); expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false); @@ -304,9 +350,10 @@ const EnterpriseSearchAPI = { ...expectedParams, }); }, - mockReturn(response: object, options?: object) { + mockReturn(response: object, options?: any) { fetchMock.mockImplementation(() => { - return Promise.resolve(new Response(JSON.stringify(response), options)); + const headers = Object.assign({}, mockExpectedResponseHeaders, options?.headers); + return Promise.resolve(new Response(JSON.stringify(response), { ...options, headers })); }); }, mockReturnError() { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 00d5eaf5d6a83..6b65c16c832fd 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -14,7 +14,7 @@ import { Logger, } from 'src/core/server'; import { ConfigType } from '../index'; -import { JSON_HEADER } from '../../common/constants'; +import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants'; interface IConstructorDependencies { config: ConfigType; @@ -46,6 +46,7 @@ export interface IEnterpriseSearchRequestHandler { export class EnterpriseSearchRequestHandler { private enterpriseSearchUrl: string; private log: Logger; + private headers: Record = {}; constructor({ config, log }: IConstructorDependencies) { this.log = log; @@ -80,6 +81,9 @@ export class EnterpriseSearchRequestHandler { // Call the Enterprise Search API const apiResponse = await fetch(url, { method, headers, body }); + // Handle response headers + this.setResponseHeaders(apiResponse); + // Handle authentication redirects if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) { return this.handleAuthenticationError(response); @@ -88,7 +92,13 @@ export class EnterpriseSearchRequestHandler { // Handle 400-500+ responses from the Enterprise Search server const { status } = apiResponse; if (status >= 500) { - return this.handleServerError(response, apiResponse, url); + if (this.headers[READ_ONLY_MODE_HEADER] === 'true') { + // Handle 503 read-only mode errors + return this.handleReadOnlyModeError(response); + } else { + // Handle unexpected server errors + return this.handleServerError(response, apiResponse, url); + } } else if (status >= 400) { return this.handleClientError(response, apiResponse); } @@ -100,7 +110,11 @@ export class EnterpriseSearchRequestHandler { } // Pass successful responses back to the front-end - return response.custom({ statusCode: status, body: json }); + return response.custom({ + statusCode: status, + headers: this.headers, + body: json, + }); } catch (e) { // Catch connection/auth errors return this.handleConnectionError(response, e); @@ -160,7 +174,7 @@ export class EnterpriseSearchRequestHandler { const { status } = apiResponse; const body = await this.getErrorResponseBody(apiResponse); - return response.customError({ statusCode: status, body }); + return response.customError({ statusCode: status, headers: this.headers, body }); } async handleServerError(response: KibanaResponseFactory, apiResponse: Response, url: string) { @@ -172,14 +186,22 @@ export class EnterpriseSearchRequestHandler { 'Enterprise Search encountered an internal server error. Please contact your system administrator if the problem persists.'; this.log.error(`Enterprise Search Server Error ${status} at <${url}>: ${message}`); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); + } + + handleReadOnlyModeError(response: KibanaResponseFactory) { + const errorMessage = + 'Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.'; + + this.log.error(`Cannot perform action: ${errorMessage}`); + return response.customError({ statusCode: 503, headers: this.headers, body: errorMessage }); } handleInvalidDataError(response: KibanaResponseFactory, url: string, json: object) { const errorMessage = 'Invalid data received from Enterprise Search'; this.log.error(`Invalid data received from <${url}>: ${JSON.stringify(json)}`); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); } handleConnectionError(response: KibanaResponseFactory, e: Error) { @@ -188,14 +210,26 @@ export class EnterpriseSearchRequestHandler { this.log.error(errorMessage); if (e instanceof Error) this.log.debug(e.stack as string); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); } handleAuthenticationError(response: KibanaResponseFactory) { const errorMessage = 'Cannot authenticate Enterprise Search user'; this.log.error(errorMessage); - return response.customError({ statusCode: 502, body: errorMessage }); + return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage }); + } + + /** + * Set response headers + * + * Currently just forwards the read-only mode header, but we can expand this + * in the future to pass more headers from Enterprise Search as we need them + */ + + setResponseHeaders(apiResponse: Response) { + const readOnlyMode = apiResponse.headers.get(READ_ONLY_MODE_HEADER); + this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false'; } /** diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 3d28a05a4b7b4..a9bd03e8f97d4 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -16,6 +16,7 @@ import { KibanaRequest, } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; @@ -82,6 +83,7 @@ export class EnterpriseSearchPlugin implements Plugin { id: ENTERPRISE_SEARCH_PLUGIN.ID, name: ENTERPRISE_SEARCH_PLUGIN.NAME, order: 0, + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, icon: 'logoEnterpriseSearch', app: [ 'kibana', diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index acddd3539965a..bd6f4b9da91fd 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -35,7 +35,7 @@ describe('Enterprise Search Telemetry API', () => { }); }); - describe('PUT /api/enterprise_search/telemetry', () => { + describe('PUT /api/enterprise_search/stats', () => { it('increments the saved objects counter for App Search', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index bfc07c8b64ef5..8f6638ddc099e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -25,7 +25,7 @@ export function registerTelemetryRoute({ }: IRouteDependencies) { router.put( { - path: '/api/enterprise_search/telemetry', + path: '/api/enterprise_search/stats', validate: { body: schema.object({ product: schema.oneOf([ diff --git a/x-pack/plugins/features/common/kibana_feature.ts b/x-pack/plugins/features/common/kibana_feature.ts index a600ada554afd..32a7502956728 100644 --- a/x-pack/plugins/features/common/kibana_feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -5,6 +5,7 @@ */ import { RecursiveReadonly } from '@kbn/utility-types'; +import { AppCategory } from 'src/core/types'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; import { SubFeatureConfig, SubFeature as KibanaSubFeature } from './sub_feature'; import { ReservedKibanaPrivilege } from './reserved_kibana_privilege'; @@ -29,6 +30,13 @@ export interface KibanaFeatureConfig { */ name: string; + /** + * The category for this feature. + * This will be used to organize the list of features for display within the + * Spaces and Roles management screens. + */ + category: AppCategory; + /** * An ordinal used to sort features relative to one another for display. */ @@ -158,6 +166,10 @@ export class KibanaFeature { return this.config.order; } + public get category() { + return this.config.category; + } + public get navLinkId() { return this.config.navLinkId; } diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 24aae3a69ee5d..aaaeccbd15e72 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -14,6 +14,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; @@ -35,6 +36,7 @@ describe('FeatureRegistry', () => { icon: 'addDataApp', navLinkId: 'someNavLink', app: ['app1'], + category: { id: 'foo', label: 'foo' }, validLicenses: ['standard', 'basic', 'gold', 'platinum'], catalogue: ['foo'], management: { @@ -57,7 +59,7 @@ describe('FeatureRegistry', () => { read: { savedObject: { all: [], - read: ['config', 'url'], + read: ['config', 'url', 'telemetry'], }, ui: [], }, @@ -143,11 +145,64 @@ describe('FeatureRegistry', () => { expect(result[0].toRaw()).toEqual(feature); }); + describe('category', () => { + it('is required', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"category\\" fails because [\\"category\\" is required]"` + ); + }); + + it('must have an id', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + category: { label: 'foo' }, + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"category\\" fails because [child \\"id\\" fails because [\\"id\\" is required]]"` + ); + }); + + it('must have a label', () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + category: { id: 'foo' }, + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"category\\" fails because [child \\"label\\" fails because [\\"label\\" is required]]"` + ); + }); + }); + it(`requires a value for privileges`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, } as any; const featureRegistry = new FeatureRegistry(); @@ -163,6 +218,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, subFeatures: [ { @@ -201,6 +257,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { ui: [], @@ -230,11 +287,12 @@ describe('FeatureRegistry', () => { expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); }); - it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { + it(`automatically grants access to config, url, and telemetry saved objects`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { ui: [], @@ -263,7 +321,7 @@ describe('FeatureRegistry', () => { const allPrivilege = result[0].privileges?.all; const readPrivilege = result[0].privileges?.read; expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'telemetry', 'url']); }); it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { @@ -271,6 +329,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -303,6 +362,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { ui: [], @@ -332,7 +392,7 @@ describe('FeatureRegistry', () => { const readPrivilege = result[0].privileges!.read; expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url', 'telemetry']); }); it(`does not allow duplicate features to be registered`, () => { @@ -340,6 +400,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; @@ -347,6 +408,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Duplicate Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; @@ -367,6 +429,7 @@ describe('FeatureRegistry', () => { name: 'some feature', navLinkId: prohibitedChars, app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }) ).toThrowErrorMatchingSnapshot(); @@ -382,6 +445,7 @@ describe('FeatureRegistry', () => { kibana: [prohibitedChars], }, app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }) ).toThrowErrorMatchingSnapshot(); @@ -395,6 +459,7 @@ describe('FeatureRegistry', () => { name: 'some feature', catalogue: [prohibitedChars], app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }) ).toThrowErrorMatchingSnapshot(); @@ -409,6 +474,7 @@ describe('FeatureRegistry', () => { id: prohibitedId, name: 'some feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }) ).toThrowErrorMatchingSnapshot(); @@ -420,6 +486,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: ['app1', 'app2'], + category: { id: 'foo', label: 'foo' }, privileges: { foo: { name: 'Foo', @@ -447,6 +514,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: ['bar'], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -481,6 +549,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: ['foo', 'bar', 'baz'], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -538,6 +607,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: ['bar'], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'something', @@ -571,6 +641,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: ['foo', 'bar', 'baz'], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'something', @@ -604,6 +675,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], privileges: { all: { @@ -641,6 +713,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['foo', 'bar', 'baz'], privileges: { all: { @@ -701,6 +774,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], privileges: null, reserved: { @@ -736,6 +810,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['foo', 'bar', 'baz'], privileges: null, reserved: { @@ -771,6 +846,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, alerting: ['bar'], privileges: { all: { @@ -811,6 +887,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, alerting: ['foo', 'bar', 'baz'], privileges: { all: { @@ -871,6 +948,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, alerting: ['bar'], privileges: null, reserved: { @@ -906,6 +984,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, alerting: ['foo', 'bar', 'baz'], privileges: null, reserved: { @@ -941,6 +1020,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], management: { kibana: ['hey'], @@ -987,6 +1067,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], management: { kibana: ['hey'], @@ -1060,6 +1141,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], management: { kibana: ['hey'], @@ -1101,6 +1183,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['bar'], management: { kibana: ['hey', 'hey-there'], @@ -1142,6 +1225,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'my reserved privileges', @@ -1184,6 +1268,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'my reserved privileges', @@ -1216,12 +1301,14 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; const feature2: KibanaFeatureConfig = { id: 'test-feature-2', name: 'Test Feature 2', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; @@ -1346,6 +1433,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; @@ -1371,6 +1459,7 @@ describe('FeatureRegistry', () => { id: 'test-feature', name: 'Test Feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }; diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index d357bdb782797..e9e556ba22fd2 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -97,7 +97,12 @@ function applyAutomaticReadPrivilegeGrants( ) { readPrivileges.forEach((readPrivilege) => { if (readPrivilege) { - readPrivilege.savedObject.read = uniq([...readPrivilege.savedObject.read, 'config', 'url']); + readPrivilege.savedObject.read = uniq([ + ...readPrivilege.savedObject.read, + 'config', + 'telemetry', + 'url', + ]); } }); } diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 06a3eb158d99d..c6ec2d52c6d1a 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -28,6 +28,14 @@ const managementSchema = Joi.object().pattern( const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)); const alertingSchema = Joi.array().items(Joi.string()); +const appCategorySchema = Joi.object({ + id: Joi.string().required(), + label: Joi.string().required(), + ariaLabel: Joi.string(), + euiIconType: Joi.string(), + order: Joi.number(), +}).required(); + const kibanaPrivilegeSchema = Joi.object({ excludeFromBasePrivileges: Joi.boolean(), management: managementSchema, @@ -80,6 +88,7 @@ const kibanaFeatureSchema = Joi.object({ .invalid(...prohibitedFeatureIds) .required(), name: Joi.string().required(), + category: appCategorySchema, order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), validLicenses: Joi.array().items( diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 3ff6b1b7bf44f..4cec44d6fa19a 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; import { KibanaFeatureConfig } from '../common'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; @@ -19,6 +20,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS defaultMessage: 'Discover', }), order: 100, + category: DEFAULT_APP_CATEGORIES.kibana, icon: 'discoverApp', navLinkId: 'discover', app: ['discover', 'kibana'], @@ -78,7 +80,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.visualizeFeatureName', { defaultMessage: 'Visualize', }), - order: 200, + order: 700, + category: DEFAULT_APP_CATEGORIES.kibana, icon: 'visualizeApp', navLinkId: 'visualize', app: ['visualize', 'lens', 'kibana'], @@ -138,7 +141,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), - order: 300, + order: 200, + category: DEFAULT_APP_CATEGORIES.kibana, icon: 'dashboardApp', navLinkId: 'dashboards', app: ['dashboards', 'kibana'], @@ -217,6 +221,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS defaultMessage: 'Dev Tools', }), order: 1300, + category: DEFAULT_APP_CATEGORIES.management, icon: 'devToolsApp', navLinkId: 'dev_tools', app: ['dev_tools', 'kibana'], @@ -254,6 +259,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS defaultMessage: 'Advanced Settings', }), order: 1500, + category: DEFAULT_APP_CATEGORIES.management, icon: 'advancedSettingsApp', app: ['kibana'], catalogue: ['advanced_settings'], @@ -293,6 +299,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS defaultMessage: 'Index Pattern Management', }), order: 1600, + category: DEFAULT_APP_CATEGORIES.management, icon: 'indexPatternApp', app: ['kibana'], catalogue: ['indexPatterns'], @@ -332,6 +339,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS defaultMessage: 'Saved Objects Management', }), order: 1700, + category: DEFAULT_APP_CATEGORIES.management, icon: 'savedObjectsApp', app: ['kibana'], catalogue: ['saved_objects'], @@ -375,6 +383,7 @@ const timelionFeature: KibanaFeatureConfig = { id: 'timelion', name: 'Timelion', order: 350, + category: DEFAULT_APP_CATEGORIES.kibana, icon: 'timelionApp', navLinkId: 'timelion', app: ['timelion', 'kibana'], diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index ee11e0e2bbe2e..ce6fb548ae6d2 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -35,6 +35,7 @@ describe('Features Plugin', () => { id: 'baz', name: 'baz', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); @@ -63,6 +64,7 @@ describe('Features Plugin', () => { id: 'baz', name: 'baz', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index 30aa6d07f6b5a..692a889203131 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -28,6 +28,7 @@ describe('GET /api/features', () => { id: 'feature_1', name: 'Feature 1', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); @@ -36,6 +37,7 @@ describe('GET /api/features', () => { name: 'Feature 2', order: 2, app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); @@ -44,6 +46,7 @@ describe('GET /api/features', () => { name: 'Feature 2', order: 1, app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); @@ -51,6 +54,7 @@ describe('GET /api/features', () => { id: 'licensed_feature', name: 'Licensed Feature', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, validLicenses: ['gold'], privileges: null, }); diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index 7532bc0573b08..f5ba17a632c92 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -46,6 +46,7 @@ describe('populateUICapabilities', () => { id: 'newFeature', name: 'my new feature', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(), read: createKibanaFeaturePrivilege(), @@ -93,6 +94,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(), @@ -146,6 +148,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, catalogue: ['anotherFooEntry', 'anotherBarEntry'], privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), @@ -215,6 +218,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(['capability3', 'capability4', 'capability5']), @@ -245,6 +249,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: '', @@ -289,6 +294,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(['capability3', 'capability4']), @@ -360,6 +366,7 @@ describe('populateUICapabilities', () => { name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(['capability3', 'capability4']), @@ -369,6 +376,7 @@ describe('populateUICapabilities', () => { id: 'anotherNewFeature', name: 'another new feature', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(['capability3', 'capability4']), @@ -379,6 +387,7 @@ describe('populateUICapabilities', () => { name: 'yet another new feature', navLinkId: 'yetAnotherNavLink', app: ['bar-app'], + category: { id: 'foo', label: 'foo' }, privileges: { all: createKibanaFeaturePrivilege(['capability1', 'capability2']), read: createKibanaFeaturePrivilege(['something1', 'something2', 'something3']), diff --git a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap index f7e4bfd1c961c..b93e27efccaef 100644 --- a/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap +++ b/x-pack/plugins/global_search_bar/public/components/__snapshots__/search_bar.test.tsx.snap @@ -7,8 +7,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Canvas", "label": "Canvas", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Canvasundefinedundefined", + "title": "Canvas • Kibana", "url": "/app/test/Canvas", }, Object { @@ -16,8 +21,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Discover", "label": "Discover", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Discoverundefinedundefined", + "title": "Discover • Kibana", "url": "/app/test/Discover", }, Object { @@ -25,8 +35,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Graph", "label": "Graph", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Graphundefinedundefined", + "title": "Graph • Kibana", "url": "/app/test/Graph", }, ] @@ -39,8 +54,13 @@ Array [ "className": "euiSelectableTemplateSitewide__listItem", "key": "Discover", "label": "Discover", + "meta": Array [ + Object { + "text": "Kibana", + }, + ], "prepend": undefined, - "title": "Discoverundefinedundefined", + "title": "Discover • Kibana", "url": "/app/test/Discover", }, Object { @@ -54,7 +74,7 @@ Array [ }, ], "prepend": undefined, - "title": "My Dashboard • Test", + "title": "My Dashboard • Test", "url": "/app/test/My Dashboard", }, ] @@ -66,7 +86,7 @@ exports[`SearchBar supports keyboard shortcuts 1`] = ` aria-haspopup="listbox" aria-label="Filter options" autocomplete="off" - class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiSelectableSearch euiSelectableTemplateSitewide__search" + class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiFieldSearch-isClearable euiSelectableSearch euiSelectableTemplateSitewide__search" data-test-subj="header-search" placeholder="Search Elastic" type="search" diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 0d1e8725b4911..11fbc7931e620 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -21,6 +21,7 @@ type Result = { id: string; type: string } | string; const createResult = (result: Result): GlobalSearchResult => { const id = typeof result === 'string' ? result : result.id; const type = typeof result === 'string' ? 'application' : result.type; + const meta = type === 'application' ? { categoryLabel: 'Kibana' } : { categoryLabel: null }; return { id, @@ -28,6 +29,7 @@ const createResult = (result: Result): GlobalSearchResult => { title: id, url: `/app/test/${id}`, score: 42, + meta, }; }; @@ -74,7 +76,7 @@ describe('SearchBar', () => { expect(findSpy).toHaveBeenCalledTimes(1); expect(findSpy).toHaveBeenCalledWith('', {}); expect(getSelectableProps(component).options).toMatchSnapshot(); - await wait(() => getSearchProps(component).onSearch('d')); + await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } })); jest.runAllTimers(); component.update(); expect(getSelectableProps(component).options).toMatchSnapshot(); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index d00349e21a7e4..54066cee414d8 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -11,6 +11,8 @@ import { EuiSelectableTemplateSitewide, EuiSelectableTemplateSitewideOption, EuiText, + EuiIcon, + EuiHeaderSectionItemButton, EuiSelectableMessage, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -52,14 +54,20 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) { if (!isMounted()) return; _setOptions([ - ..._options.map((option) => ({ - key: option.id, - label: option.title, - url: option.url, - ...(option.icon && { icon: { type: option.icon } }), - ...(option.type && - option.type !== 'application' && { meta: [{ text: cleanMeta(option.type) }] }), - })), + ..._options.map(({ id, title, url, icon, type, meta }) => { + const option: EuiSelectableTemplateSitewideOption = { + key: id, + label: title, + url, + }; + + if (icon) option.icon = { type: icon }; + + if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }]; + else option.meta = [{ text: cleanMeta(type) }]; + + return option; + }), ]); }, [isMounted, _setOptions] @@ -132,8 +140,20 @@ export function SearchBar({ globalSearch, navigateToUrl }: Props) { + + + } searchProps={{ - onSearch: setSearchValue, + onKeyUpCapture: (e: React.KeyboardEvent) => + setSearchValue(e.currentTarget.value), 'data-test-subj': 'header-search', inputRef: setSearchRef, compressed: true, diff --git a/x-pack/plugins/graph/server/plugin.ts b/x-pack/plugins/graph/server/plugin.ts index d69c592655fb5..21c50bf82f4bc 100644 --- a/x-pack/plugins/graph/server/plugin.ts +++ b/x-pack/plugins/graph/server/plugin.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { Plugin, CoreSetup, CoreStart } from 'src/core/server'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { LicenseState } from './lib/license_state'; import { registerSearchRoute } from './routes/search'; @@ -46,7 +47,8 @@ export class GraphPlugin implements Plugin { name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', }), - order: 1200, + order: 600, + category: DEFAULT_APP_CATEGORIES.kibana, icon: 'graphApp', navLinkId: 'graph', app: ['graph', 'kibana'], diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 3867c30655379..f195228775772 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -14,8 +14,8 @@ import { SinonFakeServer } from 'sinon'; import { ReactWrapper } from 'enzyme'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; +import { createMemoryHistory } from 'history'; -import { init as initHttpRequests } from './helpers/http_requests'; import { notificationServiceMock, fatalErrorsServiceMock, @@ -41,8 +41,7 @@ import { policyNameAlreadyUsedErrorMessage, maximumDocumentsRequiredMessage, } from '../../public/application/services/policies/policy_validation'; -import { HttpResponse } from './helpers/http_requests'; -import { createMemoryHistory } from 'history'; +import { editPolicyHelpers } from './helpers'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -54,11 +53,8 @@ initNotification( const history = createMemoryHistory(); let server: SinonFakeServer; -let httpRequestsMockHelpers: { - setPoliciesResponse: (response: HttpResponse) => void; - setNodesListResponse: (response: HttpResponse) => void; - setNodesDetailsResponse: (nodeAttributes: string, response: HttpResponse) => void; -}; +let httpRequestsMockHelpers: editPolicyHelpers.EditPolicySetup['http']['httpRequestsMockHelpers']; +let http: editPolicyHelpers.EditPolicySetup['http']; const policy = { phases: { hot: { @@ -94,6 +90,17 @@ const activatePhase = async (rendered: ReactWrapper, phase: string) => { }); rendered.update(); }; +const openNodeAttributesSection = (rendered: ReactWrapper, phase: string) => { + const getControls = () => findTestSubject(rendered, `${phase}-dataTierAllocationControls`); + act(() => { + findTestSubject(getControls(), 'dataTierSelect').simulate('click'); + }); + rendered.update(); + act(() => { + findTestSubject(getControls(), 'customDataAllocationOption').simulate('click'); + }); + rendered.update(); +}; const expectedErrorMessages = (rendered: ReactWrapper, expectedMessages: string[]) => { const errorMessages = rendered.find('.euiFormErrorText'); expect(errorMessages.length).toBe(expectedMessages.length); @@ -119,12 +126,16 @@ const setPolicyName = (rendered: ReactWrapper, policyName: string) => { policyNameField.simulate('change', { target: { value: policyName } }); rendered.update(); }; -const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string) => { +const setPhaseAfter = (rendered: ReactWrapper, phase: string, after: string | number) => { const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); afterInput.simulate('change', { target: { value: after } }); rendered.update(); }; -const setPhaseIndexPriority = (rendered: ReactWrapper, phase: string, priority: string) => { +const setPhaseIndexPriority = ( + rendered: ReactWrapper, + phase: string, + priority: string | number +) => { const priorityInput = rendered.find(`input#${phase}-phaseIndexPriority`); priorityInput.simulate('change', { target: { value: priority } }); rendered.update(); @@ -139,7 +150,9 @@ describe('edit policy', () => { component = ( ); - ({ server, httpRequestsMockHelpers } = initHttpRequests()); + + ({ http } = editPolicyHelpers.setup()); + ({ server, httpRequestsMockHelpers } = http); httpRequestsMockHelpers.setPoliciesResponse(policies); }); @@ -321,7 +334,7 @@ describe('edit policy', () => { describe('warm phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -431,34 +444,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'warm').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(nodeAttributesSelect.find('option').length).toBe(2); }); test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'warm'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'warm'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'warm-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -473,11 +491,23 @@ describe('edit policy', () => { rendered.update(); expect(rendered.find('.euiFlyout').exists()).toBeTruthy(); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'warm'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('cold phase', () => { beforeEach(() => { server.respondImmediately = true; - httpRequestsMockHelpers.setNodesListResponse({}); + http.setupNodeListResponse(); httpRequestsMockHelpers.setNodesDetailsResponse('attribute:true', [ { nodeId: 'testNodeId', stats: { name: 'testNodeName', host: 'testHost' } }, ]); @@ -511,34 +541,39 @@ describe('edit policy', () => { expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show warning instead of node attributes input when none exist', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: { data: ['node1'] }, + }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeTruthy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeTruthy(); expect(getNodeAttributeSelect(rendered, 'cold').exists()).toBeFalsy(); }); test('should show node attributes input when attributes exist', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(nodeAttributesSelect.find('option').length).toBe(2); }); test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { - httpRequestsMockHelpers.setNodesListResponse({ 'attribute:true': ['node1'] }); const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); + openNodeAttributesSection(rendered, 'cold'); + expect(findTestSubject(rendered, 'noNodeAttributesWarning').exists()).toBeFalsy(); const nodeAttributesSelect = getNodeAttributeSelect(rendered, 'cold'); expect(nodeAttributesSelect.exists()).toBeTruthy(); expect(findTestSubject(rendered, 'cold-viewNodeDetailsFlyoutButton').exists()).toBeFalsy(); @@ -563,6 +598,18 @@ describe('edit policy', () => { save(rendered); expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); }); + test('should show default allocation warning when no node roles are found', async () => { + http.setupNodeListResponse({ + nodesByAttributes: {}, + nodesByRoles: {}, + }); + const rendered = mountWithIntl(component); + noRollover(rendered); + setPolicyName(rendered, 'mypolicy'); + await activatePhase(rendered, 'cold'); + expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); + expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); + }); }); describe('delete phase', () => { test('should allow 0 for phase timing', async () => { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.ts new file mode 100644 index 0000000000000..4eeb542671d23 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/edit_policy.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { init as initHttpRequests } from './http_requests'; + +export type EditPolicySetup = ReturnType; + +export const setup = () => { + const { httpRequestsMockHelpers, server } = initHttpRequests(); + + const setupNodeListResponse = ( + response: Record = { + nodesByAttributes: { 'attribute:true': ['node1'] }, + nodesByRoles: { data: ['node1'] }, + } + ) => { + httpRequestsMockHelpers.setNodesListResponse(response); + }; + + return { + http: { + setupNodeListResponse, + httpRequestsMockHelpers, + server, + }, + }; +}; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts index 6cbe3bdf1f8c6..a9d326073e4d3 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/http_requests.ts @@ -40,6 +40,8 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { }; }; +export type HttpRequestMockHelpers = ReturnType; + export const init = () => { const server = sinon.fakeServer.create(); diff --git a/x-pack/legacy/plugins/spaces/server/lib/errors.ts b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts similarity index 57% rename from x-pack/legacy/plugins/spaces/server/lib/errors.ts rename to x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts index 4d8d71dca7af6..4c32ea121bb57 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/errors.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/helpers/index.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { boomify, isBoom } from 'boom'; +import * as editPolicyHelpers from './edit_policy'; -export function wrapError(error: any) { - if (isBoom(error)) { - return error; - } +export { HttpRequestMockHelpers, init } from './http_requests'; - return boomify(error, { statusCode: error.status }); -} +export { editPolicyHelpers }; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/api.ts b/x-pack/plugins/index_lifecycle_management/common/types/api.ts new file mode 100644 index 0000000000000..16b8fbd127ab6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/common/types/api.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type NodeDataRole = 'data' | 'data_hot' | 'data_warm' | 'data_cold' | 'data_frozen'; + +export interface ListNodesRouteResponse { + nodesByAttributes: { [attributePair: string]: string[] }; + nodesByRoles: { [role in NodeDataRole]?: string[] }; +} diff --git a/x-pack/plugins/index_lifecycle_management/common/types/index.ts b/x-pack/plugins/index_lifecycle_management/common/types/index.ts index fef79c7782bb0..a23dc647f1f65 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/index.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './api'; + export * from './policies'; diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 97effee44533a..a0ee82cbbe385 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -6,6 +6,8 @@ import { Index as IndexInterface } from '../../../index_management/common/types'; +export type PhaseWithAllocation = 'warm' | 'cold'; + export interface SerializedPolicy { name: string; phases: Phases; @@ -15,7 +17,6 @@ export interface Phases { hot?: SerializedHotPhase; warm?: SerializedWarmPhase; cold?: SerializedColdPhase; - frozen?: SerializedFrozenPhase; delete?: SerializedDeletePhase; } @@ -62,6 +63,7 @@ export interface SerializedWarmPhase extends SerializedPhase { set_priority?: { priority: number | null; }; + migrate?: { enabled: boolean }; }; } @@ -72,16 +74,7 @@ export interface SerializedColdPhase extends SerializedPhase { set_priority?: { priority: number | null; }; - }; -} - -export interface SerializedFrozenPhase extends SerializedPhase { - actions: { - freeze?: {}; - allocate?: AllocateAction; - set_priority?: { - priority: number | null; - }; + migrate?: { enabled: boolean }; }; } @@ -103,6 +96,13 @@ export interface AllocateAction { require?: { [attribute: string]: string; }; + migrate?: { + /** + * If enabled is ever set it will only be set to `false` because the default value + * for this is `true`. Rather leave unspecified for true. + */ + enabled: false; + }; } export interface Policy { @@ -111,7 +111,6 @@ export interface Policy { hot: HotPhase; warm: WarmPhase; cold: ColdPhase; - frozen: FrozenPhase; delete: DeletePhase; }; } @@ -125,9 +124,23 @@ export interface PhaseWithMinAge { selectedMinimumAgeUnits: string; } +/** + * Different types of allocation markers we use in deserialized policies. + * + * default - use data tier based data allocation based on node roles -- this is ES best practice mode. + * custom - use node_attrs to allocate data to specific nodes + * none - do not move data anywhere when entering a phase + */ +export type DataTierAllocationType = 'default' | 'custom' | 'none'; + export interface PhaseWithAllocationAction { selectedNodeAttrs: string; selectedReplicaCount: string; + /** + * A string value indicating allocation type. If unspecified we assume the user + * wants to use default allocation. + */ + dataTierAllocationType: DataTierAllocationType; } export interface PhaseWithIndexPriority { @@ -170,14 +183,6 @@ export interface ColdPhase freezeEnabled: boolean; } -export interface FrozenPhase - extends CommonPhaseSettings, - PhaseWithMinAge, - PhaseWithAllocationAction, - PhaseWithIndexPriority { - freezeEnabled: boolean; -} - export interface DeletePhase extends CommonPhaseSettings, PhaseWithMinAge { waitForSnapshotPolicy: string; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index f11860d36faf8..8a0a5d9fbdfad 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -10,7 +10,6 @@ import { DeletePhase, HotPhase, WarmPhase, - FrozenPhase, } from '../../../common/types'; export const defaultNewHotPhase: HotPhase = { @@ -38,6 +37,7 @@ export const defaultNewWarmPhase: WarmPhase = { selectedReplicaCount: '', warmPhaseOnRollover: true, phaseIndexPriority: '50', + dataTierAllocationType: 'default', }; export const defaultNewColdPhase: ColdPhase = { @@ -48,16 +48,7 @@ export const defaultNewColdPhase: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '0', -}; - -export const defaultNewFrozenPhase: FrozenPhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - selectedNodeAttrs: '', - selectedReplicaCount: '', - freezeEnabled: false, - phaseIndexPriority: '0', + dataTierAllocationType: 'default', }; export const defaultNewDeletePhase: DeletePhase = { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.ts new file mode 100644 index 0000000000000..2ef0fb145551f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/check_phase_compatibility.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + NodeDataRole, + ListNodesRouteResponse, + PhaseWithAllocation, +} from '../../../../common/types'; + +/** + * Given a phase and current node roles, determine whether the phase + * can use default data tier allocation. + * + * This can only be checked for phases that have an allocate action. + */ +export const isPhaseDefaultDataAllocationCompatible = ( + phase: PhaseWithAllocation, + nodesByRoles: ListNodesRouteResponse['nodesByRoles'] +): boolean => { + // The 'data' role covers all node roles, so if we have at least one node with the data role + // we can use default allocation. + if (nodesByRoles.data?.length) { + return true; + } + + // Otherwise we need to check whether a node role for the specific phase exists + if (nodesByRoles[`data_${phase}` as NodeDataRole]?.length) { + return true; + } + + // Otherwise default allocation has nowhere to allocate new shards to in this phase. + return false; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts new file mode 100644 index 0000000000000..4067ad97fc43b --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/determine_allocation_type.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataTierAllocationType, AllocateAction } from '../../../../common/types'; + +/** + * Determine what deserialized state the policy config represents. + * + * See {@DataTierAllocationType} for more information. + */ +export const determineDataTierAllocationType = ( + allocateAction?: AllocateAction +): DataTierAllocationType => { + if (!allocateAction) { + return 'default'; + } + + if (allocateAction.migrate?.enabled === false) { + return 'none'; + } + + if ( + (allocateAction.require && Object.keys(allocateAction.require).length) || + (allocateAction.include && Object.keys(allocateAction.include).length) || + (allocateAction.exclude && Object.keys(allocateAction.exclude).length) + ) { + return 'custom'; + } + + return 'default'; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts new file mode 100644 index 0000000000000..67a512cefe00c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/data_tiers/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './determine_allocation_type'; + +export * from './check_phase_compatibility'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts new file mode 100644 index 0000000000000..1dabae1a0f0c4 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './data_tiers'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss new file mode 100644 index 0000000000000..62ec3f303e1e8 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.scss @@ -0,0 +1,9 @@ +.indexLifecycleManagement__phase__dataTierAllocation { + &__controlSection { + background-color: $euiColorLightestShade; + padding-top: $euiSizeM; + padding-left: $euiSizeM; + padding-right: $euiSizeM; + padding-bottom: $euiSizeM; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx new file mode 100644 index 0000000000000..4ec488f95c94d --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/data_tier_allocation.tsx @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiText, EuiFormRow, EuiSpacer, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; + +import { DataTierAllocationType } from '../../../../../../common/types'; +import { NodeAllocation } from './node_allocation'; +import { SharedProps } from './types'; + +import './data_tier_allocation.scss'; + +type SelectOptions = EuiSuperSelectOption; + +const i18nTexts = { + allocationFieldLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.allocationFieldLabel', + { defaultMessage: 'Data tier options' } + ), + allocationOptions: { + warm: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.input', + { defaultMessage: 'Use warm nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the warm tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.noneOption.helpText', + { defaultMessage: 'Do not move data in the warm phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.warm.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + cold: { + default: { + input: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.input', + { defaultMessage: 'Use cold nodes (recommended)' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.helpText', + { defaultMessage: 'Move data to nodes in the cold tier.' } + ), + }, + none: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.input', + { defaultMessage: 'Off' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.noneOption.helpText', + { defaultMessage: 'Do not move data in the cold phase.' } + ), + }, + custom: { + inputDisplay: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.input', + { defaultMessage: 'Custom' } + ), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.helpText', + { defaultMessage: 'Move data based on node attributes.' } + ), + }, + }, + }, +}; + +export const DataTierAllocation: FunctionComponent = (props) => { + const { phaseData, setPhaseData, phase, hasNodeAttributes } = props; + + return ( +
+ + setPhaseData('dataTierAllocationType', value)} + options={ + [ + { + value: 'default', + inputDisplay: i18nTexts.allocationOptions[phase].default.input, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].default.input} + +

+ {i18nTexts.allocationOptions[phase].default.helpText} +

+
+ + ), + }, + { + value: 'none', + inputDisplay: i18nTexts.allocationOptions[phase].none.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].none.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].none.helpText} +

+
+ + ), + }, + { + 'data-test-subj': 'customDataAllocationOption', + value: 'custom', + inputDisplay: i18nTexts.allocationOptions[phase].custom.inputDisplay, + dropdownDisplay: ( + <> + {i18nTexts.allocationOptions[phase].custom.inputDisplay} + +

+ {i18nTexts.allocationOptions[phase].custom.helpText} +

+
+ + ), + }, + ] as SelectOptions[] + } + /> +
+ {phaseData.dataTierAllocationType === 'custom' && hasNodeAttributes && ( + <> + +
+ +
+ + )} +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx new file mode 100644 index 0000000000000..5aba411b6fe53 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/default_allocation_warning.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + warm: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the warm tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the warm tier to use role-based allocation. The policy will fail to complete allocation if there are no warm nodes.', + } + ), + }, + cold: { + title: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle', + { defaultMessage: 'No nodes assigned to the cold tier' } + ), + body: i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableBody', + { + defaultMessage: + 'Assign at least one node to the cold tier to use role-based allocation. The policy will fail to complete allocation if there are no cold nodes.', + } + ), + }, +}; + +interface Props { + phase: PhaseWithAllocation; +} + +export const DefaultAllocationWarning: FunctionComponent = ({ phase }) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts new file mode 100644 index 0000000000000..26464a75ae14c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NodesDataProvider } from './node_data_provider'; +export { NodeAllocation } from './node_allocation'; +export { NodeAttrsDetails } from './node_attrs_details'; +export { DataTierAllocation } from './data_tier_allocation'; +export { DefaultAllocationWarning } from './default_allocation_warning'; +export { NoNodeAttributesWarning } from './no_node_attributes_warning'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx new file mode 100644 index 0000000000000..ceccc51f95c1f --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/no_node_attributes_warning.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { PhaseWithAllocation } from '../../../../../../common/types'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.nodeAttributesMissingLabel', { + defaultMessage: 'No custom node attributes configured', + }), + warm: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warm.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Warm nodes will be used instead.', + } + ), + }, + cold: { + body: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.cold.nodeAttributesMissingDescription', + { + defaultMessage: + 'Define custom node attributes in elasticsearch.yml to use attribute-based allocation. Cold nodes will be used instead.', + } + ), + }, +}; + +export const NoNodeAttributesWarning: FunctionComponent<{ phase: PhaseWithAllocation }> = ({ + phase, +}) => { + return ( + <> + + + {i18nTexts[phase].body} + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx new file mode 100644 index 0000000000000..a57a6ba4ff2c6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_allocation.tsx @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; + +import { PhaseWithAllocationAction } from '../../../../../../common/types'; +import { propertyof } from '../../../../services/policies/policy_validation'; + +import { ErrableFormRow } from '../form_errors'; + +import { NodeAttrsDetails } from './node_attrs_details'; +import { SharedProps } from './types'; +import { LearnMoreLink } from '../learn_more_link'; + +const learnMoreLink = ( + + } + docPath="modules-cluster.html#cluster-shard-allocation-settings" + /> +); + +const i18nTexts = { + doNotModifyAllocationOption: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.doNotModifyAllocationOption', + { defaultMessage: 'Do not modify allocation configuration' } + ), +}; + +export const NodeAllocation: FunctionComponent = ({ + phase, + setPhaseData, + errors, + phaseData, + isShowingErrors, + nodes, +}) => { + const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( + null + ); + + const nodeOptions = Object.keys(nodes).map((attrs) => ({ + text: `${attrs} (${nodes[attrs].length})`, + value: attrs, + })); + + nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); + + // check that this string is a valid property + const nodeAttrsProperty = propertyof('selectedNodeAttrs'); + + return ( + <> + +

+ +

+
+ + + {/* + TODO: this field component must be revisited to support setting multiple require values and to support + setting `include and exclude values on ILM policies. See https://github.com/elastic/kibana/issues/77344 + */} + setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} + > + + + ) : null + } + > + { + setPhaseData(nodeAttrsProperty, e.target.value); + }} + /> + + + {selectedNodeAttrsForDetails ? ( + setSelectedNodeAttrsForDetails(null)} + /> + ) : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx similarity index 97% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx index af8833c8082b3..c29495d13eb8e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_attrs_details.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_attrs_details.tsx @@ -20,7 +20,7 @@ import { EuiButton, } from '@elastic/eui'; -import { useLoadNodeDetails } from '../../../services/api'; +import { useLoadNodeDetails } from '../../../../services/api'; interface Props { close: () => void; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx new file mode 100644 index 0000000000000..a7c0f3ec7c866 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/node_data_provider.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButton, EuiCallOut, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ListNodesRouteResponse } from '../../../../../../common/types'; +import { useLoadNodes } from '../../../../services/api'; + +interface Props { + children: (data: ListNodesRouteResponse) => JSX.Element; +} + +export const NodesDataProvider = ({ children }: Props): JSX.Element => { + const { isLoading, data, error, resendRequest } = useLoadNodes(); + + if (isLoading) { + return ( + <> + + + + ); + } + + const renderError = () => { + if (error) { + const { statusCode, message } = error; + return ( + <> + + } + color="danger" + > +

+ {message} ({statusCode}) +

+ + + +
+ + + + ); + } + return null; + }; + + return ( + <> + {renderError()} + {/* `data` will always be defined because we use an initial value when loading */} + {children(data!)} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts new file mode 100644 index 0000000000000..d4cb31a3be9e7 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/data_tier_allocation/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ListNodesRouteResponse, + PhaseWithAllocation, + PhaseWithAllocationAction, +} from '../../../../../../common/types'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; + +export interface SharedProps { + phase: PhaseWithAllocation; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + nodes: ListNodesRouteResponse['nodesByAttributes']; + hasNodeAttributes: boolean; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx new file mode 100644 index 0000000000000..7bf8cd3ba6d90 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_field.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiDescribedFormGroup, EuiDescribedFormGroupProps } from '@elastic/eui'; + +import { ToggleableField, Props as ToggleableFieldProps } from './toggleable_field'; + +type Props = EuiDescribedFormGroupProps & { + switchProps: ToggleableFieldProps; +}; + +export const DescribedFormField: FunctionComponent = ({ + children, + switchProps, + ...restDescribedFormProps +}) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index 4410c4bb38397..2428cade0898e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -8,11 +8,17 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; export { MinAgeInput } from './min_age_input'; -export { NodeAllocation } from './node_allocation'; -export { NodeAttrsDetails } from './node_attrs_details'; export { OptionalLabel } from './optional_label'; export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { SetPriorityInput } from './set_priority_input'; export { SnapshotPolicies } from './snapshot_policies'; +export { + DataTierAllocation, + NodeAllocation, + NodeAttrsDetails, + NodesDataProvider, + DefaultAllocationWarning, +} from './data_tier_allocation'; +export { DescribedFormField } from './described_form_field'; export { Forcemerge } from './forcemerge'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx index d7edbac3d1c54..2e70ef255524d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.tsx @@ -27,11 +27,6 @@ function getTimingLabelForPhase(phase: keyof Phases) { defaultMessage: 'Timing for cold phase', }); - case 'frozen': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseFrozen.minimumAgeLabel', { - defaultMessage: 'Timing for frozen phase', - }); - case 'delete': return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { defaultMessage: 'Timing for delete phase', diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx deleted file mode 100644 index 6a22d8716514c..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/node_allocation.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - EuiSelect, - EuiButtonEmpty, - EuiCallOut, - EuiSpacer, - EuiLoadingSpinner, - EuiButton, -} from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { useLoadNodes } from '../../../services/api'; -import { NodeAttrsDetails } from './node_attrs_details'; -import { PhaseWithAllocationAction, Phases } from '../../../../../common/types'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; - -const learnMoreLink = ( - - - - } - docPath="modules-cluster.html#cluster-shard-allocation-settings" - /> - -); - -interface Props { - phase: keyof Phases & string; - errors?: PhaseValidationErrors; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} -export const NodeAllocation = ({ - phase, - setPhaseData, - errors, - phaseData, - isShowingErrors, -}: React.PropsWithChildren>) => { - const { isLoading, data: nodes, error, resendRequest } = useLoadNodes(); - - const [selectedNodeAttrsForDetails, setSelectedNodeAttrsForDetails] = useState( - null - ); - - if (isLoading) { - return ( - - - - - ); - } - - if (error) { - const { statusCode, message } = error; - return ( - - - } - color="danger" - > -

- {message} ({statusCode}) -

- - - -
- - -
- ); - } - - let nodeOptions = Object.keys(nodes).map((attrs) => ({ - text: `${attrs} (${nodes[attrs].length})`, - value: attrs, - })); - - nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - if (nodeOptions.length) { - nodeOptions = [ - { - text: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.defaultNodeAllocation', { - defaultMessage: "Default allocation (don't use attributes)", - }), - value: '', - }, - ...nodeOptions, - ]; - } - if (!nodeOptions.length) { - return ( - - - } - color="warning" - > - - {learnMoreLink} - - - - - ); - } - - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - - return ( - - - { - setPhaseData(nodeAttrsProperty, e.target.value); - }} - /> - - {!!phaseData.selectedNodeAttrs ? ( - setSelectedNodeAttrsForDetails(phaseData.selectedNodeAttrs)} - > - - - ) : null} - {learnMoreLink} - - - {selectedNodeAttrsForDetails ? ( - setSelectedNodeAttrsForDetails(null)} - /> - ) : null} - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx new file mode 100644 index 0000000000000..ff4301808db33 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/toggleable_field.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, useState } from 'react'; +import { EuiSpacer, EuiSwitch, EuiSwitchProps } from '@elastic/eui'; + +export interface Props extends Omit { + initialValue: boolean; + onChange: (nextValue: boolean) => void; +} + +export const ToggleableField: FunctionComponent = ({ + initialValue, + onChange, + children, + ...restProps +}) => { + const [isContentVisible, setIsContentVisible] = useState(initialValue); + + return ( + <> + { + const nextValue = e.target.checked; + setIsContentVisible(nextValue); + onChange(nextValue); + }} + /> + + {isContentVisible ? children : null} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index f1c287788e08d..67e8e42cf6fd1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -43,9 +43,9 @@ import { } from '../../services/policies/policy_serialization'; import { ErrableFormRow, LearnMoreLink, PolicyJsonFlyout } from './components'; -import { ColdPhase, DeletePhase, FrozenPhase, HotPhase, WarmPhase } from './phases'; +import { ColdPhase, DeletePhase, HotPhase, WarmPhase } from './phases'; -interface Props { +export interface Props { policies: PolicyFromES[]; policyName: string; getUrlForApp: ( @@ -119,15 +119,35 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = (phase: keyof Phases, key: string, value: any) => { - setPolicy({ - ...policy, - phases: { - ...policy.phases, - [phase]: { ...policy.phases[phase], [key]: value }, - }, - }); - }; + const setPhaseData = useCallback( + (phase: keyof Phases, key: string, value: any) => { + setPolicy((nextPolicy) => ({ + ...nextPolicy, + phases: { + ...nextPolicy.phases, + [phase]: { ...nextPolicy.phases[phase], [key]: value }, + }, + })); + }, + [setPolicy] + ); + + const setHotPhaseData = useCallback( + (key: string, value: any) => setPhaseData('hot', key, value), + [setPhaseData] + ); + const setWarmPhaseData = useCallback( + (key: string, value: any) => setPhaseData('warm', key, value), + [setPhaseData] + ); + const setColdPhaseData = useCallback( + (key: string, value: any) => setPhaseData('cold', key, value), + [setPhaseData] + ); + const setDeletePhaseData = useCallback( + (key: string, value: any) => setPhaseData('delete', key, value), + [setPhaseData] + ); const setWarmPhaseOnRollover = (value: boolean) => { setPolicy({ @@ -277,7 +297,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('hot', key, value)} + setPhaseData={setHotPhaseData} phaseData={policy.phases.hot} setWarmPhaseOnRollover={setWarmPhaseOnRollover} /> @@ -287,7 +307,7 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('warm', key, value)} + setPhaseData={setWarmPhaseData} phaseData={policy.phases.warm} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> @@ -297,28 +317,18 @@ export const EditPolicy: React.FunctionComponent = ({ 0} - setPhaseData={(key, value) => setPhaseData('cold', key, value)} + setPhaseData={setColdPhaseData} phaseData={policy.phases.cold} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> - 0} - setPhaseData={(key, value) => setPhaseData('frozen', key, value)} - phaseData={policy.phases.frozen} - hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} - /> - - - 0} getUrlForApp={getUrlForApp} - setPhaseData={(key, value) => setPhaseData('delete', key, value)} + setPhaseData={setDeletePhaseData} phaseData={policy.phases.delete} hotPhaseRolloverEnabled={policy.phases.hot.rolloverEnabled} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx index ae2858e7a84ae..241a98fffa6df 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/cold_phase.tsx @@ -4,19 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { FunctionComponent, Fragment } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; +import { EuiFieldNumber, EuiDescribedFormGroup, EuiSwitch, EuiTextColor } from '@elastic/eui'; import { ColdPhase as ColdPhaseInterface, Phases } from '../../../../../common/types'; import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; @@ -27,14 +19,24 @@ import { PhaseErrorMessage, OptionalLabel, ErrableFormRow, - MinAgeInput, - NodeAllocation, SetPriorityInput, + MinAgeInput, + DescribedFormField, } from '../components'; -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); +import { DataTierAllocationField } from './shared'; + +const i18nTexts = { + freezeLabel: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel', { + defaultMessage: 'Freeze index', + }), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.dataTier.description', { + defaultMessage: + 'Move data to data nodes optimized for less frequent, read-only access. Store cold data on less-expensive hardware.', + }), + }, +}; const coldProperty: keyof Phases = 'cold'; const phaseProperty = (propertyName: keyof ColdPhaseInterface) => propertyName; @@ -46,18 +48,17 @@ interface Props { errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class ColdPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const ColdPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> + {/* Section title group; containing min age */} @@ -86,7 +87,7 @@ export class ColdPhase extends PureComponent { data-test-subj="enablePhaseSwitch-cold" label={ } @@ -101,68 +102,83 @@ export class ColdPhase extends PureComponent { } fullWidth > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={coldProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={coldProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.coldPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - + {phaseData.phaseEnabled ? ( + + errors={errors} + phaseData={phaseData} + phase={coldProperty} + isShowingErrors={isShowingErrors} + setPhaseData={setPhaseData} + rolloverEnabled={hotPhaseRolloverEnabled} + /> + ) : null} {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + {/* Replicas section */} + + {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); + }} + min={0} + /> + + + {/* Freeze section */} @@ -191,8 +207,8 @@ export class ColdPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); }} - label={freezeLabel} - aria-label={freezeLabel} + label={i18nTexts.freezeLabel} + aria-label={i18nTexts.freezeLabel} /> @@ -204,7 +220,7 @@ export class ColdPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx deleted file mode 100644 index bfaf141438169..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/frozen_phase.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { PureComponent, Fragment } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFieldNumber, - EuiDescribedFormGroup, - EuiSwitch, - EuiTextColor, -} from '@elastic/eui'; - -import { FrozenPhase as FrozenPhaseInterface, Phases } from '../../../../../common/types'; -import { PhaseValidationErrors } from '../../../services/policies/policy_validation'; - -import { - LearnMoreLink, - ActiveBadge, - PhaseErrorMessage, - OptionalLabel, - ErrableFormRow, - MinAgeInput, - NodeAllocation, - SetPriorityInput, -} from '../components'; - -const freezeLabel = i18n.translate('xpack.indexLifecycleMgmt.frozenPhase.freezeIndexLabel', { - defaultMessage: 'Freeze index', -}); - -const frozenProperty: keyof Phases = 'frozen'; -const phaseProperty = (propertyName: keyof FrozenPhaseInterface) => propertyName; - -interface Props { - setPhaseData: (key: keyof FrozenPhaseInterface & string, value: string | boolean) => void; - phaseData: FrozenPhaseInterface; - isShowingErrors: boolean; - errors?: PhaseValidationErrors; - hotPhaseRolloverEnabled: boolean; -} -export class FrozenPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
- -

- -

{' '} - {phaseData.phaseEnabled && !isShowingErrors ? : null} - -
- } - titleSize="s" - description={ - -

- -

- - } - id={`${frozenProperty}-${phaseProperty('phaseEnabled')}`} - checked={phaseData.phaseEnabled} - onChange={(e) => { - setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); - }} - aria-controls="frozenPhaseContent" - /> -
- } - fullWidth - > - - {phaseData.phaseEnabled ? ( - - - errors={errors} - phaseData={phaseData} - phase={frozenProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - - - - phase={frozenProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.freezeEnabled} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.frozenPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData(phaseProperty('selectedReplicaCount'), e.target.value); - }} - min={0} - /> - - - - - ) : ( -
- )} - - - {phaseData.phaseEnabled ? ( - - - - - } - description={ - - {' '} - - - } - fullWidth - titleSize="xs" - > - { - setPhaseData(phaseProperty('freezeEnabled'), e.target.checked); - }} - label={freezeLabel} - aria-label={freezeLabel} - /> - - - errors={errors} - phaseData={phaseData} - phase={frozenProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - /> - - ) : null} -
- ); - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts index d59f2ff6413fd..8d1ace5950497 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/index.ts @@ -7,5 +7,4 @@ export { HotPhase } from './hot_phase'; export { WarmPhase } from './warm_phase'; export { ColdPhase } from './cold_phase'; -export { FrozenPhase } from './frozen_phase'; export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx new file mode 100644 index 0000000000000..6475e5286a778 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/data_tier_allocation_field.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { PhaseWithAllocationAction, PhaseWithAllocation } from '../../../../../../common/types'; + +import { + DataTierAllocation, + DefaultAllocationWarning, + NoNodeAttributesWarning, + NodesDataProvider, +} from '../../components/data_tier_allocation'; +import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { isPhaseDefaultDataAllocationCompatible } from '../../../../lib/data_tiers'; + +const i18nTexts = { + title: i18n.translate('xpack.indexLifecycleMgmt.common.dataTier.title', { + defaultMessage: 'Data allocation', + }), +}; + +interface Props { + description: React.ReactNode; + phase: PhaseWithAllocation; + setPhaseData: (dataKey: keyof PhaseWithAllocationAction, value: string) => void; + isShowingErrors: boolean; + errors?: PhaseValidationErrors; + phaseData: PhaseWithAllocationAction; +} + +/** + * Top-level layout control for the data tier allocation field. + */ +export const DataTierAllocationField: FunctionComponent = ({ + description, + phase, + phaseData, + setPhaseData, + isShowingErrors, + errors, +}) => { + return ( + + {(nodesData) => { + const isCompatible = isPhaseDefaultDataAllocationCompatible(phase, nodesData.nodesByRoles); + const hasNodeAttrs = Boolean(Object.keys(nodesData.nodesByAttributes ?? {}).length); + + return ( + {i18nTexts.title}} + description={description} + fullWidth + > + + <> + + + {/* Data tier related warnings */} + + {phaseData.dataTierAllocationType === 'default' && !isCompatible && ( + + )} + + {phaseData.dataTierAllocationType === 'custom' && !hasNodeAttrs && ( + + )} + + + + ); + }} + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts new file mode 100644 index 0000000000000..f9e939058adb9 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/shared/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DataTierAllocationField } from './data_tier_allocation_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx index c806056899cac..16a740b1171c9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/phases/warm_phase.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, PureComponent } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -27,44 +27,53 @@ import { OptionalLabel, ErrableFormRow, SetPriorityInput, - NodeAllocation, MinAgeInput, + DescribedFormField, Forcemerge, } from '../components'; +import { DataTierAllocationField } from './shared'; -const shrinkLabel = i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { - defaultMessage: 'Shrink index', -}); - -const moveToWarmPhaseOnRolloverLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', - { - defaultMessage: 'Move to warm phase on rollover', - } -); +const i18nTexts = { + shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { + defaultMessage: 'Shrink index', + }), + moveToWarmPhaseOnRolloverLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', + { + defaultMessage: 'Move to warm phase on rollover', + } + ), + dataTierAllocation: { + description: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.dataTier.description', { + defaultMessage: + 'Move warm data to nodes optimized for read-only access. Store warm data on less-expensive hardware.', + }), + }, +}; const warmProperty: keyof Phases = 'warm'; const phaseProperty = (propertyName: keyof WarmPhaseInterface) => propertyName; interface Props { - setPhaseData: (key: keyof WarmPhaseInterface & string, value: boolean | string) => void; + setPhaseData: ( + key: keyof WarmPhaseInterface & string, + value: boolean | string | undefined + ) => void; phaseData: WarmPhaseInterface; isShowingErrors: boolean; errors?: PhaseValidationErrors; hotPhaseRolloverEnabled: boolean; } -export class WarmPhase extends PureComponent { - render() { - const { - setPhaseData, - phaseData, - errors, - isShowingErrors, - hotPhaseRolloverEnabled, - } = this.props; - - return ( -
+export const WarmPhase: FunctionComponent = ({ + setPhaseData, + phaseData, + errors, + isShowingErrors, + hotPhaseRolloverEnabled, +}) => { + return ( +
+ <> @@ -115,7 +124,7 @@ export class WarmPhase extends PureComponent { { @@ -137,58 +146,75 @@ export class WarmPhase extends PureComponent { /> ) : null} - - - - - phase={warmProperty} - setPhaseData={setPhaseData} - errors={errors} - phaseData={phaseData} - isShowingErrors={isShowingErrors} - /> - - - - - - - - } - isShowingErrors={isShowingErrors} - errors={errors?.selectedReplicaCount} - helpText={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.replicaCountHelpText', - { - defaultMessage: 'By default, the number of replicas remains the same.', - } - )} - > - { - setPhaseData('selectedReplicaCount', e.target.value); - }} - min={0} - /> - - - - - ) : null} + {phaseData.phaseEnabled ? ( + {/* Data tier allocation section */} + + + + {i18n.translate('xpack.indexLifecycleMgmt.warmPhase.replicasTitle', { + defaultMessage: 'Replicas', + })} + + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', + { defaultMessage: 'Set replicas' } + ), + initialValue: Boolean(phaseData.selectedReplicaCount), + onChange: (v) => { + if (!v) { + setPhaseData('selectedReplicaCount', ''); + } + }, + }} + fullWidth + > + + + + + } + isShowingErrors={isShowingErrors} + errors={errors?.selectedReplicaCount} + > + { + setPhaseData('selectedReplicaCount', e.target.value); + }} + min={0} + /> + + @@ -217,8 +243,8 @@ export class WarmPhase extends PureComponent { onChange={(e) => { setPhaseData(phaseProperty('shrinkEnabled'), e.target.checked); }} - label={shrinkLabel} - aria-label={shrinkLabel} + label={i18nTexts.shrinkLabel} + aria-label={i18nTexts.shrinkLabel} aria-controls="shrinkContent" /> @@ -275,7 +301,7 @@ export class WarmPhase extends PureComponent { /> ) : null} -
- ); - } -} + +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx index 265d5146b2c37..a07e149f8111b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; -import { get, find } from 'lodash'; +import React, { Fragment, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, - EuiSelect, + EuiComboBox, EuiForm, EuiFormRow, EuiOverlayMask, @@ -18,82 +17,42 @@ import { EuiFieldText, EuiSpacer, EuiText, + EuiSwitch, + EuiButton, + EuiComboBoxOptionOption, } from '@elastic/eui'; import { PolicyFromES } from '../../../../../common/types'; -import { LearnMoreLink } from '../../edit_policy/components'; -import { addLifecyclePolicyToTemplate, loadIndexTemplates } from '../../../services/api'; +import { addLifecyclePolicyToTemplate, useLoadIndexTemplates } from '../../../services/api'; import { toasts } from '../../../services/notification'; import { showApiError } from '../../../services/api_errors'; +import { LearnMoreLink } from '../../edit_policy/components'; interface Props { policy: PolicyFromES; onCancel: () => void; } -interface State { - templates: Array<{ name: string }>; - templateName?: string; - aliasName?: string; - templateError?: string; -} -export class AddPolicyToTemplateConfirmModal extends Component { - constructor(props: Props) { - super(props); - this.state = { - templates: [], - }; - } - async componentDidMount() { - const templates = await loadIndexTemplates(); - this.setState({ templates }); - } - addPolicyToTemplate = async () => { - const { policy, onCancel } = this.props; - const { templateName, aliasName } = this.state; - const policyName = policy.name; - if (!templateName) { - this.setState({ - templateError: i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.noTemplateSelectedErrorMessage', - { defaultMessage: 'You must select an index template.' } - ), - }); - return; - } - try { - await addLifecyclePolicyToTemplate({ - policyName, - templateName, - aliasName, - }); - const message = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.successMessage', - { - defaultMessage: 'Added policy {policyName} to index template {templateName}', - values: { policyName, templateName }, - } - ); - toasts.addSuccess(message); - onCancel(); - } catch (e) { - const title = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.errorMessage', - { - defaultMessage: 'Error adding policy "{policyName}" to index template {templateName}', - values: { policyName, templateName }, - } - ); - showApiError(e, title); - } - }; - renderTemplateHasPolicyWarning() { - const selectedTemplate = this.getSelectedTemplate(); - const existingPolicyName = get(selectedTemplate, 'settings.index.lifecycle.name'); + +export const AddPolicyToTemplateConfirmModal: React.FunctionComponent = ({ + policy, + onCancel, +}) => { + const [isLegacy, setIsLegacy] = useState(false); + const [templateName, setTemplateName] = useState(''); + const [aliasName, setAliasName] = useState(''); + const [templateError, setTemplateError] = useState(''); + + const { error, isLoading, data: templates, resendRequest } = useLoadIndexTemplates(isLegacy); + + const renderTemplateHasPolicyWarning = () => { + const selectedTemplate = templates!.find((template) => template.name === templateName); + const existingPolicyName = selectedTemplate?.settings?.index?.lifecycle?.name; if (!existingPolicyName) { return; } return ( + { ); - } - getSelectedTemplate() { - const { templates, templateName } = this.state; - return find(templates, (template) => template.name === templateName); - } - renderForm() { - const { templates, templateName, templateError } = this.state; - const options = templates.map(({ name }) => { - return { - value: name, - text: name, - }; - }); - options.unshift({ - value: '', - text: i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.chooseTemplateMessage', - { - defaultMessage: 'Select an index template', - } - ), - }); + }; + + const renderUnableToLoadTemplatesCallout = () => { + const { statusCode = '', message = '' } = error!; return ( - - {this.renderTemplateHasPolicyWarning()} - + + } + color="danger" > - { - this.setState({ templateError: undefined, templateName: e.target.value }); - }} - /> - - {this.renderAliasFormElement()} - +

+ {message} ({statusCode}) +

+ + + + + + ); - } - renderAliasFormElement = () => { - const { aliasName } = this.state; - const { policy } = this.props; - const showAliasTextInput = policy && get(policy, 'policy.phases.hot.actions.rollover'); + }; + + const renderAliasFormElement = () => { + const showAliasTextInput = policy.policy.phases.hot?.actions.rollover; if (!showAliasTextInput) { return null; } @@ -182,62 +124,178 @@ export class AddPolicyToTemplateConfirmModal extends Component { { - this.setState({ aliasName: e.target.value }); + setAliasName(e.target.value); }} /> ); }; - render() { - const { policy, onCancel } = this.props; - const title = i18n.translate( - 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.title', - { - defaultMessage: 'Add policy "{name}" to index template', - values: { name: policy.name }, - } - ); + + const renderForm = () => { + let options: EuiComboBoxOptionOption[] = []; + if (templates) { + options = templates.map(({ name }) => { + return { + label: name, + }; + }); + } + const onComboChange = (comboOptions: EuiComboBoxOptionOption[]) => { + setTemplateError(''); + setTemplateName(comboOptions.length > 0 ? comboOptions[0].label : ''); + }; return ( - - - -

+ + + {' '} - + id="xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.showLegacyTemplates" + defaultMessage="Show legacy index templates" + /> + } + checked={isLegacy} + onChange={(e) => { + setTemplateName(''); + setIsLegacy(e.target.checked); + }} + /> + + {error ? ( + renderUnableToLoadTemplatesCallout() + ) : ( + <> + {renderTemplateHasPolicyWarning()} + + } + > + -

-
- - {this.renderForm()} -
-
+ + + )} + {renderAliasFormElement()} + ); - } -} + }; + + const addPolicyToTemplate = async () => { + const policyName = policy.name; + if (!templateName) { + setTemplateError( + i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.noTemplateSelectedErrorMessage', + { defaultMessage: 'You must select an index template.' } + ) + ); + return; + } + try { + await addLifecyclePolicyToTemplate( + { + policyName, + templateName, + aliasName: aliasName === '' ? undefined : aliasName, + }, + isLegacy + ); + const message = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.successMessage', + { + defaultMessage: 'Added policy {policyName} to index template {templateName}', + values: { policyName, templateName }, + } + ); + toasts.addSuccess(message); + onCancel(); + } catch (e) { + const title = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.errorMessage', + { + defaultMessage: 'Error adding policy "{policyName}" to index template {templateName}', + values: { policyName, templateName }, + } + ); + showApiError(e, title); + } + }; + + const title = i18n.translate( + 'xpack.indexLifecycleMgmt.policyTable.addLifecyclePolicyToTemplateConfirmModal.title', + { + defaultMessage: 'Add policy "{name}" to index template', + values: { name: policy.name }, + } + ); + + return ( + + + +

+ {' '} + + } + /> +

+
+ + {renderForm()} +
+
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index 3d068433becbd..f63c62e1fc529 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -6,7 +6,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; -import { PolicyFromES, SerializedPolicy } from '../../../common/types'; +import { PolicyFromES, SerializedPolicy, ListNodesRouteResponse } from '../../../common/types'; import { UIM_POLICY_DELETE, @@ -17,16 +17,13 @@ import { } from '../constants'; import { trackUiMetric } from './ui_metric'; import { sendGet, sendPost, sendDelete, useRequest } from './http'; - -interface GenericObject { - [key: string]: any; -} +import { IndexSettings } from '../../../../index_management/common/types'; export const useLoadNodes = () => { - return useRequest({ + return useRequest({ path: `nodes/list`, method: 'get', - initialData: [], + initialData: { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse, }); }; @@ -37,9 +34,14 @@ export const useLoadNodeDetails = (selectedNodeAttrs: string) => { }); }; -export async function loadIndexTemplates() { - return await sendGet(`templates`); -} +export const useLoadIndexTemplates = (legacy: boolean = false) => { + return useRequest>({ + path: 'templates', + query: { legacy }, + method: 'get', + initialData: [], + }); +}; export async function loadPolicies(withIndices: boolean) { return await sendGet('policies', { withIndices }); @@ -89,8 +91,15 @@ export const addLifecyclePolicyToIndex = async (body: { return response; }; -export const addLifecyclePolicyToTemplate = async (body: GenericObject) => { - const response = await sendPost(`template`, body); +export const addLifecyclePolicyToTemplate = async ( + body: { + policyName: string; + templateName: string; + aliasName?: string; + }, + legacy: boolean = false +) => { + const response = await sendPost(`template`, body, { legacy }); // Only track successful actions. trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX_TEMPLATE); return response; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts index fb1a651b5f550..d61ed1ad25dde 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/http.ts @@ -30,8 +30,8 @@ function getFullPath(path: string): string { return apiPrefix; } -export function sendPost(path: string, payload: GenericObject) { - return _httpClient.post(getFullPath(path), { body: JSON.stringify(payload) }); +export function sendPost(path: string, payload: GenericObject, query?: GenericObject) { + return _httpClient.post(getFullPath(path), { body: JSON.stringify(payload), query }); } export function sendGet(path: string, query?: GenericObject): any { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts index 3b71c11349752..70f172de390e3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/cold_phase.ts @@ -13,6 +13,8 @@ import { PhaseValidationErrors, positiveNumberRequiredMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; const coldPhaseInitialization: ColdPhase = { phaseEnabled: false, @@ -22,6 +24,7 @@ const coldPhaseInitialization: ColdPhase = { selectedReplicaCount: '', freezeEnabled: false, phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhase => { @@ -32,6 +35,12 @@ export const coldPhaseFromES = (phaseSerialized?: SerializedColdPhase): ColdPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); phase.selectedMinimumAge = minAge; @@ -80,19 +89,7 @@ export const coldPhaseToES = ( esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts deleted file mode 100644 index 6249507bcb407..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/frozen_phase.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { isEmpty } from 'lodash'; -import { serializedPhaseInitialization } from '../../constants'; -import { AllocateAction, FrozenPhase, SerializedFrozenPhase } from '../../../../common/types'; -import { isNumber, splitSizeAndUnits } from './policy_serialization'; -import { - numberRequiredMessage, - PhaseValidationErrors, - positiveNumberRequiredMessage, -} from './policy_validation'; - -const frozenPhaseInitialization: FrozenPhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - selectedNodeAttrs: '', - selectedReplicaCount: '', - freezeEnabled: false, - phaseIndexPriority: '', -}; - -export const frozenPhaseFromES = (phaseSerialized?: SerializedFrozenPhase): FrozenPhase => { - const phase = { ...frozenPhaseInitialization }; - if (phaseSerialized === undefined || phaseSerialized === null) { - return phase; - } - - phase.phaseEnabled = true; - - if (phaseSerialized.min_age) { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); - phase.selectedMinimumAge = minAge; - phase.selectedMinimumAgeUnits = minAgeUnits; - } - - if (phaseSerialized.actions) { - const actions = phaseSerialized.actions; - if (actions.allocate) { - const allocate = actions.allocate; - if (allocate.require) { - Object.entries(allocate.require).forEach((entry) => { - phase.selectedNodeAttrs = entry.join(':'); - }); - if (allocate.number_of_replicas) { - phase.selectedReplicaCount = allocate.number_of_replicas.toString(); - } - } - } - - if (actions.freeze) { - phase.freezeEnabled = true; - } - - if (actions.set_priority) { - phase.phaseIndexPriority = actions.set_priority.priority - ? actions.set_priority.priority.toString() - : ''; - } - } - - return phase; -}; - -export const frozenPhaseToES = ( - phase: FrozenPhase, - originalPhase?: SerializedFrozenPhase -): SerializedFrozenPhase => { - if (!originalPhase) { - originalPhase = { ...serializedPhaseInitialization }; - } - - const esPhase = { ...originalPhase }; - - if (isNumber(phase.selectedMinimumAge)) { - esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; - } - - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } - - if (isNumber(phase.selectedReplicaCount)) { - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.number_of_replicas = parseInt(phase.selectedReplicaCount, 10); - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.number_of_replicas; - } - } - - if ( - esPhase.actions.allocate && - !esPhase.actions.allocate.require && - !isNumber(esPhase.actions.allocate.number_of_replicas) && - isEmpty(esPhase.actions.allocate.include) && - isEmpty(esPhase.actions.allocate.exclude) - ) { - // remove allocate action if it does not define require or number of nodes - // and both include and exclude are empty objects (ES will fail to parse if we don't) - delete esPhase.actions.allocate; - } - - if (phase.freezeEnabled) { - esPhase.actions.freeze = {}; - } else { - delete esPhase.actions.freeze; - } - - if (isNumber(phase.phaseIndexPriority)) { - esPhase.actions.set_priority = { - priority: parseInt(phase.phaseIndexPriority, 10), - }; - } else { - delete esPhase.actions.set_priority; - } - - return esPhase; -}; - -export const validateFrozenPhase = (phase: FrozenPhase): PhaseValidationErrors => { - if (!phase.phaseEnabled) { - return {}; - } - - const phaseErrors = {} as PhaseValidationErrors; - - // index priority is optional, but if it's set, it needs to be a positive number - if (phase.phaseIndexPriority) { - if (!isNumber(phase.phaseIndexPriority)) { - phaseErrors.phaseIndexPriority = [numberRequiredMessage]; - } else if (parseInt(phase.phaseIndexPriority, 10) < 0) { - phaseErrors.phaseIndexPriority = [positiveNumberRequiredMessage]; - } - } - - // min age needs to be a positive number - if (!isNumber(phase.selectedMinimumAge)) { - phaseErrors.selectedMinimumAge = [numberRequiredMessage]; - } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { - phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; - } - - return { ...phaseErrors }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts new file mode 100644 index 0000000000000..753ffb111cf13 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts @@ -0,0 +1,370 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import cloneDeep from 'lodash/cloneDeep'; +import { serializePolicy } from './policy_serialization'; +import { + defaultNewColdPhase, + defaultNewDeletePhase, + defaultNewHotPhase, + defaultNewWarmPhase, +} from '../../constants'; +import { DataTierAllocationType } from '../../../../common/types'; + +describe('Policy serialization', () => { + test('serialize a policy using "default" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'default', + // These selected attrs should be ignored + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'default', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: 'another:thing', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { something: 'here' }, + }, + }, + }, + }, + } + ) + ).toEqual({ + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { + include: { keep: 'this' }, + exclude: { keep: 'this' }, + require: { + another: 'thing', + }, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "custom" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'custom', + selectedNodeAttrs: '', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + allocate: { include: {}, exclude: {}, require: { something: 'here' } }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialize a policy using "none" data allocation with no node attributes', () => { + expect( + serializePolicy( + { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none', + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + delete: { ...defaultNewDeletePhase }, + }, + }, + { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + } + ) + ).toEqual({ + // There should be no allocation action in any phases... + name: 'test', + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_size: '50gb', + }, + set_priority: { + priority: 100, + }, + }, + }, + warm: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 50, + }, + }, + }, + cold: { + actions: { + migrate: { + enabled: false, + }, + set_priority: { + priority: 0, + }, + }, + min_age: '0d', + }, + }, + }); + }); + + test('serialization does not alter the original policy', () => { + const originalPolicy = { + name: 'test', + phases: { + hot: { actions: {} }, + warm: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + cold: { + actions: { allocate: { include: {}, exclude: {}, require: { something: 'here' } } }, + }, + }, + }; + + const originalClone = cloneDeep(originalPolicy); + + const deserializedPolicy = { + name: 'test', + phases: { + hot: { ...defaultNewHotPhase }, + warm: { + ...defaultNewWarmPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + cold: { + ...defaultNewColdPhase, + dataTierAllocationType: 'none' as DataTierAllocationType, + selectedNodeAttrs: 'ignore:this', + phaseEnabled: true, + }, + + delete: { ...defaultNewDeletePhase }, + }, + }; + + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'custom'; + serializePolicy(deserializedPolicy, originalPolicy); + deserializedPolicy.phases.warm.dataTierAllocationType = 'default'; + serializePolicy(deserializedPolicy, originalPolicy); + expect(originalPolicy).toEqual(originalClone); + }); +}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts index 31c063aba2c4a..996b2e8c371b8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts @@ -11,14 +11,12 @@ import { defaultNewDeletePhase, defaultNewHotPhase, defaultNewWarmPhase, - defaultNewFrozenPhase, serializedPhaseInitialization, } from '../../constants'; import { hotPhaseFromES, hotPhaseToES } from './hot_phase'; import { warmPhaseFromES, warmPhaseToES } from './warm_phase'; import { coldPhaseFromES, coldPhaseToES } from './cold_phase'; -import { frozenPhaseFromES, frozenPhaseToES } from './frozen_phase'; import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; export const splitSizeAndUnits = (field: string): { size: string; units: string } => { @@ -55,7 +53,6 @@ export const initializeNewPolicy = (newPolicyName: string = ''): Policy => { hot: { ...defaultNewHotPhase }, warm: { ...defaultNewWarmPhase }, cold: { ...defaultNewColdPhase }, - frozen: { ...defaultNewFrozenPhase }, delete: { ...defaultNewDeletePhase }, }, }; @@ -73,7 +70,6 @@ export const deserializePolicy = (policy: PolicyFromES): Policy => { hot: hotPhaseFromES(phases.hot), warm: warmPhaseFromES(phases.warm), cold: coldPhaseFromES(phases.cold), - frozen: frozenPhaseFromES(phases.frozen), delete: deletePhaseFromES(phases.delete), }, }; @@ -98,13 +94,6 @@ export const serializePolicy = ( serializedPolicy.phases.cold = coldPhaseToES(policy.phases.cold, originalEsPolicy.phases.cold); } - if (policy.phases.frozen.phaseEnabled) { - serializedPolicy.phases.frozen = frozenPhaseToES( - policy.phases.frozen, - originalEsPolicy.phases.frozen - ); - } - if (policy.phases.delete.phaseEnabled) { serializedPolicy.phases.delete = deletePhaseToES( policy.phases.delete, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts index f5197e6ffec99..ffd3c01ab001d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { ColdPhase, DeletePhase, - FrozenPhase, HotPhase, Policy, PolicyFromES, @@ -18,7 +17,6 @@ import { validateHotPhase } from './hot_phase'; import { validateWarmPhase } from './warm_phase'; import { validateColdPhase } from './cold_phase'; import { validateDeletePhase } from './delete_phase'; -import { validateFrozenPhase } from './frozen_phase'; export const propertyof = (propertyName: keyof T & string) => propertyName; @@ -117,7 +115,6 @@ export interface ValidationErrors { hot: PhaseValidationErrors; warm: PhaseValidationErrors; cold: PhaseValidationErrors; - frozen: PhaseValidationErrors; delete: PhaseValidationErrors; policyName: string[]; } @@ -158,14 +155,12 @@ export const validatePolicy = ( const hotPhaseErrors = validateHotPhase(policy.phases.hot); const warmPhaseErrors = validateWarmPhase(policy.phases.warm); const coldPhaseErrors = validateColdPhase(policy.phases.cold); - const frozenPhaseErrors = validateFrozenPhase(policy.phases.frozen); const deletePhaseErrors = validateDeletePhase(policy.phases.delete); const isValid = policyNameErrors.length === 0 && Object.keys(hotPhaseErrors).length === 0 && Object.keys(warmPhaseErrors).length === 0 && Object.keys(coldPhaseErrors).length === 0 && - Object.keys(frozenPhaseErrors).length === 0 && Object.keys(deletePhaseErrors).length === 0; return [ isValid, @@ -174,7 +169,6 @@ export const validatePolicy = ( hot: hotPhaseErrors, warm: warmPhaseErrors, cold: coldPhaseErrors, - frozen: frozenPhaseErrors, delete: deletePhaseErrors, }, ]; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts new file mode 100644 index 0000000000000..fe97b85778a53 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { serializePhaseWithAllocation } from './serialize_phase_with_allocation'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.ts new file mode 100644 index 0000000000000..5a9db3069aea6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/shared/serialize_phase_with_allocation.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import cloneDeep from 'lodash/cloneDeep'; + +import { + AllocateAction, + PhaseWithAllocationAction, + SerializedPhase, +} from '../../../../../common/types'; + +export const serializePhaseWithAllocation = ( + phase: PhaseWithAllocationAction, + originalPhaseActions: SerializedPhase['actions'] = {} +): SerializedPhase['actions'] => { + const esPhaseActions: SerializedPhase['actions'] = cloneDeep(originalPhaseActions); + + if (phase.dataTierAllocationType === 'custom' && phase.selectedNodeAttrs) { + const [name, value] = phase.selectedNodeAttrs.split(':'); + esPhaseActions.allocate = esPhaseActions.allocate || ({} as AllocateAction); + esPhaseActions.allocate.require = { + [name]: value, + }; + } else if (phase.dataTierAllocationType === 'none') { + esPhaseActions.migrate = { enabled: false }; + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate; + } + } else if (phase.dataTierAllocationType === 'default') { + if (esPhaseActions.allocate) { + delete esPhaseActions.allocate.require; + } + delete esPhaseActions.migrate; + } + + return esPhaseActions; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts index cc815d67dbc18..6971f652f986b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/warm_phase.ts @@ -16,6 +16,9 @@ import { positiveNumbersAboveZeroErrorMessage, } from './policy_validation'; +import { determineDataTierAllocationType } from '../../lib'; +import { serializePhaseWithAllocation } from './shared'; + const warmPhaseInitialization: WarmPhase = { phaseEnabled: false, warmPhaseOnRollover: false, @@ -28,6 +31,7 @@ const warmPhaseInitialization: WarmPhase = { forceMergeEnabled: false, selectedForceMergeSegments: '', phaseIndexPriority: '', + dataTierAllocationType: 'default', }; export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhase => { @@ -39,6 +43,12 @@ export const warmPhaseFromES = (phaseSerialized?: SerializedWarmPhase): WarmPhas phase.phaseEnabled = true; + if (phaseSerialized.actions.allocate) { + phase.dataTierAllocationType = determineDataTierAllocationType( + phaseSerialized.actions.allocate + ); + } + if (phaseSerialized.min_age) { if (phaseSerialized.min_age === '0ms') { phase.warmPhaseOnRollover = true; @@ -99,19 +109,7 @@ export const warmPhaseToES = ( delete esPhase.min_age; } - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.selectedNodeAttrs) { - const [name, value] = phase.selectedNodeAttrs.split(':'); - esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); - esPhase.actions.allocate.require = { - [name]: value, - }; - } else { - if (esPhase.actions.allocate) { - delete esPhase.actions.allocate.require; - } - } + esPhase.actions = serializePhaseWithAllocation(phase, esPhase.actions); if (isNumber(phase.selectedReplicaCount)) { esPhase.actions.allocate = esPhase.actions.allocate || ({} as AllocateAction); diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts index 3075f9c89eb8d..84b8fa35cfe9b 100644 --- a/x-pack/plugins/index_lifecycle_management/server/plugin.ts +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -82,10 +82,11 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { - const attributes = nodeStats.attributes || {}; - for (const [key, value] of Object.entries(attributes)) { - const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); - if (isNodeAttributeAllowed) { - const attributeString = `${key}:${value}`; - accum[attributeString] = accum[attributeString] || []; - accum[attributeString].push(nodeId); +interface Stats { + nodes: { + [nodeId: string]: { + attributes: Record; + roles: string[]; + }; + }; +} + +function convertStatsIntoList( + stats: Stats, + disallowedNodeAttributes: string[] +): ListNodesRouteResponse { + return Object.entries(stats.nodes).reduce( + (accum, [nodeId, nodeStats]) => { + const attributes = nodeStats.attributes || {}; + for (const [key, value] of Object.entries(attributes)) { + const isNodeAttributeAllowed = !disallowedNodeAttributes.includes(key); + if (isNodeAttributeAllowed) { + const attributeString = `${key}:${value}`; + accum.nodesByAttributes[attributeString] = accum.nodesByAttributes[attributeString] ?? []; + accum.nodesByAttributes[attributeString].push(nodeId); + } + } + + const dataRoles = nodeStats.roles.filter((r) => r.startsWith('data')) as NodeDataRole[]; + for (const role of dataRoles) { + accum.nodesByRoles[role as NodeDataRole] = accum.nodesByRoles[role] ?? []; + accum.nodesByRoles[role as NodeDataRole]!.push(nodeId); } - } - return accum; - }, {}); + return accum; + }, + { nodesByAttributes: {}, nodesByRoles: {} } as ListNodesRouteResponse + ); } async function fetchNodeStats(callAsCurrentUser: LegacyAPICaller): Promise { @@ -54,8 +77,8 @@ export function registerListRoute({ router, config, license, lib }: RouteDepende const stats = await fetchNodeStats( context.core.elasticsearch.legacy.client.callAsCurrentUser ); - const okResponse = { body: convertStatsIntoList(stats, disallowedNodeAttributes) }; - return response.ok(okResponse); + const body: ListNodesRouteResponse = convertStatsIntoList(stats, disallowedNodeAttributes); + return response.ok({ body }); } catch (e) { if (lib.isEsError(e)) { return response.customError({ diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index ba6b8665479a9..8234686a542a9 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -40,6 +40,8 @@ const setPrioritySchema = schema.maybe( const unfollowSchema = schema.maybe(schema.object({})); // Unfollow has no options +const migrateSchema = schema.maybe(schema.object({ enabled: schema.literal(false) })); + const allocateNodeSchema = schema.maybe(schema.recordOf(schema.string(), schema.string())); const allocateSchema = schema.maybe( schema.object({ @@ -76,6 +78,7 @@ const warmPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, readonly: schema.maybe(schema.object({})), // Readonly has no options @@ -94,23 +97,7 @@ const coldPhaseSchema = schema.maybe( schema.object({ min_age: minAgeSchema, actions: schema.object({ - set_priority: setPrioritySchema, - unfollow: unfollowSchema, - allocate: allocateSchema, - freeze: schema.maybe(schema.object({})), // Freeze has no options - searchable_snapshot: schema.maybe( - schema.object({ - snapshot_repository: schema.string(), - }) - ), - }), - }) -); - -const frozenPhaseSchema = schema.maybe( - schema.object({ - min_age: minAgeSchema, - actions: schema.object({ + migrate: migrateSchema, set_priority: setPrioritySchema, unfollow: unfollowSchema, allocate: allocateSchema, @@ -149,7 +136,6 @@ const bodySchema = schema.object({ hot: hotPhaseSchema, warm: warmPhaseSchema, cold: coldPhaseSchema, - frozen: frozenPhaseSchema, delete: deletePhaseSchema, }), }); diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts index c11d981b33dfe..b47f346c6492d 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_add_policy_route.ts @@ -5,45 +5,78 @@ */ import { merge } from 'lodash'; -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { LegacyAPICaller } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; +import { TemplateFromEs, TemplateSerialized } from '../../../../../index_management/common/types'; import { LegacyTemplateSerialized } from '../../../../../index_management/server'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../../services'; -async function getIndexTemplate( +async function getLegacyIndexTemplate( callAsCurrentUser: LegacyAPICaller, templateName: string -): Promise { +): Promise { const response = await callAsCurrentUser('indices.getTemplate', { name: templateName }); return response[templateName]; } +async function getIndexTemplate( + callAsCurrentUser: LegacyAPICaller, + templateName: string +): Promise { + const params = { + method: 'GET', + path: `/_index_template/${encodeURIComponent(templateName)}`, + // we allow 404 incase the user shutdown security in-between the check and now + ignore: [404], + }; + + const { index_templates: templates } = await callAsCurrentUser<{ + index_templates: TemplateFromEs[]; + }>('transport.request', params); + return templates?.find((template) => template.name === templateName)?.index_template; +} + async function updateIndexTemplate( callAsCurrentUser: LegacyAPICaller, + isLegacy: boolean, templateName: string, policyName: string, aliasName?: string ): Promise { - // Fetch existing template - const template = await getIndexTemplate(callAsCurrentUser, templateName); - merge(template, { - settings: { - index: { - lifecycle: { - name: policyName, - rollover_alias: aliasName, - }, + const settings = { + index: { + lifecycle: { + name: policyName, + rollover_alias: aliasName, }, }, - }); + }; + const indexTemplate = isLegacy + ? await getLegacyIndexTemplate(callAsCurrentUser, templateName) + : await getIndexTemplate(callAsCurrentUser, templateName); + if (!indexTemplate) { + return false; + } + if (isLegacy) { + merge(indexTemplate, { settings }); + } else { + merge(indexTemplate, { + template: { + settings, + }, + }); + } + + const pathPrefix = isLegacy ? '/_template/' : '/_index_template/'; const params = { method: 'PUT', - path: `/_template/${encodeURIComponent(templateName)}`, + path: `${pathPrefix}${encodeURIComponent(templateName)}`, ignore: [404], - body: template, + body: indexTemplate, }; return await callAsCurrentUser('transport.request', params); @@ -55,20 +88,35 @@ const bodySchema = schema.object({ aliasName: schema.maybe(schema.string()), }); +const querySchema = schema.object({ + legacy: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), +}); + export function registerAddPolicyRoute({ router, license, lib }: RouteDependencies) { router.post( - { path: addBasePath('/template'), validate: { body: bodySchema } }, + { path: addBasePath('/template'), validate: { body: bodySchema, query: querySchema } }, license.guardApiRoute(async (context, request, response) => { const body = request.body as typeof bodySchema.type; const { templateName, policyName, aliasName } = body; - + const isLegacy = (request.query as TypeOf).legacy === 'true'; try { - await updateIndexTemplate( + const updatedTemplate = await updateIndexTemplate( context.core.elasticsearch.legacy.client.callAsCurrentUser, + isLegacy, templateName, policyName, aliasName ); + if (!updatedTemplate) { + return response.notFound({ + body: i18n.translate('xpack.indexLifecycleMgmt.templateNotFoundMessage', { + defaultMessage: `Template {name} not found.`, + values: { + name: templateName, + }, + }), + }); + } return response.ok(); } catch (e) { if (lib.isEsError(e)) { diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts index afbee246af0d9..b60892428b969 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/templates/register_fetch_route.ts @@ -4,20 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'src/core/server'; -import { LegacyTemplateSerialized } from '../../../../../index_management/server'; - +import { LegacyAPICaller } from 'kibana/server'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { + IndexSettings, + LegacyTemplateSerialized, + TemplateFromEs, +} from '../../../../../index_management/common/types'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../../../services'; -/** - * We don't want to output system template (whose name starts with a ".") which don't - * have a time base index pattern (with a wildcard in it) as those templates are already - * assigned to a single index. - * - * @param {String} templateName The index template - * @param {Array} indexPatterns Index patterns - */ function isReservedSystemTemplate(templateName: string, indexPatterns: string[]): boolean { return ( templateName.startsWith('kibana_index_template') || @@ -28,9 +24,9 @@ function isReservedSystemTemplate(templateName: string, indexPatterns: string[]) ); } -function filterAndFormatTemplates(templates: { +function filterLegacyTemplates(templates: { [templateName: string]: LegacyTemplateSerialized; -}): Array<{}> { +}): Array<{ name: string; settings?: IndexSettings }> { const formattedTemplates = []; const templateNames = Object.keys(templates); for (const templateName of templateNames) { @@ -40,11 +36,6 @@ function filterAndFormatTemplates(templates: { continue; } const formattedTemplate = { - index_lifecycle_name: - settings!.index && settings!.index.lifecycle ? settings!.index.lifecycle.name : undefined, - index_patterns, - allocation_rules: - settings!.index && settings!.index.routing ? settings!.index.routing : undefined, settings, name: templateName, }; @@ -53,12 +44,30 @@ function filterAndFormatTemplates(templates: { return formattedTemplates; } +function filterTemplates( + templates: + | { index_templates: TemplateFromEs[] } + | { [templateName: string]: LegacyTemplateSerialized }, + isLegacy: boolean +): Array<{ name: string; settings?: IndexSettings }> { + if (isLegacy) { + return filterLegacyTemplates(templates as { [templateName: string]: LegacyTemplateSerialized }); + } + const { index_templates: indexTemplates } = templates as { index_templates: TemplateFromEs[] }; + return indexTemplates.map((template: TemplateFromEs) => { + return { name: template.name, settings: template.index_template.template?.settings }; + }); +} + async function fetchTemplates( - callAsCurrentUser: LegacyAPICaller -): Promise<{ [templateName: string]: LegacyTemplateSerialized }> { + callAsCurrentUser: LegacyAPICaller, + isLegacy: boolean +): Promise< + { index_templates: TemplateFromEs[] } | { [templateName: string]: LegacyTemplateSerialized } +> { const params = { method: 'GET', - path: '/_template', + path: isLegacy ? '/_template' : '/_index_template', // we allow 404 incase the user shutdown security in-between the check and now ignore: [404], }; @@ -66,15 +75,21 @@ async function fetchTemplates( return await callAsCurrentUser('transport.request', params); } +const querySchema = schema.object({ + legacy: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), +}); + export function registerFetchRoute({ router, license, lib }: RouteDependencies) { router.get( - { path: addBasePath('/templates'), validate: false }, + { path: addBasePath('/templates'), validate: { query: querySchema } }, license.guardApiRoute(async (context, request, response) => { + const isLegacy = (request.query as TypeOf).legacy === 'true'; try { const templates = await fetchTemplates( - context.core.elasticsearch.legacy.client.callAsCurrentUser + context.core.elasticsearch.legacy.client.callAsCurrentUser, + isLegacy ); - const okResponse = { body: filterAndFormatTemplates(templates) }; + const okResponse = { body: filterTemplates(templates, isLegacy) }; return response.ok(okResponse); } catch (e) { if (lib.isEsError(e)) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/point_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/point_datatype.test.tsx new file mode 100644 index 0000000000000..0ee70d63ba667 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/point_datatype.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { act } from 'react-dom/test-utils'; + +import { componentHelpers, MappingsEditorTestBed } from '../helpers'; + +const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; + +// Parameters automatically added to the point datatype when saved (with the default values) +export const defaultPointParameters = { + type: 'point', + ignore_malformed: false, + ignore_z_value: true, +}; + +describe('Mappings editor: point datatype', () => { + /** + * Variable to store the mappings data forwarded to the consumer component + */ + let data: any; + let onChangeHandler: jest.Mock = jest.fn(); + let getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + let testBed: MappingsEditorTestBed; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + onChangeHandler = jest.fn(); + getMappingsEditorData = getMappingsEditorDataFactory(onChangeHandler); + }); + + test('initial view and default parameters values', async () => { + const defaultMappings = { + properties: { + myField: { + type: 'point', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + + const { + component, + actions: { startEditField, updateFieldAndCloseFlyout }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + + // Save the field and close the flyout + await updateFieldAndCloseFlyout(); + + // It should have the default parameters values added + updatedMappings.properties.myField = defaultPointParameters; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); + + describe('meta parameter', () => { + const defaultMappings = { + properties: { + myField: { + type: 'point', + }, + }, + }; + + const updatedMappings = { ...defaultMappings }; + + const metaParameter = { + meta: { + my_metadata: 'foobar', + }, + }; + + beforeEach(async () => { + await act(async () => { + testBed = setup({ value: defaultMappings, onChange: onChangeHandler }); + }); + testBed.component.update(); + }); + + test('valid meta object', async () => { + const { + component, + actions: { + startEditField, + updateFieldAndCloseFlyout, + showAdvancedSettings, + toggleFormRow, + updateJsonEditor, + }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + await showAdvancedSettings(); + + // Enable the meta parameter and add value + toggleFormRow('metaParameter'); + await act(async () => { + updateJsonEditor('metaParameterEditor', metaParameter.meta); + }); + component.update(); + + // Save the field and close the flyout + await updateFieldAndCloseFlyout(); + + // It should have the default parameters values added, plus metadata + updatedMappings.properties.myField = { + ...defaultPointParameters, + ...metaParameter, + }; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); + + test('strip empty string', async () => { + const { + component, + actions: { startEditField, updateFieldAndCloseFlyout, showAdvancedSettings, toggleFormRow }, + } = testBed; + + // Open the flyout to edit the field + await startEditField('myField'); + await showAdvancedSettings(); + + // Enable the meta parameter + toggleFormRow('metaParameter'); + + // Save the field and close the flyout without adding any values to meta parameter + await updateFieldAndCloseFlyout(); + + // It should have the default parameters values added + updatedMappings.properties.myField = defaultPointParameters; + + ({ data } = await getMappingsEditorData(component)); + expect(data).toEqual(updatedMappings); + }); + }); +}); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 2a4af89c46559..e123dea6ff2ff 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -239,6 +239,10 @@ const createActions = (testBed: TestBed) => { const getCheckboxValue = (testSubject: TestSubjects): boolean => find(testSubject).props().checked; + const toggleFormRow = (formRowName: string) => { + form.toggleEuiSwitch(`${formRowName}.formRowToggle`); + }; + return { selectTab, getFieldAt, @@ -252,6 +256,7 @@ const createActions = (testBed: TestBed) => { getComboBoxValue, getToggleValue, getCheckboxValue, + toggleFormRow, }; }; @@ -365,4 +370,6 @@ export type TestSubjects = | 'searchQuoteAnalyzer-custom' | 'searchQuoteAnalyzer-toggleCustomButton' | 'searchQuoteAnalyzer-custom.input' - | 'useSameAnalyzerForSearchCheckBox.input'; + | 'useSameAnalyzerForSearchCheckBox.input' + | 'metaParameterEditor' + | string; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_z_value_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_z_value_parameter.tsx index bd118ac08964f..ce58a264db968 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_z_value_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/ignore_z_value_parameter.tsx @@ -10,15 +10,18 @@ import { i18n } from '@kbn/i18n'; import { EditFieldFormRow } from '../fields/edit_field'; -export const IgnoreZValueParameter = () => ( +export const IgnoreZValueParameter = ({ description }: { description?: string }) => ( ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts index a2d5c7c8d5308..b3bf071948956 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/index.ts @@ -69,6 +69,8 @@ export * from './other_type_name_parameter'; export * from './other_type_json_parameter'; +export * from './meta_parameter'; + export * from './ignore_above_parameter'; export const PARAMETER_SERIALIZERS = [relationsSerializer, dynamicSerializer]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx new file mode 100644 index 0000000000000..a950ba82d0eac --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/meta_parameter.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { documentationService } from '../../../../../services/documentation'; +import { UseField, JsonEditorField } from '../../../shared_imports'; +import { getFieldConfig } from '../../../lib'; +import { EditFieldFormRow } from '../fields/edit_field'; + +interface Props { + defaultToggleValue: boolean; +} + +export const MetaParameter: FunctionComponent = ({ defaultToggleValue }) => ( + + + +); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx index ba9c75baa1987..1550485ebad93 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/binary_type.tsx @@ -5,14 +5,25 @@ */ import React from 'react'; -import { StoreParameter, DocValuesParameter } from '../../field_parameters'; +import { NormalizedField, ParameterName, Field as FieldType } from '../../../../types'; +import { getFieldConfig } from '../../../../lib'; +import { StoreParameter, DocValuesParameter, MetaParameter } from '../../field_parameters'; import { AdvancedParametersSection } from '../edit_field'; -export const BinaryType = () => { +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +interface Props { + field: NormalizedField; +} + +export const BinaryType = ({ field }: Props) => { return ( + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx index 962606b2f4ffd..1ee2bf22edb44 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx @@ -16,11 +16,13 @@ import { DocValuesParameter, BoostParameter, NullValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -90,6 +92,8 @@ export const BooleanType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx index 74331cb1b6b22..748dc54838270 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/completion_type.tsx @@ -10,11 +10,12 @@ import { i18n } from '@kbn/i18n'; import { NormalizedField, Field as FieldType } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { UseField, Field } from '../../../../shared_imports'; -import { AnalyzersParameter } from '../../field_parameters'; +import { AnalyzersParameter, MetaParameter } from '../../field_parameters'; import { EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': case 'max_input_length': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -88,6 +89,8 @@ export const CompletionType = ({ field }: Props) => { )} formFieldPath="preserve_position_increments" /> + + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx new file mode 100644 index 0000000000000..aa8aefba921e7 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/constant_keyword_type.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { UseField, Field } from '../../../../shared_imports'; +import { getFieldConfig } from '../../../../lib'; +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { MetaParameter } from '../../field_parameters'; +import { AdvancedParametersSection, EditFieldFormRow, BasicParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const ConstantKeywordType: FunctionComponent = ({ field }) => { + return ( + <> + + {/* Value field */} + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx index 0c067d09046d7..35382506a3cd9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx @@ -19,6 +19,7 @@ import { IgnoreMalformedParameter, FormatParameter, LocaleParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'locale': case 'format': + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -73,6 +75,8 @@ export const DateType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx index e96426ece27e8..b1545d44885c8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx @@ -18,6 +18,7 @@ import { NullValueParameter, SimilarityParameter, SplitQueriesOnWhitespaceParameter, + MetaParameter, IgnoreAboveParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -30,6 +31,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'boost': case 'ignore_above': + case 'meta': case 'similarity': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -83,6 +85,8 @@ export const FlattenedType = React.memo(({ field }: Props) => { defaultToggleValue={getDefaultToggleValue('null_value', field.source)} /> + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx index 997e866da35f0..0f28c5080d26d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/geo_point_type.tsx @@ -14,11 +14,14 @@ import { IgnoreMalformedParameter, NullValueParameter, IgnoreZValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { + case 'meta': + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; case 'null_value': { return field.null_value !== undefined; } @@ -65,6 +68,8 @@ export const GeoPointType = ({ field }: Props) => { config={getFieldConfig('null_value_geo_point')} /> + + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx new file mode 100644 index 0000000000000..1ff97a8d72a21 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/histogram_type.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { getFieldConfig } from '../../../../lib'; +import { IgnoreMalformedParameter, MetaParameter } from '../../field_parameters'; +import { AdvancedParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const HistogramType = ({ field }: Props) => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts index d84d9c6ea40cf..6b092c5561b3b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts @@ -28,8 +28,11 @@ import { ObjectType } from './object_type'; import { OtherType } from './other_type'; import { NestedType } from './nested_type'; import { JoinType } from './join_type'; +import { HistogramType } from './histogram_type'; +import { ConstantKeywordType } from './constant_keyword_type'; import { RankFeatureType } from './rank_feature_type'; import { WildcardType } from './wildcard_type'; +import { PointType } from './point_type'; const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { alias: AliasType, @@ -54,8 +57,11 @@ const typeToParametersFormMap: { [key in DataType]?: ComponentType } = { other: OtherType, nested: NestedType, join: JoinType, + histogram: HistogramType, + constant_keyword: ConstantKeywordType, rank_feature: RankFeatureType, wildcard: WildcardType, + point: PointType, }; export const getParametersFormForType = ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx index 3d78205934eea..6ad3c9c5d0bd4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx @@ -18,6 +18,7 @@ import { CoerceNumberParameter, IgnoreMalformedParameter, CopyToParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; import { PARAMETERS_DEFINITION } from '../../../../constants'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'copy_to': case 'boost': + case 'meta': case 'ignore_malformed': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -95,6 +97,8 @@ export const NumericType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/point_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/point_type.tsx new file mode 100644 index 0000000000000..9108c56e4496b --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/point_type.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; +import { UseField, TextAreaField } from '../../../../shared_imports'; +import { getFieldConfig } from '../../../../lib'; +import { + IgnoreMalformedParameter, + IgnoreZValueParameter, + NullValueParameter, + MetaParameter, +} from '../../field_parameters'; +import { AdvancedParametersSection, BasicParametersSection } from '../edit_field'; + +interface Props { + field: NormalizedField; +} + +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { + return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; +}; + +export const PointType: FunctionComponent = ({ field }) => { + return ( + <> + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx index f87d1f9400101..9a37f55ac8e9d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { NormalizedField, Field as FieldType } from '../../../../types'; +import { NormalizedField, Field as FieldType, ParameterName } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; import { StoreParameter, @@ -14,11 +14,12 @@ import { CoerceNumberParameter, FormatParameter, LocaleParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; import { FormDataProvider } from '../../../../shared_imports'; -const getDefaultToggleValue = (param: 'locale' | 'format' | 'boost', field: FieldType) => { +const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; }; @@ -57,6 +58,8 @@ export const RangeType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx index dafbebd24b3fa..3fa456c33f5e9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/search_as_you_type.tsx @@ -15,6 +15,7 @@ import { SimilarityParameter, TermVectorParameter, MaxShingleSizeParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, AdvancedParametersSection } from '../edit_field'; @@ -26,6 +27,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'similarity': case 'term_vector': + case 'meta': case 'max_shingle_size': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -65,6 +67,8 @@ export const SearchAsYouType = React.memo(({ field }: Props) => { /> + + ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx index c4ed11097b609..07def791096e7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx @@ -28,6 +28,7 @@ import { CopyToParameter, TermVectorParameter, FieldDataParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; @@ -40,6 +41,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { case 'boost': case 'position_increment_gap': case 'similarity': + case 'meta': case 'term_vector': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -47,7 +49,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { return field.search_analyzer !== undefined && field.search_analyzer !== field.analyzer; } case 'copy_to': { - return field.null_value !== undefined && field.null_value !== ''; + return field[param] !== undefined && field[param] !== ''; } case 'indexPrefixes': { if (field.index_prefixes === undefined) { @@ -241,6 +243,8 @@ export const TextType = React.memo(({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx index 42854673269ae..5cc2addba53b8 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx @@ -20,12 +20,14 @@ import { BoostParameter, AnalyzerParameter, NullValueParameter, + MetaParameter, } from '../../field_parameters'; import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } from '../edit_field'; const getDefaultToggleValue = (param: string, field: FieldType) => { switch (param) { case 'analyzer': + case 'meta': case 'boost': { return field[param] !== undefined && field[param] !== getFieldConfig(param).defaultValue; } @@ -107,6 +109,8 @@ export const TokenCountType = ({ field }: Props) => { + + diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index a8844c7a9b270..293ae56d57ace 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -71,6 +71,26 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + constant_keyword: { + value: 'constant_keyword', + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.constantKeywordDescription', { + defaultMessage: 'Constant keyword', + }), + documentation: { + main: '/keyword.html#constant-keyword-field-type', + }, + description: () => ( +

+ {'keyword'}, + }} + /> +

+ ), + }, numeric: { value: 'numeric', label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.numericDescription', { @@ -699,6 +719,23 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + histogram: { + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.histogramDescription', { + defaultMessage: 'Histogram', + }), + value: 'histogram', + documentation: { + main: '/histogram.html', + }, + description: () => ( +

+ +

+ ), + }, join: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.joinDescription', { defaultMessage: 'Join', @@ -784,6 +821,26 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = {

), }, + point: { + label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.pointDescription', { + defaultMessage: 'Point', + }), + value: 'point', + documentation: { + main: '/point.html', + }, + description: () => ( +

+ {'x,y'}, + }} + /> +

+ ), + }, wildcard: { label: i18n.translate('xpack.idxMgmt.mappingsEditor.dataType.wildcardDescription', { defaultMessage: 'Wildcard', @@ -822,6 +879,7 @@ export const MAIN_TYPES: MainType[] = [ 'binary', 'boolean', 'completion', + 'constant_keyword', 'date', 'date_nanos', 'dense_vector', @@ -842,7 +900,9 @@ export const MAIN_TYPES: MainType[] = [ 'shape', 'text', 'token_count', + 'histogram', 'wildcard', + 'point', 'other', ]; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx index f2148f1f657a6..4ffedc8ca114d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/parameters_definition.tsx @@ -29,7 +29,7 @@ import { INDEX_DEFAULT } from './default_values'; import { TYPE_DEFINITION } from './data_types_definition'; const { toInt } = fieldFormatters; -const { emptyField, containsCharsField, numberGreaterThanField } = fieldValidators; +const { emptyField, containsCharsField, numberGreaterThanField, isJsonField } = fieldValidators; const commonErrorMessages = { smallerThanZero: i18n.translate( @@ -382,6 +382,50 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, schema: t.any, }, + null_value_point: { + fieldConfig: { + defaultValue: '', + label: nullValueLabel, + helpText: () => ( + + {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.pointWellKnownTextDocumentationLink', + { + defaultMessage: 'Well-Known Text', + } + )} + + ), + }} + /> + ), + validations: [ + { + validator: nullValueValidateEmptyField, + }, + ], + deserializer: (value: any) => { + if (value === '') { + return value; + } + return JSON.stringify(value); + }, + serializer: (value: string) => { + try { + return JSON.parse(value); + } catch (error) { + // swallow error and return non-parsed value; + return value; + } + }, + }, + schema: t.any, + }, copy_to: { fieldConfig: { defaultValue: '', @@ -404,6 +448,98 @@ export const PARAMETERS_DEFINITION: { [key in ParameterName]: ParameterDefinitio }, schema: t.string, }, + value: { + fieldConfig: { + defaultValue: '', + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.valueLabel', { + defaultMessage: 'Value', + }), + }, + schema: t.string, + }, + meta: { + fieldConfig: { + defaultValue: '', + label: i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaLabel', { + defaultMessage: 'Metadata', + }), + helpText: ( + {JSON.stringify({ arbitrary_key: 'anything_goes' })}, + }} + /> + ), + validations: [ + { + validator: isJsonField( + i18n.translate('xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorJsonError', { + defaultMessage: 'Invalid JSON.', + }), + { allowEmptyString: true } + ), + }, + { + validator: ({ value }: ValidationFuncArg) => { + if (typeof value !== 'string' || value.trim() === '') { + return; + } + + const json = JSON.parse(value); + const valuesAreNotString = Object.values(json).some((v) => typeof v !== 'string'); + + if (Array.isArray(json)) { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorArraysNotAllowedError', + { + defaultMessage: 'Arrays are not allowed.', + } + ), + }; + } else if (valuesAreNotString) { + return { + message: i18n.translate( + 'xpack.idxMgmt.mappingsEditor.parameters.metaFieldEditorOnlyStringValuesAllowedError', + { + defaultMessage: 'Values must be a string.', + } + ), + }; + } + }, + }, + ], + deserializer: (value: any) => { + if (value === '') { + return value; + } + return JSON.stringify(value, null, 2); + }, + serializer: (value: string) => { + // Strip out empty strings + if (value.trim() === '') { + return undefined; + } + + try { + const parsed = JSON.parse(value); + // If an empty object was passed, strip out this value entirely. + if (!Object.keys(parsed).length) { + return undefined; + } + return parsed; + } catch (error) { + // swallow error and return non-parsed value; + return value; + } + }, + }, + schema: t.any, + }, max_input_length: { fieldConfig: { defaultValue: 50, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index fd0e4ed32bfe8..ca38a8d1e6c33 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -59,6 +59,9 @@ export type MainType = | 'geo_point' | 'geo_shape' | 'token_count' + | 'point' + | 'histogram' + | 'constant_keyword' | 'wildcard' /** * 'other' is a special type that only exists inside of MappingsEditor as a placeholder @@ -107,6 +110,7 @@ export type ParameterName = | 'null_value_boolean' | 'null_value_geo_point' | 'null_value_ip' + | 'null_value_point' | 'copy_to' | 'dynamic' | 'dynamic_toggle' @@ -146,7 +150,9 @@ export type ParameterName = | 'dims' | 'depth_limit' | 'relations' - | 'max_shingle_size'; + | 'max_shingle_size' + | 'value' + | 'meta'; export interface Parameter { fieldConfig: FieldConfig; diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index afc9c76f1afbe..c52b958d94ae1 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -123,6 +123,10 @@ class DocumentationService { return `${this.esDocsBase}/ignore-malformed.html`; } + public getMetaLink() { + return `${this.esDocsBase}/mapping-field-meta.html`; + } + public getFormatLink() { return `${this.esDocsBase}/mapping-date-format.html`; } diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 4862b2a7e6a59..5cc976969d79c 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -52,6 +52,8 @@ const baseAlertRequestParamsRT = rt.intersection([ ]), criteria: rt.array(rt.any), alertInterval: rt.string, + alertThrottle: rt.string, + alertOnNoData: rt.boolean, }), ]); @@ -91,6 +93,7 @@ export const alertPreviewSuccessResponsePayloadRT = rt.type({ fired: rt.number, noData: rt.number, error: rt.number, + notifications: rt.number, }), }); export type AlertPreviewSuccessResponsePayload = rt.TypeOf< diff --git a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx index 877d047c941d4..02c3ea29c1846 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx @@ -33,6 +33,7 @@ import { getAlertPreview, PreviewableAlertTypes } from './get_alert_preview'; interface Props { alertInterval: string; + alertThrottle: string; alertType: PreviewableAlertTypes; fetch: HttpSetup['fetch']; alertParams: { criteria: any[]; sourceId: string } & Record; @@ -45,6 +46,7 @@ export const AlertPreview: React.FC = (props) => { const { alertParams, alertInterval, + alertThrottle, fetch, alertType, validate, @@ -73,16 +75,27 @@ export const AlertPreview: React.FC = (props) => { ...alertParams, lookback: previewLookbackInterval as 'h' | 'd' | 'w' | 'M', alertInterval, + alertThrottle, + alertOnNoData: showNoDataResults ?? false, } as AlertPreviewRequestParams, alertType, }); - setPreviewResult({ ...result, groupByDisplayName, previewLookbackInterval }); + setPreviewResult({ ...result, groupByDisplayName, previewLookbackInterval, alertThrottle }); } catch (e) { setPreviewError(e); } finally { setIsPreviewLoading(false); } - }, [alertParams, alertInterval, fetch, alertType, groupByDisplayName, previewLookbackInterval]); + }, [ + alertParams, + alertInterval, + fetch, + alertType, + groupByDisplayName, + previewLookbackInterval, + alertThrottle, + showNoDataResults, + ]); const previewIntervalError = useMemo(() => { const intervalInSeconds = getIntervalInSeconds(alertInterval); @@ -101,6 +114,13 @@ export const AlertPreview: React.FC = (props) => { return hasValidationErrors || previewIntervalError; }, [alertParams.criteria, previewIntervalError, validate]); + const showNumberOfNotifications = useMemo(() => { + if (!previewResult) return false; + const { notifications, fired, noData, error } = previewResult.resultTotals; + const unthrottledNotifications = fired + (showNoDataResults ? noData + error : 0); + return unthrottledNotifications > notifications; + }, [previewResult, showNoDataResults]); + return ( = (props) => { <> - {previewResult.resultTotals.fired}{' '} - {previewResult.resultTotals.fired === 1 - ? firedTimeLabel - : firedTimesLabel} + ), }} @@ -173,7 +196,7 @@ export const AlertPreview: React.FC = (props) => { ) : null} e.value === previewResult.previewLookbackInterval @@ -211,6 +234,32 @@ export const AlertPreview: React.FC = (props) => { defaultMessage="An error occurred when trying to evaluate some of the data." /> ) : null} + {showNumberOfNotifications ? ( + <> + + + {i18n.translate( + 'xpack.infra.metrics.alertFlyout.alertPreviewTotalNotificationsNumber', + { + defaultMessage: + '{notifs, plural, one {# notification} other {# notifications}}', + values: { + notifs: previewResult.resultTotals.notifications, + }, + } + )} + + ), + }} + /> + + ) : null}{' '} )} @@ -218,6 +267,7 @@ export const AlertPreview: React.FC = (props) => { <> = (props) => { {previewError.body?.statusCode === 508 ? ( = (props) => { ) : ( = previewOptions.map((o) => omit(o, 'shortText') ); - -const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { - defaultMessage: 'time', -}); -const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { - defaultMessage: 'times', -}); diff --git a/x-pack/plugins/infra/public/alerting/common/index.ts b/x-pack/plugins/infra/public/alerting/common/index.ts index e1b4a70cfb1fc..384391578f0c6 100644 --- a/x-pack/plugins/infra/public/alerting/common/index.ts +++ b/x-pack/plugins/infra/public/alerting/common/index.ts @@ -45,10 +45,3 @@ export const previewOptions = [ }), }, ]; - -export const firedTimeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTime', { - defaultMessage: 'time', -}); -export const firedTimesLabel = i18n.translate('xpack.infra.metrics.alertFlyout.firedTimes', { - defaultMessage: 'times', -}); diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx index ada7a30a859e0..60a00371e5ade 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.test.tsx @@ -69,6 +69,7 @@ describe('Expression', () => { Reflect.set(alertParams, key, value)} diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 5ac2f407839e4..f47f30c280b2a 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -89,6 +89,7 @@ interface Props { alertOnNoData?: boolean; }; alertInterval: string; + alertThrottle: string; alertsContext: AlertsContextValue; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; @@ -104,7 +105,14 @@ const defaultExpression = { } as InventoryMetricConditions; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; + const { + setAlertParams, + alertParams, + errors, + alertsContext, + alertInterval, + alertThrottle, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -378,6 +386,7 @@ export const Expressions: React.FC = (props) => { { Reflect.set(alertParams, key, value)} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 6b102045fa516..c71a3b6b13338 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -51,6 +51,7 @@ interface Props { alertParams: AlertParams; alertsContext: AlertsContextValue; alertInterval: string; + alertThrottle: string; setAlertParams(key: string, value: any): void; setAlertProperty(key: string, value: any): void; } @@ -65,7 +66,14 @@ const defaultExpression = { export { defaultExpression }; export const Expressions: React.FC = (props) => { - const { setAlertParams, alertParams, errors, alertsContext, alertInterval } = props; + const { + setAlertParams, + alertParams, + errors, + alertsContext, + alertInterval, + alertThrottle, + } = props; const { source, createDerivedIndexPattern } = useSourceViaHttp({ sourceId: 'default', type: 'metrics', @@ -399,6 +407,7 @@ export const Expressions: React.FC = (props) => { { const { criteria, filterQuery, nodeType } = params as InventoryMetricThresholdParams; @@ -52,6 +56,10 @@ export const previewInventoryMetricThresholdAlert = async ({ const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; + const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); + const executionsPerThrottle = Math.floor( + (throttleIntervalInSeconds / alertIntervalInSeconds) * alertResultsPerExecution + ); try { const results = await Promise.all( criteria.map((c) => @@ -66,6 +74,12 @@ export const previewInventoryMetricThresholdAlert = async ({ let numberOfTimesFired = 0; let numberOfNoDataResults = 0; let numberOfErrors = 0; + let numberOfNotifications = 0; + let throttleTracker = 0; + const notifyWithThrottle = () => { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker++; + }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); const allConditionsFiredInMappedBucket = results.every((result) => { @@ -79,11 +93,27 @@ export const previewInventoryMetricThresholdAlert = async ({ const someConditionsErrorInMappedBucket = results.some((result) => { return result[item].isError; }); - if (allConditionsFiredInMappedBucket) numberOfTimesFired++; - if (someConditionsNoDataInMappedBucket) numberOfNoDataResults++; - if (someConditionsErrorInMappedBucket) numberOfErrors++; + if (someConditionsErrorInMappedBucket) { + numberOfErrors++; + if (alertOnNoData) { + notifyWithThrottle(); + } + } else if (someConditionsNoDataInMappedBucket) { + numberOfNoDataResults++; + if (alertOnNoData) { + notifyWithThrottle(); + } + } else if (allConditionsFiredInMappedBucket) { + numberOfTimesFired++; + notifyWithThrottle(); + } else if (throttleTracker > 0) { + throttleTracker++; + } + if (throttleTracker === executionsPerThrottle) { + throttleTracker = 0; + } } - return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors]; + return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors, numberOfNotifications]; }); return previewResults; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts index c26b44dfe8ff8..73e17537476c8 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.test.ts @@ -16,11 +16,14 @@ describe('Previewing the metric threshold alert type', () => { ...baseParams, lookback: 'h', alertInterval: '1m', + alertThrottle: '1m', + alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults] = ungroupedResult; + const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; expect(firedResults).toBe(30); expect(noDataResults).toBe(0); expect(errorResults).toBe(0); + expect(notifications).toBe(30); }); test('returns the expected results using a bucket interval shorter than the alert interval', async () => { @@ -28,22 +31,42 @@ describe('Previewing the metric threshold alert type', () => { ...baseParams, lookback: 'h', alertInterval: '3m', + alertThrottle: '3m', + alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults] = ungroupedResult; + const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; expect(firedResults).toBe(10); expect(noDataResults).toBe(0); expect(errorResults).toBe(0); + expect(notifications).toBe(10); }); test('returns the expected results using a bucket interval longer than the alert interval', async () => { const [ungroupedResult] = await previewMetricThresholdAlert({ ...baseParams, lookback: 'h', alertInterval: '30s', + alertThrottle: '30s', + alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults] = ungroupedResult; + const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; expect(firedResults).toBe(60); expect(noDataResults).toBe(0); expect(errorResults).toBe(0); + expect(notifications).toBe(60); + }); + test('returns the expected results using a throttle interval longer than the alert interval', async () => { + const [ungroupedResult] = await previewMetricThresholdAlert({ + ...baseParams, + lookback: 'h', + alertInterval: '1m', + alertThrottle: '3m', + alertOnNoData: true, + }); + const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; + expect(firedResults).toBe(30); + expect(noDataResults).toBe(0); + expect(errorResults).toBe(0); + expect(notifications).toBe(15); }); }); describe('querying with a groupBy parameter', () => { @@ -56,15 +79,19 @@ describe('Previewing the metric threshold alert type', () => { }, lookback: 'h', alertInterval: '1m', + alertThrottle: '1m', + alertOnNoData: true, }); - const [firedResultsA, noDataResultsA, errorResultsA] = resultA; + const [firedResultsA, noDataResultsA, errorResultsA, notificationsA] = resultA; expect(firedResultsA).toBe(30); expect(noDataResultsA).toBe(0); expect(errorResultsA).toBe(0); - const [firedResultsB, noDataResultsB, errorResultsB] = resultB; + expect(notificationsA).toBe(30); + const [firedResultsB, noDataResultsB, errorResultsB, notificationsB] = resultB; expect(firedResultsB).toBe(60); expect(noDataResultsB).toBe(0); expect(errorResultsB).toBe(0); + expect(notificationsB).toBe(60); }); }); describe('querying a data set with a period of No Data', () => { @@ -82,11 +109,14 @@ describe('Previewing the metric threshold alert type', () => { }, lookback: 'h', alertInterval: '1m', + alertThrottle: '1m', + alertOnNoData: true, }); - const [firedResults, noDataResults, errorResults] = ungroupedResult; + const [firedResults, noDataResults, errorResults, notifications] = ungroupedResult; expect(firedResults).toBe(25); expect(noDataResults).toBe(10); expect(errorResults).toBe(0); + expect(notifications).toBe(35); }); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts index 0f2afda663da8..e1615625d605a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/preview_metric_threshold_alert.ts @@ -28,6 +28,8 @@ interface PreviewMetricThresholdAlertParams { config: InfraSource['configuration']; lookback: Unit; alertInterval: string; + alertThrottle: string; + alertOnNoData: boolean; end?: number; overrideLookbackIntervalInSeconds?: number; } @@ -43,6 +45,8 @@ export const previewMetricThresholdAlert: ( config, lookback, alertInterval, + alertThrottle, + alertOnNoData, end = Date.now(), overrideLookbackIntervalInSeconds, }, @@ -77,6 +81,11 @@ export const previewMetricThresholdAlert: ( // Now determine how to interpolate this histogram based on the alert interval const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); const alertResultsPerExecution = alertIntervalInSeconds / bucketIntervalInSeconds; + const throttleIntervalInSeconds = Math.max( + getIntervalInSeconds(alertThrottle), + alertIntervalInSeconds + ); + const previewResults = await Promise.all( groups.map(async (group) => { // Interpolate the buckets returned by evaluateAlert and return a count of how many of these @@ -90,6 +99,12 @@ export const previewMetricThresholdAlert: ( let numberOfTimesFired = 0; let numberOfNoDataResults = 0; let numberOfErrors = 0; + let numberOfNotifications = 0; + let throttleTracker = 0; + const notifyWithThrottle = () => { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker += alertIntervalInSeconds; + }; for (let i = 0; i < numberOfExecutionBuckets; i++) { const mappedBucketIndex = Math.floor(i * alertResultsPerExecution); const allConditionsFiredInMappedBucket = alertResults.every( @@ -102,11 +117,27 @@ export const previewMetricThresholdAlert: ( const someConditionsErrorInMappedBucket = alertResults.some((alertResult) => { return alertResult[group].isError; }); - if (allConditionsFiredInMappedBucket) numberOfTimesFired++; - if (someConditionsNoDataInMappedBucket) numberOfNoDataResults++; - if (someConditionsErrorInMappedBucket) numberOfErrors++; + if (someConditionsErrorInMappedBucket) { + numberOfErrors++; + if (alertOnNoData) { + notifyWithThrottle(); + } + } else if (someConditionsNoDataInMappedBucket) { + numberOfNoDataResults++; + if (alertOnNoData) { + notifyWithThrottle(); + } + } else if (allConditionsFiredInMappedBucket) { + numberOfTimesFired++; + notifyWithThrottle(); + } else if (throttleTracker > 0) { + throttleTracker += alertIntervalInSeconds; + } + if (throttleTracker >= throttleIntervalInSeconds) { + throttleTracker = 0; + } } - return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors]; + return [numberOfTimesFired, numberOfNoDataResults, numberOfErrors, numberOfNotifications]; }) ); return previewResults; @@ -114,7 +145,15 @@ export const previewMetricThresholdAlert: ( if (isTooManyBucketsPreviewException(e)) { // If there's too much data on the first request, recursively slice the lookback interval // until all the data can be retrieved - const basePreviewParams = { callCluster, params, config, lookback, alertInterval }; + const basePreviewParams = { + callCluster, + params, + config, + lookback, + alertInterval, + alertThrottle, + alertOnNoData, + }; const { maxBuckets } = e; // If this is still the first iteration, try to get the number of groups in order to // calculate max buckets. If this fails, just estimate based on 1 group @@ -159,7 +198,7 @@ export const previewMetricThresholdAlert: ( .reduce((a, b) => { if (!a) return b; if (!b) return a; - return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; + return [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]]; }) ); return zippedResult; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts index f6d8a4a807e95..0ee123ed2946f 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -262,18 +262,18 @@ async function fetchLogEntryAnomalies( bucket_span: duration, timestamp: anomalyStartTime, by_field_value: categoryId, - } = result._source; + } = result.fields; return { id: result._id, - anomalyScore, - dataset, + anomalyScore: anomalyScore[0], + dataset: dataset[0], typical: typical[0], actual: actual[0], - jobId: job_id, - startTime: anomalyStartTime, - duration: duration * 1000, - categoryId, + jobId: job_id[0], + startTime: parseInt(anomalyStartTime[0], 10), + duration: duration[0] * 1000, + categoryId: categoryId?.[0], }; }); @@ -417,8 +417,8 @@ export async function fetchLogEntryExamples( return { examples: hits.map((hit) => ({ id: hit._id, - dataset: hit._source.event?.dataset ?? '', - message: hit._source.message ?? '', + dataset: hit.fields['event.dataset']?.[0] ?? '', + message: hit.fields.message?.[0] ?? '', timestamp: hit.sort[0], tiebreaker: hit.sort[1], })), diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index ff9e3c7d2167c..1205c5ae9f61b 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -23,6 +23,7 @@ import { } from './queries/log_entry_categories'; import { createLogEntryCategoryExamplesQuery, + LogEntryCategoryExampleHit, logEntryCategoryExamplesResponseRT, } from './queries/log_entry_category_examples'; import { @@ -423,11 +424,11 @@ async function fetchLogEntryCategoryExamples( return { examples: hits.map((hit) => ({ id: hit._id, - dataset: hit._source.event?.dataset ?? '', - message: hit._source.message ?? '', + dataset: hit.fields['event.dataset']?.[0] ?? '', + message: hit.fields.message?.[0] ?? '', timestamp: hit.sort[0], tiebreaker: hit.sort[1], - context: getContextFromSource(hit._source), + context: getContextFromFields(hit.fields), })), timing: { spans: [esSearchSpan], @@ -437,10 +438,10 @@ async function fetchLogEntryCategoryExamples( const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); -const getContextFromSource = (source: any): LogEntryContext => { - const containerId = source.container?.id; - const hostName = source.host?.name; - const logFilePath = source.log?.file?.path; +const getContextFromFields = (fields: LogEntryCategoryExampleHit['fields']): LogEntryContext => { + const containerId = fields['container.id']?.[0]; + const hostName = fields['host.name']?.[0]; + const logFilePath = fields['log.file.path']?.[0]; if (typeof containerId === 'string') { return { 'container.id': containerId }; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts index c722544c509aa..e692ed019cf86 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts @@ -46,13 +46,16 @@ export const createLogEntryAnomaliesQuery = ( ...createDatasetsFilters(datasets), ]; - const sourceFields = [ + const fields = [ 'job_id', 'record_score', 'typical', 'actual', 'partition_field_value', - 'timestamp', + { + field: 'timestamp', + format: 'epoch_millis', + }, 'bucket_span', 'by_field_value', ]; @@ -75,7 +78,8 @@ export const createLogEntryAnomaliesQuery = ( search_after: queryCursor, sort: sortOptions, size: pageSize, - _source: sourceFields, + _source: false, + fields, }, }; @@ -84,18 +88,18 @@ export const createLogEntryAnomaliesQuery = ( export const logEntryAnomalyHitRT = rt.type({ _id: rt.string, - _source: rt.intersection([ + fields: rt.intersection([ rt.type({ - job_id: rt.string, - record_score: rt.number, + job_id: rt.array(rt.string), + record_score: rt.array(rt.number), typical: rt.array(rt.number), actual: rt.array(rt.number), - partition_field_value: rt.string, - bucket_span: rt.number, - timestamp: rt.number, + partition_field_value: rt.array(rt.string), + bucket_span: rt.array(rt.number), + timestamp: rt.array(rt.string), }), rt.partial({ - by_field_value: rt.string, + by_field_value: rt.array(rt.string), }), ]), sort: rt.tuple([rt.union([rt.string, rt.number]), rt.union([rt.string, rt.number])]), diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts index 6e2afa874b757..c99353d01a3e8 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_category_examples.ts @@ -43,30 +43,21 @@ export const createLogEntryCategoryExamplesQuery = ( }, }, sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], + _source: false, + fields: ['event.dataset', 'message', 'container.id', 'host.name', 'log.file.path'], }, - _source: ['event.dataset', 'message', 'container.id', 'host.name', 'log.file.path'], index: indices, size: exampleCount, }); export const logEntryCategoryExampleHitRT = rt.type({ _id: rt.string, - _source: rt.partial({ - event: rt.partial({ - dataset: rt.string, - }), - message: rt.string, - container: rt.partial({ - id: rt.string, - }), - host: rt.partial({ - name: rt.string, - }), - log: rt.partial({ - file: rt.partial({ - path: rt.string, - }), - }), + fields: rt.partial({ + 'event.dataset': rt.array(rt.string), + message: rt.array(rt.string), + 'container.id': rt.array(rt.string), + 'host.name': rt.array(rt.string), + 'log.file.path': rt.array(rt.string), }), sort: rt.tuple([rt.number, rt.number]), }); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index 74a664e78dcd6..1b6a4c611e177 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -33,7 +33,7 @@ export const createLogEntryExamplesQuery = ( }, }, }, - ...(!!dataset + ...(dataset !== '' ? [ { term: { @@ -41,7 +41,19 @@ export const createLogEntryExamplesQuery = ( }, }, ] - : []), + : [ + { + bool: { + must_not: [ + { + exists: { + field: partitionField, + }, + }, + ], + }, + }, + ]), ...(categoryQuery ? [ { @@ -58,19 +70,18 @@ export const createLogEntryExamplesQuery = ( }, }, sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }], + _source: false, + fields: ['event.dataset', 'message'], }, - _source: ['event.dataset', 'message'], index: indices, size: exampleCount, }); export const logEntryExampleHitRT = rt.type({ _id: rt.string, - _source: rt.partial({ - event: rt.partial({ - dataset: rt.string, - }), - message: rt.string, + fields: rt.partial({ + 'event.dataset': rt.array(rt.string), + message: rt.array(rt.string), }), sort: rt.tuple([rt.number, rt.number]), }); diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index 40d09dadfe050..1233e9d2d1357 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -30,7 +30,16 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }, }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { - const { criteria, filterQuery, lookback, sourceId, alertType, alertInterval } = request.body; + const { + criteria, + filterQuery, + lookback, + sourceId, + alertType, + alertInterval, + alertThrottle, + alertOnNoData, + } = request.body; const callCluster = (endpoint: string, opts: Record) => { return callWithRequest(requestContext, endpoint, opts); @@ -51,22 +60,26 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) lookback, config: source.configuration, alertInterval, + alertThrottle, + alertOnNoData, }); const numberOfGroups = previewResult.length; const resultTotals = previewResult.reduce( - (totals, [firedResult, noDataResult, errorResult]) => { + (totals, [firedResult, noDataResult, errorResult, notifications]) => { return { ...totals, fired: totals.fired + firedResult, noData: totals.noData + noDataResult, error: totals.error + errorResult, + notifications: totals.notifications + notifications, }; }, { fired: 0, noData: 0, error: 0, + notifications: 0, } ); return response.ok({ @@ -84,22 +97,26 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) lookback, source, alertInterval, + alertThrottle, + alertOnNoData, }); const numberOfGroups = previewResult.length; const resultTotals = previewResult.reduce( - (totals, [firedResult, noDataResult, errorResult]) => { + (totals, [firedResult, noDataResult, errorResult, notifications]) => { return { ...totals, fired: totals.fired + firedResult, noData: totals.noData + noDataResult, error: totals.error + errorResult, + notifications: totals.notifications + notifications, }; }, { fired: 0, noData: 0, error: 0, + notifications: 0, } ); diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 3e065142ea101..378a6c6c12159 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -15,9 +15,11 @@ 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}`; export const EPM_API_ROUTES = { + BULK_INSTALL_PATTERN: EPM_PACKAGES_BULK, LIST_PATTERN: EPM_PACKAGES_MANY, LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`, INFO_PATTERN: EPM_PACKAGES_ONE, diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index b7521f95b4f83..ec7c0ee850834 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -46,6 +46,10 @@ export const epmRouteService = { ); // 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 }, diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts index 54e767fee4b22..7ed2fed91aa93 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts @@ -71,6 +71,30 @@ export interface InstallPackageResponse { response: AssetReference[]; } +export interface IBulkInstallPackageError { + name: string; + statusCode: number; + error: string | Error; +} + +export interface BulkInstallPackageInfo { + name: string; + newVersion: string; + // this will be null if no package was present before the upgrade (aka it was an install) + oldVersion: string | null; + assets: AssetReference[]; +} + +export interface BulkInstallPackagesResponse { + response: Array; +} + +export interface BulkInstallPackagesRequest { + body: { + packages: string[]; + }; +} + export interface MessageResponse { response: string; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/layout.tsx index 3bcf0aab9a5c8..6edce74d162bb 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/components/layout.tsx @@ -111,7 +111,7 @@ export const CreatePackagePolicyPageLayout: React.FunctionComponent<{ ) : ( ); }, [from]); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/index.tsx index a02214a6fe7fa..39f35fed56ef5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_policy/create_package_policy_page/index.tsx @@ -242,7 +242,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { notifications.toasts.addSuccess({ title: i18n.translate('xpack.ingestManager.createPackagePolicy.addedNotificationTitle', { - defaultMessage: `Successfully added '{packagePolicyName}'`, + defaultMessage: `'{packagePolicyName}' integration added.`, values: { packagePolicyName: packagePolicy.name, }, @@ -250,7 +250,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { text: agentCount && agentPolicy ? i18n.translate('xpack.ingestManager.createPackagePolicy.addedNotificationMessage', { - defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy`, + defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, values: { agentPolicyName: agentPolicy.name, }, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/components/confirm_delete_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/components/confirm_delete_modal.tsx index d2092f070a22a..a115e03a369a2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/components/confirm_delete_modal.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/components/confirm_delete_modal.tsx @@ -21,7 +21,7 @@ export const ConfirmEnrollmentTokenDelete = (props: Props) => { { confirmButtonText={i18n.translate( 'xpack.ingestManager.enrollmentTokenDeleteModal.deleteButton', { - defaultMessage: 'Delete', + defaultMessage: 'Revoke enrollment token', } )} defaultFocusedButton="confirm" @@ -42,7 +42,8 @@ export const ConfirmEnrollmentTokenDelete = (props: Props) => { > = ({

diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx index d85a6e8b5b833..f447469a02df2 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx @@ -268,7 +268,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { @@ -290,7 +290,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { setFlyoutOpen(true)}> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx index eeade9036df00..fbd74f8b03e72 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -95,8 +95,7 @@ export const SetupPage: React.FunctionComponent<{ diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts index 9f776565cf262..b621f2dd29331 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts @@ -56,10 +56,7 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { return 400; // Bad Request }; -export const defaultIngestErrorHandler: IngestErrorHandler = async ({ - error, - response, -}: IngestErrorHandlerParams): Promise => { +export function ingestErrorToResponseOptions(error: IngestErrorHandlerParams['error']) { const logger = appContextService.getLogger(); if (isLegacyESClientError(error)) { // there was a problem communicating with ES (e.g. via `callCluster`) @@ -72,36 +69,44 @@ export const defaultIngestErrorHandler: IngestErrorHandler = async ({ logger.error(message); - return response.customError({ + return { statusCode: error?.statusCode || error.status, body: { message }, - }); + }; } // our "expected" errors if (error instanceof IngestManagerError) { // only log the message logger.error(error.message); - return response.customError({ + return { statusCode: getHTTPResponseCode(error), body: { message: error.message }, - }); + }; } // handle any older Boom-based errors or the few places our app uses them if (isBoom(error)) { // only log the message logger.error(error.output.payload.message); - return response.customError({ + return { statusCode: error.output.statusCode, body: { message: error.output.payload.message }, - }); + }; } // not sure what type of error this is. log as much as possible logger.error(error); - return response.customError({ + return { statusCode: 500, body: { message: error.message }, - }); + }; +} + +export const defaultIngestErrorHandler: IngestErrorHandler = async ({ + error, + response, +}: IngestErrorHandlerParams): Promise => { + const options = ingestErrorToResponseOptions(error); + return response.customError(options); }; diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts index 5e36a2ec9a884..f495bf551dcff 100644 --- a/x-pack/plugins/ingest_manager/server/errors/index.ts +++ b/x-pack/plugins/ingest_manager/server/errors/index.ts @@ -5,7 +5,7 @@ */ /* eslint-disable max-classes-per-file */ -export { defaultIngestErrorHandler } from './handlers'; +export { defaultIngestErrorHandler, ingestErrorToResponseOptions } from './handlers'; export class IngestManagerError extends Error { constructor(message?: string) { diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index e8ea886cbf9f5..f87ebb3d2c404 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -11,7 +11,7 @@ import { AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL, AGENT_POLLING_REQUEST_TIMEOUT_MS, } from '../common'; -export { AgentService, ESIndexPatternService, getRegistryUrl } from './services'; +export { AgentService, ESIndexPatternService, getRegistryUrl, PackageService } from './services'; export { IngestManagerSetupContract, IngestManagerSetupDeps, diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index b10f3527a0459..f0f7bca29c99e 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -13,8 +13,10 @@ import { PluginInitializerContext, SavedObjectsServiceStart, HttpServiceSetup, + SavedObjectsClientContract, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { EncryptedSavedObjectsPluginStart, @@ -47,7 +49,7 @@ import { registerSettingsRoutes, registerAppRoutes, } from './routes'; -import { IngestManagerConfigType, NewPackagePolicy } from '../common'; +import { EsAssetReference, IngestManagerConfigType, NewPackagePolicy } from '../common'; import { appContextService, licenseService, @@ -55,6 +57,7 @@ import { ESIndexPatternService, AgentService, packagePolicyService, + PackageService, } from './services'; import { getAgentStatusById, @@ -65,6 +68,7 @@ import { import { CloudSetup } from '../../cloud/server'; import { agentCheckinState } from './services/agents/checkin/state'; import { registerIngestManagerUsageCollector } from './collectors/register'; +import { getInstallation } from './services/epm/packages'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; @@ -118,6 +122,7 @@ export type ExternalCallbacksStorage = Map => { + const installation = await getInstallation({ savedObjectsClient, pkgName }); + return installation?.installed_es || []; + }, + }, agentService: { getAgent, listAgents, diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts index b0439b30e8973..fb320b01dea97 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts @@ -10,6 +10,7 @@ import { RequestHandler } from 'kibana/server'; import { AcksService } from '../../services/agents'; import { AgentEvent } from '../../../common/types/models'; import { PostAgentAcksRequest, PostAgentAcksResponse } from '../../../common/types/rest_spec'; +import { defaultIngestErrorHandler } from '../../errors'; export const postAgentAcksHandlerBuilder = function ( ackService: AcksService @@ -43,18 +44,8 @@ export const postAgentAcksHandlerBuilder = function ( }; return response.ok({ body }); - } catch (e) { - if (e.isBoom) { - return response.customError({ - statusCode: e.output.statusCode, - body: { message: e.message }, - }); - } - - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; }; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts index 12a0956b79155..64a7795cc9dac 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/actions_handlers.ts @@ -11,6 +11,7 @@ import { TypeOf } from '@kbn/config-schema'; import { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; import { ActionsService } from '../../services/agents'; import { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; +import { defaultIngestErrorHandler } from '../../errors'; export const postNewAgentActionHandlerBuilder = function ( actionsService: ActionsService @@ -38,18 +39,8 @@ export const postNewAgentActionHandlerBuilder = function ( }; return response.ok({ body }); - } catch (e) { - if (e.isBoom) { - return response.customError({ - statusCode: e.output.statusCode, - body: { message: e.message }, - }); - } - - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; }; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index 605e4db230ce5..2ebb7a0667aab 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -30,6 +30,7 @@ import { import * as AgentService from '../../services/agents'; import * as APIKeyService from '../../services/api_keys'; import { appContextService } from '../../services/app_context'; +import { defaultIngestErrorHandler } from '../../errors'; export const getAgentHandler: RequestHandler= 500) { - logger.error(err); - } - - return response.customError({ - statusCode: err.output.statusCode, - body: { message: err.output.payload.message }, - }); - } - - logger.error(err); - - return response.customError({ - statusCode: 500, - body: { message: err.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -250,18 +221,8 @@ export const postAgentEnrollHandler: RequestHandler< }; return response.ok({ body }); - } catch (e) { - if (e.isBoom) { - return response.customError({ - statusCode: e.output.statusCode, - body: { message: e.message }, - }); - } - - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -288,11 +249,8 @@ export const getAgentsHandler: RequestHandler< perPage, }; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -307,11 +265,8 @@ export const putAgentsReassignHandler: RequestHandler< const body: PutAgentReassignResponse = {}; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -330,10 +285,7 @@ export const getAgentStatusForAgentPolicyHandler: RequestHandler< const body: GetAgentStatusResponse = { results }; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts index 5df695d248f5b..fa200e912d625 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/unenroll_handler.ts @@ -9,6 +9,7 @@ import { TypeOf } from '@kbn/config-schema'; import { PostAgentUnenrollResponse } from '../../../common/types'; import { PostAgentUnenrollRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; +import { defaultIngestErrorHandler } from '../../errors'; export const postAgentsUnenrollHandler: RequestHandler< TypeOf, @@ -25,10 +26,7 @@ export const postAgentsUnenrollHandler: RequestHandler< const body: PostAgentUnenrollResponse = {}; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts index 7eb3df2346106..311b3bbf7f13b 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_policy/handlers.ts @@ -32,6 +32,7 @@ import { DeleteAgentPolicyResponse, GetFullAgentPolicyResponse, } from '../../../common'; +import { defaultIngestErrorHandler } from '../../errors'; export const getAgentPoliciesHandler: RequestHandler< undefined, @@ -64,11 +65,8 @@ export const getAgentPoliciesHandler: RequestHandler< ); return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -91,11 +89,8 @@ export const getOneAgentPolicyHandler: RequestHandler > = async (context, request, response) => { - const logger = appContextService.getLogger(); const savedObjectsClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const { pkgkey } = request.params; const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); - const installType = getInstallType({ pkgVersion, installedPkg }); try { const res = await installPackage({ savedObjectsClient, @@ -155,36 +157,38 @@ export const installPackageFromRegistryHandler: RequestHandler< }; return response.ok({ body }); } catch (e) { - // could have also done `return defaultIngestErrorHandler({ error: e, response })` at each of the returns, - // but doing it this way will log the outer/install errors before any inner/rollback errors const defaultResult = await defaultIngestErrorHandler({ error: e, response }); - if (e instanceof IngestManagerError) { - return defaultResult; - } + await handleInstallPackageFailure({ + savedObjectsClient, + error: e, + pkgName, + pkgVersion, + installedPkg, + callCluster, + }); - // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update - try { - if (installType === 'install' || installType === 'reinstall') { - logger.error(`uninstalling ${pkgkey} after error installing`); - await removeInstallation({ savedObjectsClient, pkgkey, callCluster }); - } - if (installType === 'update') { - // @ts-ignore getInstallType ensures we have installedPkg - const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; - logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackage({ - savedObjectsClient, - pkgkey: prevVersion, - callCluster, - }); - } - } catch (error) { - logger.error(`failed to uninstall or rollback package after installation error ${error}`); - } return defaultResult; } }; +export const bulkInstallPackagesFromRegistryHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; + const res = await bulkInstallPackages({ + savedObjectsClient, + callCluster, + packagesToUpgrade: request.body.packages, + }); + const body: BulkInstallPackagesResponse = { + response: res, + }; + return response.ok({ body }); +}; + export const installPackageByUploadHandler: RequestHandler< undefined, undefined, diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts index 9048652f0e8a9..eaf61335b5e06 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts @@ -14,6 +14,7 @@ import { installPackageFromRegistryHandler, installPackageByUploadHandler, deletePackageHandler, + bulkInstallPackagesFromRegistryHandler, } from './handlers'; import { GetCategoriesRequestSchema, @@ -23,6 +24,7 @@ import { InstallPackageFromRegistryRequestSchema, InstallPackageByUploadRequestSchema, DeletePackageRequestSchema, + BulkUpgradePackagesFromRegistryRequestSchema, } from '../../types'; const MAX_FILE_SIZE_BYTES = 104857600; // 100MB @@ -82,6 +84,15 @@ export const registerRoutes = (router: IRouter) => { installPackageFromRegistryHandler ); + router.post( + { + path: EPM_API_ROUTES.BULK_INSTALL_PATTERN, + validate: BulkUpgradePackagesFromRegistryRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + bulkInstallPackagesFromRegistryHandler + ); + router.post( { path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/routes/output/handler.ts b/x-pack/plugins/ingest_manager/server/routes/output/handler.ts index d920ad4e6cb71..9cdab757acc87 100644 --- a/x-pack/plugins/ingest_manager/server/routes/output/handler.ts +++ b/x-pack/plugins/ingest_manager/server/routes/output/handler.ts @@ -9,6 +9,7 @@ import { TypeOf } from '@kbn/config-schema'; import { GetOneOutputRequestSchema, PutOutputRequestSchema } from '../../types'; import { GetOneOutputResponse, GetOutputsResponse } from '../../../common'; import { outputService } from '../../services/output'; +import { defaultIngestErrorHandler } from '../../errors'; export const getOutputsHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; @@ -23,11 +24,8 @@ export const getOutputsHandler: RequestHandler = async (context, request, respon }; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -43,17 +41,14 @@ export const getOneOuputHandler: RequestHandler { @@ -19,17 +19,14 @@ export const getSettingsHandler: RequestHandler = async (context, request, respo item: settings, }; return response.ok({ body }); - } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { return response.notFound({ body: { message: `Setings not found` }, }); } - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + return defaultIngestErrorHandler({ error, response }); } }; @@ -49,17 +46,14 @@ export const putSettingsHandler: RequestHandler< item: settings, }; return response.ok({ body }); - } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { return response.notFound({ body: { message: `Setings not found` }, }); } - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + return defaultIngestErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts index 866aa587b8a56..c7b4098803827 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts @@ -57,7 +57,7 @@ describe('test agent acks services', () => { ); }); - it('should update config field on the agent if a policy change is acknowledged', async () => { + it('should update config field on the agent if a policy change is acknowledged with an agent without policy', async () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); const actionAttributes = { @@ -116,6 +116,114 @@ describe('test agent acks services', () => { `); }); + it('should update config field on the agent if a policy change is acknowledged with a higher revision than the agent one', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const actionAttributes = { + type: 'CONFIG_CHANGE', + policy_id: 'policy1', + policy_revision: 4, + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + ack_data: JSON.stringify({ packages: ['system'] }), + }; + + mockSavedObjectsClient.bulkGet.mockReturnValue( + Promise.resolve({ + saved_objects: [ + { + id: 'action2', + references: [], + type: AGENT_ACTION_SAVED_OBJECT_TYPE, + attributes: actionAttributes, + }, + ], + } as SavedObjectsBulkResponse) + ); + + await acknowledgeAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + policy_id: 'policy1', + policy_revision: 3, + } as unknown) as Agent, + [ + { + type: 'ACTION_RESULT', + subtype: 'CONFIG', + timestamp: '2019-01-04T14:32:03.36764-05:00', + action_id: 'action2', + agent_id: 'id', + } as AgentEvent, + ] + ); + expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); + expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(1); + expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0][0]).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "packages": Array [ + "system", + ], + "policy_revision": 4, + }, + "id": "id", + "type": "fleet-agents", + } + `); + }); + + it('should not update config field on the agent if a policy change is acknowledged with a lower revision than the agent one', async () => { + const mockSavedObjectsClient = savedObjectsClientMock.create(); + + const actionAttributes = { + type: 'CONFIG_CHANGE', + policy_id: 'policy1', + policy_revision: 4, + sent_at: '2020-03-14T19:45:02.620Z', + timestamp: '2019-01-04T14:32:03.36764-05:00', + created_at: '2020-03-14T19:45:02.620Z', + ack_data: JSON.stringify({ packages: ['system'] }), + }; + + mockSavedObjectsClient.bulkGet.mockReturnValue( + Promise.resolve({ + saved_objects: [ + { + id: 'action2', + references: [], + type: AGENT_ACTION_SAVED_OBJECT_TYPE, + attributes: actionAttributes, + }, + ], + } as SavedObjectsBulkResponse) + ); + + await acknowledgeAgentActions( + mockSavedObjectsClient, + ({ + id: 'id', + type: AGENT_TYPE_PERMANENT, + policy_id: 'policy1', + policy_revision: 5, + } as unknown) as Agent, + [ + { + type: 'ACTION_RESULT', + subtype: 'CONFIG', + timestamp: '2019-01-04T14:32:03.36764-05:00', + action_id: 'action2', + agent_id: 'id', + } as AgentEvent, + ] + ); + expect(mockSavedObjectsClient.bulkUpdate).toBeCalled(); + expect(mockSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toHaveLength(0); + }); + it('should not update config field on the agent if a policy change for an old revision is acknowledged', async () => { const mockSavedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts index d29dfcec7ef30..1392710eb0eff 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -139,16 +139,12 @@ function getLatestConfigChangePolicyActionIfUpdated( !isAgentPolicyAction(action) || action.type !== 'CONFIG_CHANGE' || action.policy_id !== agent.policy_id || - (acc?.policy_revision ?? 0) < (agent.policy_revision || 0) + (action?.policy_revision ?? 0) < (agent.policy_revision || 0) ) { return acc; } - if (action.policy_revision > (acc?.policy_revision ?? 0)) { - return action; - } - - return acc; + return action; }, null); } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts index 1e58319183c7d..dfa03ec9d527d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/install.ts @@ -35,7 +35,10 @@ export const installTransformForDataset = async ( callCluster: CallESAsCurrentUser, savedObjectsClient: SavedObjectsClientContract ) => { - const installation = await getInstallation({ savedObjectsClient, pkgName: registryPackage.name }); + const installation = await getInstallation({ + savedObjectsClient, + pkgName: registryPackage.name, + }); let previousInstalledTransformEsAssets: EsAssetReference[] = []; if (installation) { previousInstalledTransformEsAssets = installation.installed_es.filter( @@ -143,14 +146,14 @@ async function installTransform({ // defer validation on put if the source index is not available await callCluster('transport.request', { method: 'PUT', - path: `_transform/${transform.installationName}`, + path: `/_transform/${transform.installationName}`, query: 'defer_validation=true', body: transform.content, }); await callCluster('transport.request', { method: 'POST', - path: `_transform/${transform.installationName}/_start`, + path: `/_transform/${transform.installationName}/_start`, }); return { id: transform.installationName, type: ElasticsearchAssetType.transform }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts index 5c9d3e2846200..a527d05f1c49b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/remove.ts @@ -12,7 +12,7 @@ export const stopTransforms = async (transformIds: string[], callCluster: CallES for (const transformId of transformIds) { await callCluster('transport.request', { method: 'POST', - path: `_transform/${transformId}/_stop`, + path: `/_transform/${transformId}/_stop`, query: 'force=true', ignore: [404], }); @@ -29,7 +29,7 @@ export const deleteTransforms = async ( await callCluster('transport.request', { method: 'DELETE', query: 'force=true', - path: `_transform/${transformId}`, + path: `/_transform/${transformId}`, ignore: [404], }); }) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts index 0b66077b8699a..c43a33df2db61 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/transform/transform.test.ts @@ -134,13 +134,12 @@ describe('test transform install', () => { legacyScopedClusterClient.callAsCurrentUser, savedObjectsClient ); - expect(legacyScopedClusterClient.callAsCurrentUser.mock.calls).toEqual([ [ 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0/_stop', + path: '/_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0/_stop', query: 'force=true', ignore: [404], }, @@ -150,7 +149,7 @@ describe('test transform install', () => { { method: 'DELETE', query: 'force=true', - path: '_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.15.0-dev.0', ignore: [404], }, ], @@ -158,7 +157,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -167,7 +166,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -176,14 +175,14 @@ describe('test transform install', () => { 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata-default-0.16.0-dev.0/_start', }, ], [ 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', }, ], ]); @@ -295,7 +294,7 @@ describe('test transform install', () => { 'transport.request', { method: 'PUT', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0', query: 'defer_validation=true', body: '{"content": "data"}', }, @@ -304,7 +303,7 @@ describe('test transform install', () => { 'transport.request', { method: 'POST', - path: '_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', + path: '/_transform/metrics-endpoint.metadata_current-default-0.16.0-dev.0/_start', }, ], ]); @@ -391,19 +390,19 @@ describe('test transform install', () => { [ 'transport.request', { - ignore: [404], method: 'POST', - path: '_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0/_stop', + path: '/_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0/_stop', query: 'force=true', + ignore: [404], }, ], [ 'transport.request', { - ignore: [404], method: 'DELETE', - path: '_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0', query: 'force=true', + path: '/_transform/metrics-endpoint.metadata-current-default-0.15.0-dev.0', + ignore: [404], }, ], ]); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 54b9c4d3fbb17..800151a41a429 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -6,6 +6,9 @@ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import semver from 'semver'; +import Boom from 'boom'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { BulkInstallPackageInfo, IBulkInstallPackageError } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants'; import { AssetReference, @@ -32,10 +35,15 @@ import { ArchiveAsset, } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; -import { deleteKibanaSavedObjectsAssets } from './remove'; -import { PackageOutdatedError } from '../../../errors'; +import { deleteKibanaSavedObjectsAssets, removeInstallation } from './remove'; +import { + IngestManagerError, + PackageOutdatedError, + ingestErrorToResponseOptions, +} from '../../../errors'; import { getPackageSavedObjects } from './get'; import { installTransformForDataset } from '../elasticsearch/transform/install'; +import { appContextService } from '../../app_context'; export async function installLatestPackage(options: { savedObjectsClient: SavedObjectsClientContract; @@ -94,17 +102,185 @@ export async function ensureInstalledPackage(options: { return installation; } -export async function installPackage({ +export async function handleInstallPackageFailure({ savedObjectsClient, - pkgkey, + error, + pkgName, + pkgVersion, + installedPkg, callCluster, - force = false, }: { + savedObjectsClient: SavedObjectsClientContract; + error: IngestManagerError | Boom | Error; + pkgName: string; + pkgVersion: string; + installedPkg: SavedObject | undefined; + callCluster: CallESAsCurrentUser; +}) { + if (error instanceof IngestManagerError) { + return; + } + const logger = appContextService.getLogger(); + const pkgkey = Registry.pkgToPkgKey({ + name: pkgName, + version: pkgVersion, + }); + + // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update + try { + const installType = getInstallType({ pkgVersion, installedPkg }); + if (installType === 'install' || installType === 'reinstall') { + logger.error(`uninstalling ${pkgkey} after error installing`); + await removeInstallation({ savedObjectsClient, pkgkey, callCluster }); + } + + if (installType === 'update') { + if (!installedPkg) { + logger.error( + `failed to rollback package after installation error ${error} because saved object was undefined` + ); + return; + } + const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; + logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); + await installPackage({ + savedObjectsClient, + pkgkey: prevVersion, + callCluster, + }); + } + } catch (e) { + logger.error(`failed to uninstall or rollback package after installation error ${e}`); + } +} + +type BulkInstallResponse = BulkInstallPackageInfo | IBulkInstallPackageError; +function bulkInstallErrorToOptions({ + pkgToUpgrade, + error, +}: { + pkgToUpgrade: string; + error: Error; +}): IBulkInstallPackageError { + const { statusCode, body } = ingestErrorToResponseOptions(error); + return { + name: pkgToUpgrade, + statusCode, + error: body.message, + }; +} + +interface UpgradePackageParams { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + installedPkg: UnwrapPromise>; + latestPkg: UnwrapPromise>; + pkgToUpgrade: string; +} +async function upgradePackage({ + savedObjectsClient, + callCluster, + installedPkg, + latestPkg, + pkgToUpgrade, +}: UpgradePackageParams): Promise { + if (!installedPkg || semver.gt(latestPkg.version, installedPkg.attributes.version)) { + const pkgkey = Registry.pkgToPkgKey({ + name: latestPkg.name, + version: latestPkg.version, + }); + + try { + const assets = await installPackage({ savedObjectsClient, pkgkey, callCluster }); + return { + name: pkgToUpgrade, + newVersion: latestPkg.version, + oldVersion: installedPkg?.attributes.version ?? null, + assets, + }; + } catch (installFailed) { + await handleInstallPackageFailure({ + savedObjectsClient, + error: installFailed, + pkgName: latestPkg.name, + pkgVersion: latestPkg.version, + installedPkg, + callCluster, + }); + return bulkInstallErrorToOptions({ pkgToUpgrade, error: installFailed }); + } + } else { + // package was already at the latest version + return { + name: pkgToUpgrade, + newVersion: latestPkg.version, + oldVersion: latestPkg.version, + assets: [ + ...installedPkg.attributes.installed_es, + ...installedPkg.attributes.installed_kibana, + ], + }; + } +} + +interface BulkInstallPackagesParams { + savedObjectsClient: SavedObjectsClientContract; + packagesToUpgrade: string[]; + callCluster: CallESAsCurrentUser; +} +export async function bulkInstallPackages({ + savedObjectsClient, + packagesToUpgrade, + callCluster, +}: BulkInstallPackagesParams): Promise { + const installedAndLatestPromises = packagesToUpgrade.map((pkgToUpgrade) => + Promise.all([ + getInstallationObject({ savedObjectsClient, pkgName: pkgToUpgrade }), + Registry.fetchFindLatestPackage(pkgToUpgrade), + ]) + ); + const installedAndLatestResults = await Promise.allSettled(installedAndLatestPromises); + const installResponsePromises = installedAndLatestResults.map(async (result, index) => { + const pkgToUpgrade = packagesToUpgrade[index]; + if (result.status === 'fulfilled') { + const [installedPkg, latestPkg] = result.value; + return upgradePackage({ + savedObjectsClient, + callCluster, + installedPkg, + latestPkg, + pkgToUpgrade, + }); + } else { + return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason }); + } + }); + const installResults = await Promise.allSettled(installResponsePromises); + const installResponses = installResults.map((result, index) => { + const pkgToUpgrade = packagesToUpgrade[index]; + if (result.status === 'fulfilled') { + return result.value; + } else { + return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason }); + } + }); + + return installResponses; +} + +interface InstallPackageParams { savedObjectsClient: SavedObjectsClientContract; pkgkey: string; callCluster: CallESAsCurrentUser; force?: boolean; -}): Promise { +} + +export async function installPackage({ + savedObjectsClient, + pkgkey, + callCluster, + force = false, +}: InstallPackageParams): Promise { // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); // TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge diff --git a/x-pack/plugins/ingest_manager/server/services/index.ts b/x-pack/plugins/ingest_manager/server/services/index.ts index e768862d2dee1..5942277e90824 100644 --- a/x-pack/plugins/ingest_manager/server/services/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/index.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsClientContract, KibanaRequest } from 'kibana/server'; -import { AgentStatus, Agent } from '../types'; +import { AgentStatus, Agent, EsAssetReference } from '../types'; import * as settingsService from './settings'; export { ESIndexPatternSavedObjectService } from './es_index_pattern'; @@ -22,6 +22,17 @@ export interface ESIndexPatternService { ): Promise; } +/** + * Service that provides exported function that return information about EPM packages + */ + +export interface PackageService { + getInstalledEsAssetReferences( + savedObjectsClient: SavedObjectsClientContract, + pkgName: string + ): Promise; +} + /** * A service that provides exported functions that return information about an Agent */ diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts index d7a801feec34f..5d2a078374854 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts @@ -43,6 +43,12 @@ export const InstallPackageFromRegistryRequestSchema = { ), }; +export const BulkUpgradePackagesFromRegistryRequestSchema = { + body: schema.object({ + packages: schema.arrayOf(schema.string(), { minSize: 1 }), + }), +}; + export const InstallPackageByUploadRequestSchema = { body: schema.buffer(), }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx index 281c72915e46e..325f18ee9833a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/bucket_nesting_editor.tsx @@ -69,7 +69,7 @@ export function BucketNestingEditor({ values: { field: fieldName }, }), filters: i18n.translate('xpack.lens.indexPattern.groupingOverallFilters', { - defaultMessage: 'Top values for each custom query', + defaultMessage: 'Top values for each filter', }), date_histogram: i18n.translate('xpack.lens.indexPattern.groupingOverallDateHistogram', { defaultMessage: 'Top values for each {field}', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx index 91adbcecaf897..077e07a89f788 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filter_popover.tsx @@ -118,6 +118,7 @@ export const QueryInput = ({ return ( { columnOrder: ['col1', 'col2'], columns: { col1: { - label: 'Custom query', + label: 'filters', dataType: 'document', operationType: 'filters', scale: 'ordinal', @@ -209,7 +209,7 @@ describe('filters', () => { }); }); - describe('Modify custom query', () => { + describe('Modify filters', () => { it('should correctly show existing filters ', () => { const setStateSpy = jest.fn(); const instance = mount( @@ -236,7 +236,7 @@ describe('filters', () => { ).toEqual('src : 2'); }); - it('should remove custom query', () => { + it('should remove filter', () => { const setStateSpy = jest.fn(); const instance = mount( = { type: 'filters', - displayName: customQueryLabel, + displayName: filtersLabel, priority: 3, // Higher than any metric getPossibleOperationForField: ({ type }) => { if (type === 'document') { @@ -102,7 +102,7 @@ export const filtersOperation: OperationDefinition = } return { - label: customQueryLabel, + label: filtersLabel, dataType: 'string', operationType: 'filters', scale: 'ordinal', @@ -223,8 +223,8 @@ export const FilterList = ({ defaultMessage: 'This query is invalid', })} onRemoveClick={() => onRemoveFilter(filter.id)} - removeTitle={i18n.translate('xpack.lens.indexPattern.filters.removeCustomQuery', { - defaultMessage: 'Remove custom query', + removeTitle={i18n.translate('xpack.lens.indexPattern.filters.removeFilter', { + defaultMessage: 'Remove a filter', })} > 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 62b5f64fb26f2..73378cea919a6 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,7 +20,12 @@ import { } from '@elastic/eui'; export const NewBucketButton = ({ label, onClick }: { label: string; onClick: () => void }) => ( - + {label} ); diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 365e430a460fa..50b8f4c6fc40b 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -126,6 +126,7 @@ export function PieToolbar(props: VisualizationToolbarProps & { * Adjusts the borders for groupings */ groupPosition?: 'none' | 'left' | 'center' | 'right'; + dataTestSubj?: string; }; export const ToolbarButton: React.FunctionComponent = ({ @@ -42,6 +43,7 @@ export const ToolbarButton: React.FunctionComponent = ({ size = 'm', hasArrow = true, groupPosition = 'none', + dataTestSubj = '', ...rest }) => { const classes = classNames( @@ -52,6 +54,7 @@ export const ToolbarButton: React.FunctionComponent = ({ ); return ( = ({ @@ -39,6 +40,7 @@ export const ToolbarPopover: React.FunctionComponent = ({ type, isDisabled = false, groupPosition, + buttonDataTestSubj, }) => { const [open, setOpen] = useState(false); @@ -56,9 +58,11 @@ export const ToolbarPopover: React.FunctionComponent = ({ onClick={() => { setOpen(!open); }} + title={title} hasArrow={false} isDisabled={isDisabled} groupPosition={groupPosition} + dataTestSubj={buttonDataTestSubj} > diff --git a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx index 835f3e2cde769..45ec7098aa639 100644 --- a/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/axis_settings_popover.tsx @@ -73,7 +73,12 @@ export interface AxisSettingsPopoverProps { const popoverConfig = ( axis: AxesSettingsConfigKeys, isHorizontal: boolean -): { icon: IconType; groupPosition: ToolbarButtonProps['groupPosition']; popoverTitle: string } => { +): { + icon: IconType; + groupPosition: ToolbarButtonProps['groupPosition']; + popoverTitle: string; + buttonDataTestSubj: string; +} => { switch (axis) { case 'yLeft': return { @@ -86,6 +91,7 @@ const popoverConfig = ( : i18n.translate('xpack.lens.xyChart.leftAxisLabel', { defaultMessage: 'Left axis', }), + buttonDataTestSubj: 'lnsLeftAxisButton', }; case 'yRight': return { @@ -98,6 +104,7 @@ const popoverConfig = ( : i18n.translate('xpack.lens.xyChart.rightAxisLabel', { defaultMessage: 'Right axis', }), + buttonDataTestSubj: 'lnsRightAxisButton', }; case 'x': default: @@ -111,6 +118,8 @@ const popoverConfig = ( : i18n.translate('xpack.lens.xyChart.bottomAxisLabel', { defaultMessage: 'Bottom axis', }), + + buttonDataTestSubj: 'lnsBottomAxisButton', }; } }; @@ -143,6 +152,7 @@ export const AxisSettingsPopover: React.FunctionComponent diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx index 7e2e8f0453588..2114d63fcfacd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mountWithIntl as mount, shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; import { EuiButtonGroupProps, EuiSuperSelect, EuiButtonGroup } from '@elastic/eui'; -import { LayerContextMenu, XyToolbar } from './xy_config_panel'; +import { LayerContextMenu, XyToolbar, DimensionEditor } from './xy_config_panel'; import { ToolbarPopover } from '../shared_components'; import { AxisSettingsPopover } from './axis_settings_popover'; import { FramePublicAPI } from '../types'; @@ -171,4 +171,48 @@ describe('XY Config panels', () => { expect(component.find(AxisSettingsPopover).length).toEqual(3); }); }); + + describe('Dimension Editor', () => { + test('shows the correct axis side options when in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Bottom', 'Top']); + }); + + test('shows the default axis side options when not in horizontal mode', () => { + const state = testState(); + const component = mount( + + ); + + const options = component + .find(EuiButtonGroup) + .first() + .prop('options') as EuiButtonGroupProps['options']; + + expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Left', 'Right']); + }); + }); }); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index bc98bf53d9f12..c7781c2e1d50c 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -208,6 +208,7 @@ export function XyToolbar(props: VisualizationToolbarProps) { isDisabled={!hasNonBarSeries} type="values" groupPosition="left" + buttonDataTestSubj="lnsMissingValuesButton" > ) { })} > { return { @@ -274,9 +276,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { group.groupId === 'left') || {}).length === 0 } @@ -310,9 +318,15 @@ export function XyToolbar(props: VisualizationToolbarProps) { toggleAxisTitleVisibility={onAxisTitlesVisibilitySettingsChange} /> group.groupId === 'right') || {}).length === 0 } @@ -345,6 +359,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) const { state, setState, layerId, accessor } = props; const index = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[index]; + const isHorizontal = isHorizontalChart(state.layers); const axisMode = (layer.yConfig && layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || @@ -377,15 +392,23 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) }, { id: `${idPrefix}left`, - label: i18n.translate('xpack.lens.xyChart.axisSide.left', { - defaultMessage: 'Left', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.bottom', { + defaultMessage: 'Bottom', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.left', { + defaultMessage: 'Left', + }), }, { id: `${idPrefix}right`, - label: i18n.translate('xpack.lens.xyChart.axisSide.right', { - defaultMessage: 'Right', - }), + label: isHorizontal + ? i18n.translate('xpack.lens.xyChart.axisSide.top', { + defaultMessage: 'Top', + }) + : i18n.translate('xpack.lens.xyChart.axisSide.right', { + defaultMessage: 'Right', + }), }, ]} idSelected={`${idPrefix}${axisMode}`} @@ -467,6 +490,7 @@ const ColorPicker = ({ const colorPicker = ( { expect(first.type).toBe('basic'); trigger$.next(); + // waiting on a promise gives the exhaustMap time to complete and not de-dupe these calls + await Promise.resolve(); trigger$.next(); const [, second] = await license$.pipe(take(2), toArray()).toPromise(); @@ -89,18 +91,15 @@ describe('licensing update', () => { expect(fetcher).toHaveBeenCalledTimes(1); }); - it('handles fetcher race condition', async () => { + it('ignores trigger if license fetching is delayed ', async () => { const delayMs = 100; - let firstCall = true; - const fetcher = jest.fn().mockImplementation( + const fetcher = jest.fn().mockImplementationOnce( () => new Promise((resolve) => { - if (firstCall) { - firstCall = false; - setTimeout(() => resolve(licenseMock.createLicense()), delayMs); - } else { - resolve(licenseMock.createLicense({ license: { type: 'gold' } })); - } + setTimeout( + () => resolve(licenseMock.createLicense({ license: { type: 'gold' } })), + delayMs + ); }) ); const trigger$ = new Subject(); @@ -113,7 +112,7 @@ describe('licensing update', () => { await delay(delayMs * 2); - await expect(fetcher).toHaveBeenCalledTimes(2); + await expect(fetcher).toHaveBeenCalledTimes(1); await expect(values).toHaveLength(1); await expect(values[0].type).toBe('gold'); }); @@ -144,7 +143,7 @@ describe('licensing update', () => { expect(fetcher).toHaveBeenCalledTimes(0); }); - it('refreshManually guarantees license fetching', async () => { + it(`refreshManually multiple times gets new license`, async () => { const trigger$ = new Subject(); const firstLicense = licenseMock.createLicense({ license: { uid: 'first', type: 'basic' } }); const secondLicense = licenseMock.createLicense({ license: { uid: 'second', type: 'gold' } }); diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts index 0197ca5396ad1..cd5052b0b49a3 100644 --- a/x-pack/plugins/licensing/common/license_update.ts +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -5,32 +5,41 @@ */ import { ConnectableObservable, Observable, Subject, from, merge } from 'rxjs'; -import { filter, map, pairwise, switchMap, publishReplay, takeUntil } from 'rxjs/operators'; +import { + filter, + map, + pairwise, + exhaustMap, + publishReplay, + share, + take, + takeUntil, +} from 'rxjs/operators'; import { hasLicenseInfoChanged } from './has_license_info_changed'; import { ILicense } from './types'; export function createLicenseUpdate( - trigger$: Observable, + triggerRefresh$: Observable, stop$: Observable, fetcher: () => Promise, initialValues?: ILicense ) { - const triggerRefresh$ = trigger$.pipe(switchMap(fetcher)); - const manuallyFetched$ = new Subject(); + const manuallyRefresh$ = new Subject(); + const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe(exhaustMap(fetcher), share()); - const fetched$ = merge(triggerRefresh$, manuallyFetched$).pipe( + const cached$ = fetched$.pipe( takeUntil(stop$), publishReplay(1) // have to cast manually as pipe operator cannot return ConnectableObservable // https://github.com/ReactiveX/rxjs/issues/2972 ) as ConnectableObservable; - const fetchSubscription = fetched$.connect(); - stop$.subscribe({ complete: () => fetchSubscription.unsubscribe() }); + const cachedSubscription = cached$.connect(); + stop$.subscribe({ complete: () => cachedSubscription.unsubscribe() }); const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]); - const license$: Observable = merge(initialValues$, fetched$).pipe( + const license$: Observable = merge(initialValues$, cached$).pipe( pairwise(), filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)), map(([, next]) => next!) @@ -38,10 +47,10 @@ export function createLicenseUpdate( return { license$, - async refreshManually() { - const license = await fetcher(); - manuallyFetched$.next(license); - return license; + refreshManually() { + const licensePromise = fetched$.pipe(take(1)).toPromise(); + manuallyRefresh$.next(); + return licensePromise; }, }; } diff --git a/x-pack/plugins/licensing/public/plugin.test.ts b/x-pack/plugins/licensing/public/plugin.test.ts index 960fe3699e210..c20563dd15913 100644 --- a/x-pack/plugins/licensing/public/plugin.test.ts +++ b/x-pack/plugins/licensing/public/plugin.test.ts @@ -115,7 +115,9 @@ describe('licensing plugin', () => { refresh(); } else if (i === 2) { expect(value.type).toBe('gold'); - refresh(); + // since this is a synchronous subscription, we need to give the exhaustMap a chance + // to mark the subscription as complete before emitting another value on the Subject + process.nextTick(() => refresh()); } else if (i === 3) { expect(value.type).toBe('platinum'); done(); diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts index 5c768a00783a8..af3ec42ab4ec5 100644 --- a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts @@ -25,7 +25,23 @@ describe('createOnPreResponseHandler', () => { }, }); }); - it('sets license.signature header after refresh for non-error responses', async () => { + it('sets license.signature header immediately for 429 error responses', async () => { + const refresh = jest.fn(); + const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' })); + const toolkit = httpServiceMock.createOnPreResponseToolkit(); + + const interceptor = createOnPreResponseHandler(refresh, license$); + await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 429 }, toolkit); + + expect(refresh).toHaveBeenCalledTimes(0); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-license-sig': 'foo', + }, + }); + }); + it('sets license.signature header after refresh for other error responses', async () => { const updatedLicense = licenseMock.createLicense({ signature: 'bar' }); const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' })); const refresh = jest.fn().mockImplementation( diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.ts index c8befceb4fe32..6428e41b18058 100644 --- a/x-pack/plugins/licensing/server/on_pre_response_handler.ts +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.ts @@ -15,9 +15,11 @@ export function createOnPreResponseHandler( return async (req, res, t) => { // If we're returning an error response, refresh license info from // Elasticsearch in case the error is due to a change in license information - // in Elasticsearch. - // https://github.com/elastic/x-pack-kibana/pull/2876 - if (res.statusCode >= 400) { + // in Elasticsearch. https://github.com/elastic/x-pack-kibana/pull/2876 + // We're explicit ignoring a 429 "Too Many Requests". This is being used to communicate + // that back-pressure should be applied, and we don't need to refresh the license in these + // situations. + if (res.statusCode >= 400 && res.statusCode !== 429) { await refresh(); } const license = await license$.pipe(take(1)).toPromise(); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index 2e0ba7cf3efee..f565321f87ef7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -15,7 +15,7 @@ import { RENDER_AS, SOURCE_TYPES, } from '../../../../common/constants'; -import { SearchSource } from '../../../../../../../src/plugins/data/public/search/search_source'; +import { SearchSource } from 'src/plugins/data/public'; export class MockSearchSource { setField = jest.fn(); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts index 3223d0c94178f..0bc9bba7816ca 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.test.ts @@ -9,7 +9,7 @@ jest.mock('../../../kibana_services'); jest.mock('./load_index_settings'); import { getIndexPatternService, getSearchService, getHttp } from '../../../kibana_services'; -import { SearchSource } from '../../../../../../../src/plugins/data/public/search/search_source'; +import { SearchSource } from 'src/plugins/data/public'; // @ts-expect-error import { loadIndexSettings } from './load_index_settings'; diff --git a/x-pack/plugins/maps/public/components/tooltip_selector/add_tooltip_field_popover.tsx b/x-pack/plugins/maps/public/components/tooltip_selector/add_tooltip_field_popover.tsx index 7e18088444129..3aed5af5c5c25 100644 --- a/x-pack/plugins/maps/public/components/tooltip_selector/add_tooltip_field_popover.tsx +++ b/x-pack/plugins/maps/public/components/tooltip_selector/add_tooltip_field_popover.tsx @@ -27,7 +27,7 @@ export type FieldProps = { name: string; }; -type FieldOptions = Array>; +type FieldOption = EuiSelectableOption<{ value: string }>; function sortByLabel(a: EuiSelectableOption, b: EuiSelectableOption): number { return a.label.localeCompare(b.label); @@ -68,7 +68,7 @@ interface Props { interface State { isPopoverOpen: boolean; checkedFields: string[]; - options?: FieldOptions; + options?: FieldOption[]; prevFields?: FieldProps[]; prevSelectedFields?: FieldProps[]; } @@ -107,15 +107,13 @@ export class AddTooltipFieldPopover extends Component { }); }; - _onSelect = (selectableOptions: EuiSelectableOption[]) => { - // EUI team to remove casting: https://github.com/elastic/eui/issues/3966 - const options = selectableOptions as FieldOptions; + _onSelect = (options: FieldOption[]) => { const checkedFields: string[] = options .filter((option) => { return option.checked === 'on'; }) .map((option) => { - return option.value!; + return option.value; }); this.setState({ @@ -156,7 +154,7 @@ export class AddTooltipFieldPopover extends Component { return ( - searchable searchProps={{ compressed: true }} options={this.state.options} diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 5eb0482905e36..46e39fcdac27a 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'src/core/server'; import { take } from 'rxjs/operators'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { PluginSetupContract as FeaturesPluginSetupContract } from '../../features/server'; // @ts-ignore import { getEcommerceSavedObjects } from './sample_data/ecommerce_saved_objects'; @@ -168,7 +169,8 @@ export class MapsPlugin implements Plugin { name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', }), - order: 600, + order: 400, + category: DEFAULT_APP_CATEGORIES.kibana, icon: APP_ICON, navLinkId: APP_ID, app: [APP_ID, 'kibana'], diff --git a/x-pack/plugins/ml/common/constants/app.ts b/x-pack/plugins/ml/common/constants/app.ts index 97dd7a7b0fef5..3d54e9e150fef 100644 --- a/x-pack/plugins/ml/common/constants/app.ts +++ b/x-pack/plugins/ml/common/constants/app.ts @@ -4,6 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export const PLUGIN_ID = 'ml'; export const PLUGIN_ICON = 'machineLearningApp'; export const PLUGIN_ICON_SOLUTION = 'logoKibana'; +export const ML_APP_NAME = i18n.translate('xpack.ml.navMenu.mlAppNameText', { + defaultMessage: 'Machine Learning', +}); diff --git a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts index 830537cbadbc8..9a7af2496c03f 100644 --- a/x-pack/plugins/ml/common/constants/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/constants/data_frame_analytics.ts @@ -4,4 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ANALYSIS_CONFIG_TYPE = { + OUTLIER_DETECTION: 'outlier_detection', + REGRESSION: 'regression', + CLASSIFICATION: 'classification', +} as const; export const DEFAULT_RESULTS_FIELD = 'ml'; diff --git a/x-pack/plugins/ml/common/constants/ml_url_generator.ts b/x-pack/plugins/ml/common/constants/ml_url_generator.ts index 44f33aa329e7a..541b8af6fc0fc 100644 --- a/x-pack/plugins/ml/common/constants/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/constants/ml_url_generator.ts @@ -31,8 +31,16 @@ export const ML_PAGES = { * Open index data visualizer viewer page */ DATA_VISUALIZER_INDEX_VIEWER: 'jobs/new_job/datavisualizer', + ANOMALY_DETECTION_CREATE_JOB: `jobs/new_job`, ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE: `jobs/new_job/step/job_type`, + ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX: `jobs/new_job/step/index_or_search`, SETTINGS: 'settings', CALENDARS_MANAGE: 'settings/calendars_list', + CALENDARS_NEW: 'settings/calendars_list/new_calendar', + CALENDARS_EDIT: 'settings/calendars_list/edit_calendar', FILTER_LISTS_MANAGE: 'settings/filter_lists', + FILTER_LISTS_NEW: 'settings/filter_lists/new_filter_list', + FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', + ACCESS_DENIED: 'access-denied', + OVERVIEW: 'overview', } as const; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index 791a7de48f36f..9a415ac0718b3 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -5,3 +5,5 @@ */ export { SearchResponse7 } from './types/es_client'; +export { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './constants/anomalies'; +export { getSeverityColor, getSeverityType } from './util/anomaly_utils'; diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index 96d6c81a3d309..5d0ecf96fb6b5 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -6,6 +6,7 @@ import Boom from 'boom'; import { EsErrorBody } from '../util/errors'; +import { ANALYSIS_CONFIG_TYPE } from '../constants/data_frame_analytics'; export interface DeleteDataFrameAnalyticsWithIndexStatus { success: boolean; @@ -81,8 +82,4 @@ export interface DataFrameAnalyticsConfig { allow_lazy_start?: boolean; } -export enum ANALYSIS_CONFIG_TYPE { - OUTLIER_DETECTION = 'outlier_detection', - REGRESSION = 'regression', - CLASSIFICATION = 'classification', -} +export type DataFrameAnalysisConfigType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; diff --git a/x-pack/plugins/ml/common/types/file_datavisualizer.ts b/x-pack/plugins/ml/common/types/file_datavisualizer.ts index a8b775c8d5f60..9dc3896e9be48 100644 --- a/x-pack/plugins/ml/common/types/file_datavisualizer.ts +++ b/x-pack/plugins/ml/common/types/file_datavisualizer.ts @@ -29,6 +29,8 @@ export interface FindFileStructureResponse { count: number; cardinality: number; top_hits: Array<{ count: number; value: any }>; + max_value?: number; + min_value?: number; }; }; sample_start: string; @@ -42,7 +44,7 @@ export interface FindFileStructureResponse { delimiter: string; need_client_timezone: boolean; num_lines_analyzed: number; - column_names: string[]; + column_names?: string[]; explanation?: string[]; grok_pattern?: string; multiline_start_pattern?: string; diff --git a/x-pack/plugins/ml/common/types/ml_url_generator.ts b/x-pack/plugins/ml/common/types/ml_url_generator.ts index 234be8b6faf90..d176c22bdbb62 100644 --- a/x-pack/plugins/ml/common/types/ml_url_generator.ts +++ b/x-pack/plugins/ml/common/types/ml_url_generator.ts @@ -5,27 +5,21 @@ */ import { RefreshInterval, TimeRange } from '../../../../../src/plugins/data/common/query'; -import { JobId } from '../../../reporting/common/types'; +import { JobId } from './anomaly_detection_jobs/job'; import { ML_PAGES } from '../constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from './data_frame_analytics'; type OptionalPageState = object | undefined; export type MLPageState = PageState extends OptionalPageState - ? { page: PageType; pageState?: PageState } + ? { page: PageType; pageState?: PageState; excludeBasePath?: boolean } : PageState extends object - ? { page: PageType; pageState: PageState } - : { page: PageType }; - -export const ANALYSIS_CONFIG_TYPE = { - OUTLIER_DETECTION: 'outlier_detection', - REGRESSION: 'regression', - CLASSIFICATION: 'classification', -} as const; - -type DataFrameAnalyticsType = typeof ANALYSIS_CONFIG_TYPE[keyof typeof ANALYSIS_CONFIG_TYPE]; + ? { page: PageType; pageState: PageState; excludeBasePath?: boolean } + : { page: PageType; excludeBasePath?: boolean }; export interface MlCommonGlobalState { time?: TimeRange; + refreshInterval?: RefreshInterval; } export interface MlCommonAppState { [key: string]: any; @@ -42,16 +36,28 @@ export interface MlGenericUrlPageState extends MlIndexBasedSearchState { [key: string]: any; } -export interface MlGenericUrlState { - page: - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER - | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE; - pageState: MlGenericUrlPageState; -} +export type MlGenericUrlState = MLPageState< + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE + | typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX + | typeof ML_PAGES.OVERVIEW + | typeof ML_PAGES.CALENDARS_MANAGE + | typeof ML_PAGES.CALENDARS_NEW + | typeof ML_PAGES.FILTER_LISTS_MANAGE + | typeof ML_PAGES.FILTER_LISTS_NEW + | typeof ML_PAGES.SETTINGS + | typeof ML_PAGES.ACCESS_DENIED + | typeof ML_PAGES.DATA_VISUALIZER + | typeof ML_PAGES.DATA_VISUALIZER_FILE + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT, + MlGenericUrlPageState | undefined +>; export interface AnomalyDetectionQueryState { jobId?: JobId; groupIds?: string[]; + globalState?: MlCommonGlobalState; } export type AnomalyDetectionUrlState = MLPageState< @@ -86,7 +92,7 @@ export interface ExplorerUrlPageState { /** * Job IDs */ - jobIds: JobId[]; + jobIds?: JobId[]; /** * Optionally set the time range in the time picker. */ @@ -104,6 +110,7 @@ export interface ExplorerUrlPageState { */ mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; mlExplorerFilter?: ExplorerAppState['mlExplorerFilter']; + globalState?: MlCommonGlobalState; } export type ExplorerUrlState = MLPageState; @@ -122,6 +129,7 @@ export interface TimeSeriesExplorerAppState { to?: string; }; mlTimeSeriesExplorer?: { + forecastId?: string; detectorIndex?: number; entities?: Record; }; @@ -131,10 +139,12 @@ export interface TimeSeriesExplorerAppState { export interface TimeSeriesExplorerPageState extends Pick, Pick { - jobIds: JobId[]; + jobIds?: JobId[]; timeRange?: TimeRange; detectorIndex?: number; entities?: Record; + forecastId?: string; + globalState?: MlCommonGlobalState; } export type TimeSeriesExplorerUrlState = MLPageState< @@ -145,6 +155,7 @@ export type TimeSeriesExplorerUrlState = MLPageState< export interface DataFrameAnalyticsQueryState { jobId?: JobId | JobId[]; groupIds?: string[]; + globalState?: MlCommonGlobalState; } export type DataFrameAnalyticsUrlState = MLPageState< @@ -152,17 +163,10 @@ export type DataFrameAnalyticsUrlState = MLPageState< DataFrameAnalyticsQueryState | undefined >; -export interface DataVisualizerUrlState { - page: - | typeof ML_PAGES.DATA_VISUALIZER - | typeof ML_PAGES.DATA_VISUALIZER_FILE - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT; -} - export interface DataFrameAnalyticsExplorationQueryState { ml: { jobId: JobId; - analysisType: DataFrameAnalyticsType; + analysisType: DataFrameAnalysisConfigType; }; } @@ -170,7 +174,24 @@ export type DataFrameAnalyticsExplorationUrlState = MLPageState< typeof ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, { jobId: JobId; - analysisType: DataFrameAnalyticsType; + analysisType: DataFrameAnalysisConfigType; + globalState?: MlCommonGlobalState; + } +>; + +export type CalendarEditUrlState = MLPageState< + typeof ML_PAGES.CALENDARS_EDIT, + { + calendarId: string; + globalState?: MlCommonGlobalState; + } +>; + +export type FilterEditUrlState = MLPageState< + typeof ML_PAGES.FILTER_LISTS_EDIT, + { + filterId: string; + globalState?: MlCommonGlobalState; } >; @@ -183,5 +204,6 @@ export type MlUrlGeneratorState = | TimeSeriesExplorerUrlState | DataFrameAnalyticsUrlState | DataFrameAnalyticsExplorationUrlState - | DataVisualizerUrlState + | CalendarEditUrlState + | FilterEditUrlState | MlGenericUrlState; diff --git a/x-pack/plugins/ml/common/util/analytics_utils.ts b/x-pack/plugins/ml/common/util/analytics_utils.ts index d725984a47d66..d231ed4344389 100644 --- a/x-pack/plugins/ml/common/util/analytics_utils.ts +++ b/x-pack/plugins/ml/common/util/analytics_utils.ts @@ -9,8 +9,8 @@ import { ClassificationAnalysis, OutlierAnalysis, RegressionAnalysis, - ANALYSIS_CONFIG_TYPE, } from '../types/data_frame_analytics'; +import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; export const isOutlierAnalysis = (arg: any): arg is OutlierAnalysis => { const keys = Object.keys(arg); diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index fc673397ef177..1cd52079b4e39 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -16,7 +16,8 @@ "embeddable", "uiActions", "kibanaLegacy", - "indexPatternManagement" + "indexPatternManagement", + "discover" ], "optionalPlugins": [ "home", @@ -34,5 +35,8 @@ "dashboard", "savedObjects", "home" + ], + "extraPublicDirs": [ + "common" ] } diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index c281dc4e9ae05..e3bcc53fe697f 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -20,6 +20,7 @@ import { MlSetupDependencies, MlStartDependencies } from '../plugin'; import { MlRouter } from './routing'; import { mlApiServicesProvider } from './services/ml_api_service'; import { HttpService } from './services/http_service'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../common/constants/ml_url_generator'; export type MlDependencies = Omit & MlStartDependencies; @@ -50,11 +51,21 @@ export interface MlServicesContext { export type MlGlobalServices = ReturnType; const App: FC = ({ coreStart, deps, appMountParams }) => { + const redirectToMlAccessDeniedPage = async () => { + const accessDeniedPageUrl = await deps.share.urlGenerators + .getUrlGenerator(ML_APP_URL_GENERATOR) + .createUrl({ + page: ML_PAGES.ACCESS_DENIED, + }); + await coreStart.application.navigateToUrl(accessDeniedPageUrl); + }; + const pageDeps = { history: appMountParams.history, indexPatterns: deps.data.indexPatterns, config: coreStart.uiSettings!, setBreadcrumbs: coreStart.chrome!.setBreadcrumbs, + redirectToMlAccessDeniedPage, }; const services = { appName: 'ML', diff --git a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts index 653eca126006d..cdd25821ea5ca 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -33,10 +33,12 @@ export function checkGetManagementMlJobsResolver() { }); } -export function checkGetJobsCapabilitiesResolver(): Promise { +export function checkGetJobsCapabilitiesResolver( + redirectToMlAccessDeniedPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities, isPlatinumOrTrialLicense }) => { + .then(async ({ capabilities, isPlatinumOrTrialLicense }) => { _capabilities = capabilities; // the minimum privilege for using ML with a platinum or trial license is being able to get the transforms list. // all other functionality is controlled by the return capabilities object. @@ -46,21 +48,23 @@ export function checkGetJobsCapabilitiesResolver(): Promise { if (_capabilities.canGetJobs || isPlatinumOrTrialLicense === false) { return resolve(_capabilities); } else { - window.location.href = '#/access-denied'; + await redirectToMlAccessDeniedPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/access-denied'; + .catch(async (e) => { + await redirectToMlAccessDeniedPage(); return reject(); }); }); } -export function checkCreateJobsCapabilitiesResolver(): Promise { +export function checkCreateJobsCapabilitiesResolver( + redirectToJobsManagementPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities, isPlatinumOrTrialLicense }) => { + .then(async ({ capabilities, isPlatinumOrTrialLicense }) => { _capabilities = capabilities; // if the license is basic (isPlatinumOrTrialLicense === false) then do not redirect, // allow the promise to resolve as the separate license check will redirect then user to @@ -69,34 +73,36 @@ export function checkCreateJobsCapabilitiesResolver(): Promise { return resolve(_capabilities); } else { // if the user has no permission to create a job, - // redirect them back to the Transforms Management page - window.location.href = '#/jobs'; + // redirect them back to the Anomaly Detection Management page + await redirectToJobsManagementPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/jobs'; + .catch(async (e) => { + await redirectToJobsManagementPage(); return reject(); }); }); } -export function checkFindFileStructurePrivilegeResolver(): Promise { +export function checkFindFileStructurePrivilegeResolver( + redirectToMlAccessDeniedPage: () => Promise +): Promise { return new Promise((resolve, reject) => { getCapabilities() - .then(({ capabilities }) => { + .then(async ({ capabilities }) => { _capabilities = capabilities; // the minimum privilege for using ML with a basic license is being able to use the datavisualizer. // all other functionality is controlled by the return _capabilities object if (_capabilities.canFindFileStructure) { return resolve(_capabilities); } else { - window.location.href = '#/access-denied'; + await redirectToMlAccessDeniedPage(); return reject(); } }) - .catch((e) => { - window.location.href = '#/access-denied'; + .catch(async (e) => { + await redirectToMlAccessDeniedPage(); return reject(); }); }); diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap index 9eb44c71aa799..114a6b235d1ad 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap @@ -1,170 +1,527 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` - - ", + "end_timestamp": 1455041968976, + "job_id": "farequote", + "modified_time": 1546417097181, + "modified_username": "", + "timestamp": 1455026177994, + "type": "annotation", + }, + ] + } + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + kibana={ + Object { + "notifications": Object { + "toasts": Object { + "danger": [Function], + "show": [Function], + "success": [Function], + "warning": [Function], + }, + }, + "overlays": Object { + "openFlyout": [Function], + "openModal": [Function], + }, + "services": Object {}, + } + } +/> +`; + +exports[`AnnotationsTable Initialization with job config prop. 1`] = ` +", - "end_timestamp": 1455041968976, "job_id": "farequote", - "modified_time": 1546417097181, - "modified_username": "", - "timestamp": 1455026177994, - "type": "annotation", + "query": Object { + "bool": Object { + "adjust_pure_negative": true, + "boost": 1, + "must": Array [ + Object { + "query_string": Object { + "analyze_wildcard": true, + "auto_generate_synonyms_phrase_query": true, + "boost": 1, + "default_operator": "or", + "enable_position_increments": true, + "escape": false, + "fields": Array [], + "fuzziness": "AUTO", + "fuzzy_max_expansions": 50, + "fuzzy_prefix_length": 0, + "fuzzy_transpositions": true, + "max_determinized_states": 10000, + "phrase_slop": 0, + "query": "*", + "type": "best_fields", + }, + }, + ], + }, + }, + "query_delay": "115823ms", + "scroll_size": 1000, + "state": "stopped", }, - ] - } - pagination={ - Object { - "pageSizeOptions": Array [ - 5, - 10, - 25, - ], - } - } - responsive={true} - rowProps={[Function]} - search={ - Object { - "box": Object { - "incremental": true, - "schema": true, - }, - "defaultQuery": "event:(user or delayed_data)", - "filters": Array [ - Object { - "field": "event", - "multiSelect": "or", - "name": "Event", - "options": Array [], - "type": "field_value_selection", - }, - ], - } - } - sorting={ - Object { - "sort": Object { - "direction": "asc", - "field": "timestamp", + "description": "", + "established_model_memory": 42102, + "finished_time": 1546418359427, + "job_id": "farequote", + "job_type": "anomaly_detector", + "job_version": "7.0.0", + "model_plot_config": Object { + "enabled": true, }, - } + "model_size_stats": Object { + "bucket_allocation_failures_count": 0, + "job_id": "farequote", + "log_time": 1546418359000, + "memory_status": "ok", + "model_bytes": 42102, + "result_type": "model_size_stats", + "timestamp": 1455232500000, + "total_by_field_count": 3, + "total_over_field_count": 0, + "total_partition_field_count": 2, + }, + "model_snapshot_id": "1546418359", + "model_snapshot_min_version": "6.4.0", + "model_snapshot_retention_days": 1, + "results_index_name": "shared", + "state": "closed", + }, + ] + } + kibana={ + Object { + "notifications": Object { + "toasts": Object { + "danger": [Function], + "show": [Function], + "success": [Function], + "warning": [Function], + }, + }, + "overlays": Object { + "openFlyout": [Function], + "openModal": [Function], + }, + "services": Object {}, } - tableLayout="fixed" - /> - -`; - -exports[`AnnotationsTable Initialization with job config prop. 1`] = ` - - - - - + } +/> `; exports[`AnnotationsTable Minimal initialization without props. 1`] = ` - + `; diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index 9dabfce163dbb..d5025fd3c3649 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -13,7 +13,6 @@ import uniq from 'lodash/uniq'; import PropTypes from 'prop-types'; -import rison from 'rison-node'; import React, { Component, Fragment } from 'react'; import memoizeOne from 'memoize-one'; import { @@ -54,12 +53,15 @@ import { ANNOTATION_EVENT_USER, ANNOTATION_EVENT_DELAYED_DATA, } from '../../../../../common/constants/annotations'; +import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../../common/constants/app'; const CURRENT_SERIES = 'current_series'; /** * Table component for rendering the lists of annotations for an ML job. */ -export class AnnotationsTable extends Component { +class AnnotationsTableUI extends Component { static propTypes = { annotations: PropTypes.array, jobs: PropTypes.array, @@ -199,7 +201,17 @@ export class AnnotationsTable extends Component { } } - openSingleMetricView = (annotation = {}) => { + openSingleMetricView = async (annotation = {}) => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + // Creates the link to the Single Metric Viewer. // Set the total time range from the start to the end of the annotation. const job = this.getJob(annotation.job_id); @@ -210,30 +222,10 @@ export class AnnotationsTable extends Component { ); const from = new Date(dataCounts.earliest_record_timestamp).toISOString(); const to = new Date(resultLatest).toISOString(); - - const globalSettings = { - ml: { - jobIds: [job.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from, - to, - mode: 'absolute', - }, - }; - - const appState = { - query: { - query_string: { - analyze_wildcard: true, - query: '*', - }, - }, + const timeRange = { + from, + to, + mode: 'absolute', }; let mlTimeSeriesExplorer = {}; const entityCondition = {}; @@ -247,11 +239,11 @@ export class AnnotationsTable extends Component { }; if (annotation.timestamp < dataCounts.earliest_record_timestamp) { - globalSettings.time.from = new Date(annotation.timestamp).toISOString(); + timeRange.from = new Date(annotation.timestamp).toISOString(); } if (annotation.end_timestamp > dataCounts.latest_record_timestamp) { - globalSettings.time.to = new Date(annotation.end_timestamp).toISOString(); + timeRange.to = new Date(annotation.end_timestamp).toISOString(); } } @@ -274,14 +266,34 @@ export class AnnotationsTable extends Component { entityCondition[annotation.by_field_name] = annotation.by_field_value; } mlTimeSeriesExplorer.entities = entityCondition; - appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; - - const _g = rison.encode(globalSettings); - const _a = rison.encode(appState); + // appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; + + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const singleMetricViewerLink = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + timeRange, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + jobIds: [job.job_id], + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, + }, + ...mlTimeSeriesExplorer, + }, + excludeBasePath: true, + }); - const url = `?_g=${_g}&_a=${_a}`; - addItemToRecentlyAccessed('timeseriesexplorer', job.job_id, url); - window.open(`#/timeseriesexplorer${url}`, '_self'); + addItemToRecentlyAccessed('timeseriesexplorer', job.job_id, singleMetricViewerLink); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); }; onMouseOverRow = (record) => { @@ -686,3 +698,5 @@ export class AnnotationsTable extends Component { ); } } + +export const AnnotationsTable = withKibana(AnnotationsTableUI); diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js index fdeab0c49e32b..6025dd1c7433e 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/links_menu.js @@ -29,6 +29,8 @@ import { getUrlForRecord, openCustomUrlWindow } from '../../util/custom_url_util import { formatHumanReadableDateTimeSeconds } from '../../util/date_utils'; import { getIndexPatternIdFromName } from '../../util/index_utils'; import { replaceStringTokens } from '../../util/string_utils'; +import { ML_APP_URL_GENERATOR, ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../common/constants/app'; /* * Component for rendering the links menu inside a cell in the anomalies table. */ @@ -142,7 +144,18 @@ class LinksMenuUI extends Component { } }; - viewSeries = () => { + viewSeries = async () => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const record = this.props.anomaly.source; const bounds = this.props.bounds; const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z @@ -171,44 +184,36 @@ class LinksMenuUI extends Component { entityCondition[record.by_field_name] = record.by_field_value; } - // Use rison to build the URL . - const _g = rison.encode({ - ml: { + const singleMetricViewerLink = await mlUrlGenerator.createUrl({ + excludeBasePath: true, + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { jobIds: [record.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from: from, - to: to, - mode: 'absolute', - }, - }); - - const _a = rison.encode({ - mlTimeSeriesExplorer: { + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeRange: { + from: from, + to: to, + mode: 'absolute', + }, zoom: { from: zoomFrom, to: zoomTo, }, detectorIndex: record.detector_index, entities: entityCondition, - }, - query: { query_string: { analyze_wildcard: true, query: '*', }, }, }); - - // Need to encode the _a parameter in case any entities contain unsafe characters such as '+'. - let path = '#/timeseriesexplorer'; - path += `?_g=${_g}&_a=${encodeURIComponent(_a)}`; - window.open(path, '_blank'); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); }; viewExamples = () => { diff --git a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx index 4a63a8cd7e716..d54a7fe81e858 100644 --- a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx +++ b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.test.tsx @@ -6,13 +6,22 @@ import React from 'react'; import { Router } from 'react-router-dom'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { createBrowserHistory } from 'history'; import { I18nProvider } from '@kbn/i18n/react'; import { AnomalyResultsViewSelector } from './index'; +jest.mock('../../contexts/kibana', () => { + return { + useMlUrlGenerator: () => ({ + createUrl: jest.fn(), + }), + useNavigateToPath: () => jest.fn(), + }; +}); + describe('AnomalyResultsViewSelector', () => { test('should create selector with correctly selected value', () => { const history = createBrowserHistory(); @@ -31,27 +40,4 @@ describe('AnomalyResultsViewSelector', () => { getByTestId('mlAnomalyResultsViewSelectorSingleMetricViewer').hasAttribute('checked') ).toBe(true); }); - - test('should open window to other results view when clicking on non-checked input', () => { - // Create mock for window.open - const mockedOpen = jest.fn(); - const originalOpen = window.open; - window.open = mockedOpen; - - const history = createBrowserHistory(); - - const { getByTestId } = render( - - - - - - ); - - fireEvent.click(getByTestId('mlAnomalyResultsViewSelectorExplorer')); - expect(mockedOpen).toHaveBeenCalledWith('#/explorer', '_self'); - - // Clean-up window.open. - window.open = originalOpen; - }); }); diff --git a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx index 78acb422851e3..c4c8f06bbbc3a 100644 --- a/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/anomaly_results_view_selector/anomaly_results_view_selector.tsx @@ -5,21 +5,25 @@ */ import React, { FC, useMemo } from 'react'; -import { encode } from 'rison-node'; import { EuiButtonGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useUrlState } from '../../util/url_state'; +import { useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; interface Props { - viewId: 'timeseriesexplorer' | 'explorer'; + viewId: typeof ML_PAGES.SINGLE_METRIC_VIEWER | typeof ML_PAGES.ANOMALY_EXPLORER; } // Component for rendering a set of buttons for switching between the Anomaly Detection results views. export const AnomalyResultsViewSelector: FC = ({ viewId }) => { + const urlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + const toggleButtonsIcons = useMemo( () => [ { @@ -28,7 +32,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { defaultMessage: 'View results in the Single Metric Viewer', }), iconType: 'visLine', - value: 'timeseriesexplorer', + value: ML_PAGES.SINGLE_METRIC_VIEWER, 'data-test-subj': 'mlAnomalyResultsViewSelectorSingleMetricViewer', }, { @@ -37,7 +41,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { defaultMessage: 'View results in the Anomaly Explorer', }), iconType: 'visTable', - value: 'explorer', + value: ML_PAGES.ANOMALY_EXPLORER, 'data-test-subj': 'mlAnomalyResultsViewSelectorExplorer', }, ], @@ -46,9 +50,14 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { const [globalState] = useUrlState('_g'); - const onChangeView = (newViewId: string) => { - const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; - window.open(`#/${newViewId}${fullGlobalStateString}`, '_self'); + const onChangeView = async (newViewId: Props['viewId']) => { + const url = await urlGenerator.createUrl({ + page: newViewId, + pageState: { + globalState, + }, + }); + await navigateToPath(url); }; return ( @@ -60,7 +69,7 @@ export const AnomalyResultsViewSelector: FC = ({ viewId }) => { data-test-subj="mlAnomalyResultsViewSelector" options={toggleButtonsIcons} idSelected={viewId} - onChange={onChangeView} + onChange={(newViewId: string) => onChangeView(newViewId as Props['viewId'])} isIconOnly /> ); diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts index 368e758a027c4..b4668810b9421 100644 --- a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts @@ -22,16 +22,19 @@ export const useCreateADLinks = () => { const userTimeSettings = useUiSettings().get(ANOMALY_DETECTION_DEFAULT_TIME_RANGE); const createLinkWithUserDefaults = useCallback( (location, jobList) => { - const resultsPageUrl = mlJobService.createResultsUrlForJobs( + return mlJobService.createResultsUrlForJobs( jobList, location, useUserTimeSettings === true && userTimeSettings !== undefined ? userTimeSettings : undefined ); - return `${basePath.get()}/app/ml${resultsPageUrl}`; }, [basePath] ); return { createLinkWithUserDefaults }; }; + +export type CreateLinkWithUserDefaults = ReturnType< + typeof useCreateADLinks +>['createLinkWithUserDefaults']; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 22815fe593d57..6aad5d53c3a3c 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -32,6 +32,7 @@ import { UseIndexDataReturnType } from './types'; import { DecisionPathPopover } from './feature_importance/decision_path_popover'; import { TopClasses } from '../../../../common/types/feature_importance'; import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalysisConfigType } from '../../../../common/types/data_frame_analytics'; // TODO Fix row hovering + bar highlighting // import { hoveredRow$ } from './column_chart'; @@ -44,7 +45,7 @@ export const DataGridTitle: FC<{ title: string }> = ({ title }) => ( interface PropsWithoutHeader extends UseIndexDataReturnType { baseline?: number; - analysisType?: ANALYSIS_CONFIG_TYPE; + analysisType?: DataFrameAnalysisConfigType; resultsField?: string; dataTestSubj: string; toastNotifications: CoreSetup['notifications']['toasts']; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx index 263337f93e9a8..7c4428db71b3b 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx @@ -13,10 +13,11 @@ import { FeatureImportance, TopClasses } from '../../../../../common/types/featu import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common'; import { ClassificationDecisionPath } from './decision_path_classification'; import { useMlKibana } from '../../../contexts/kibana'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; interface DecisionPathPopoverProps { featureImportance: FeatureImportance[]; - analysisType: ANALYSIS_CONFIG_TYPE; + analysisType: DataFrameAnalysisConfigType; predictionFieldName?: string; baseline?: number; predictedValue?: number | string | undefined; diff --git a/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js b/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js index 1f03dbe134756..279afc8c50339 100644 --- a/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js +++ b/x-pack/plugins/ml/public/application/components/data_recognizer/recognized_result.js @@ -9,11 +9,16 @@ import PropTypes from 'prop-types'; import { EuiIcon, EuiFlexItem } from '@elastic/eui'; import { CreateJobLinkCard } from '../create_job_link_card'; +import { useMlKibana } from '../../contexts/kibana'; export const RecognizedResult = ({ config, indexPattern, savedSearch }) => { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); const id = savedSearch === null ? `index=${indexPattern.id}` : `savedSearchId=${savedSearch.id}`; - - const href = `#/jobs/new_job/recognize?id=${config.id}&${id}`; + const href = `${basePath.get()}/app/ml/jobs/new_job/recognize?id=${config.id}&${id}`; let logo = null; // if a logo is available, use that, otherwise display the id diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx index 3a4875fa243fd..671f0b196ce35 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/main_tabs.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; -import { encode } from 'rison-node'; +import React, { FC, useState, useEffect } from 'react'; -import { EuiTabs, EuiTab, EuiLink } from '@elastic/eui'; +import { EuiTabs, EuiTab } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; - -import { useUrlState } from '../../util/url_state'; - import { TabId } from './navigation_menu'; +import { useMlKibana, useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; +import { MlUrlGeneratorState } from '../../../../common/types/ml_url_generator'; +import { useUrlState } from '../../util/url_state'; +import { ML_APP_NAME } from '../../../../common/constants/app'; export interface Tab { id: TabId; @@ -66,20 +66,57 @@ function getTabs(disableLinks: boolean): Tab[] { } interface TabData { testSubject: string; - pathId?: string; + pathId?: MlUrlGeneratorState['page']; + name: string; } const TAB_DATA: Record = { - overview: { testSubject: 'mlMainTab overview' }, + overview: { + testSubject: 'mlMainTab overview', + name: i18n.translate('xpack.ml.overviewTabLabel', { + defaultMessage: 'Overview', + }), + }, // Note that anomaly detection jobs list is mapped to ml#/jobs. - anomaly_detection: { testSubject: 'mlMainTab anomalyDetection', pathId: 'jobs' }, - data_frame_analytics: { testSubject: 'mlMainTab dataFrameAnalytics' }, - datavisualizer: { testSubject: 'mlMainTab dataVisualizer' }, - settings: { testSubject: 'mlMainTab settings' }, - 'access-denied': { testSubject: 'mlMainTab overview' }, + anomaly_detection: { + testSubject: 'mlMainTab anomalyDetection', + name: i18n.translate('xpack.ml.anomalyDetectionTabLabel', { + defaultMessage: 'Anomaly Detection', + }), + pathId: 'jobs', + }, + data_frame_analytics: { + testSubject: 'mlMainTab dataFrameAnalytics', + name: i18n.translate('xpack.ml.dataFrameAnalyticsTabLabel', { + defaultMessage: 'Data Frame Analytics', + }), + }, + datavisualizer: { + testSubject: 'mlMainTab dataVisualizer', + name: i18n.translate('xpack.ml.dataVisualizerTabLabel', { + defaultMessage: 'Data Visualizer', + }), + }, + settings: { + testSubject: 'mlMainTab settings', + name: i18n.translate('xpack.ml.settingsTabLabel', { + defaultMessage: 'Settings', + }), + }, + 'access-denied': { + testSubject: 'mlMainTab overview', + name: i18n.translate('xpack.ml.accessDeniedTabLabel', { + defaultMessage: 'Access Denied', + }), + }, }; export const MainTabs: FC = ({ tabId, disableLinks }) => { + const { + services: { + chrome: { docTitle }, + }, + } = useMlKibana(); const [globalState] = useUrlState('_g'); const [selectedTabId, setSelectedTabId] = useState(tabId); function onSelectedTabChanged(id: TabId) { @@ -87,16 +124,40 @@ export const MainTabs: FC = ({ tabId, disableLinks }) => { } const tabs = getTabs(disableLinks); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToTab = async (defaultPathId: MlUrlGeneratorState['page']) => { + const pageState = + globalState?.refreshInterval !== undefined + ? { + globalState: { + refreshInterval: globalState.refreshInterval, + }, + } + : undefined; + // TODO - Fix ts so passing pageState won't default to MlGenericUrlState when pageState is passed in + // @ts-ignore + const path = await mlUrlGenerator.createUrl({ + page: defaultPathId, + // only retain the refreshInterval part of globalState + // appState will not be considered. + pageState, + }); + + await navigateToPath(path, false); + }; + + useEffect(() => { + docTitle.change([TAB_DATA[selectedTabId].name, ML_APP_NAME]); + }, [selectedTabId]); return ( {tabs.map((tab: Tab) => { const { id, disabled } = tab; const testSubject = TAB_DATA[id].testSubject; - const defaultPathId = TAB_DATA[id].pathId || id; - // globalState (e.g. selected jobs and time range) should be retained when changing pages. - // appState will not be considered. - const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; + const defaultPathId = (TAB_DATA[id].pathId || id) as MlUrlGeneratorState['page']; return disabled ? ( @@ -104,21 +165,18 @@ export const MainTabs: FC = ({ tabId, disableLinks }) => { ) : (
- { + onSelectedTabChanged(id); + redirectToTab(defaultPathId); + }} + isSelected={id === selectedTabId} + key={`tab-${id}-key`} > - onSelectedTabChanged(id)} - isSelected={id === selectedTabId} - key={`tab-${id}-key`} - > - {tab.name} - - + {tab.name} +
); })} diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js b/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js index 48e0da72f067c..eb12cb7679674 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js +++ b/x-pack/plugins/ml/public/application/components/rule_editor/scope_section.js @@ -17,8 +17,19 @@ import { ScopeExpression } from './scope_expression'; import { checkPermission } from '../../capabilities/check_capabilities'; import { getScopeFieldDefaults } from './utils'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ML_PAGES } from '../../../../common/constants/ml_url_generator'; +import { useMlUrlGenerator, useNavigateToPath } from '../../contexts/kibana'; function NoFilterListsCallOut() { + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + const redirectToFilterManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.FILTER_LISTS_MANAGE, + }); + await navigateToPath(path, true); + }; + return ( + useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts index 48385ad3ae6a8..d448185c914b8 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_create_url.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { useCallback, useEffect, useState } from 'react'; import { useMlKibana } from './kibana_context'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { MlUrlGeneratorState } from '../../../../common/types/ml_url_generator'; +import { useUrlState } from '../../util/url_state'; export const useMlUrlGenerator = () => { const { @@ -18,3 +21,59 @@ export const useMlUrlGenerator = () => { return getUrlGenerator(ML_APP_URL_GENERATOR); }; + +export const useMlLink = (params: MlUrlGeneratorState): string => { + const [href, setHref] = useState(params.page); + const mlUrlGenerator = useMlUrlGenerator(); + + useEffect(() => { + let isCancelled = false; + const generateUrl = async (_params: MlUrlGeneratorState) => { + const url = await mlUrlGenerator.createUrl(_params); + if (!isCancelled) { + setHref(url); + } + }; + generateUrl(params); + return () => { + isCancelled = true; + }; + }, [params]); + + return href; +}; + +export const useCreateAndNavigateToMlLink = ( + page: MlUrlGeneratorState['page'] +): (() => Promise) => { + const mlUrlGenerator = useMlUrlGenerator(); + const [globalState] = useUrlState('_g'); + + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToMlPage = useCallback( + async (_page: MlUrlGeneratorState['page']) => { + const pageState = + globalState?.refreshInterval !== undefined + ? { + globalState: { + refreshInterval: globalState.refreshInterval, + }, + } + : undefined; + + // TODO: fix ts only interpreting it as MlUrlGenericState if pageState is passed + // @ts-ignore + const url = await mlUrlGenerator.createUrl({ page: _page, pageState }); + await navigateToUrl(url); + }, + [mlUrlGenerator, navigateToUrl] + ); + + // returns the onClick callback + return useCallback(() => redirectToMlPage(page), [redirectToMlPage, page]); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 60681fb6e7bbe..d22bba7738db4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -15,8 +15,8 @@ import { SavedSearchQuery } from '../../contexts/ml'; import { AnalysisConfig, ClassificationAnalysis, + DataFrameAnalysisConfigType, RegressionAnalysis, - ANALYSIS_CONFIG_TYPE, } from '../../../../common/types/data_frame_analytics'; import { isOutlierAnalysis, @@ -26,6 +26,7 @@ import { getDependentVar, getPredictedFieldName, } from '../../../../common/util/analytics_utils'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common/constants/data_frame_analytics'; export type IndexPattern = string; export enum ANALYSIS_ADVANCED_FIELDS { @@ -429,7 +430,7 @@ interface LoadEvalDataConfig { predictionFieldName?: string; searchQuery?: ResultsSearchQuery; ignoreDefaultQuery?: boolean; - jobType: ANALYSIS_CONFIG_TYPE; + jobType: DataFrameAnalysisConfigType; requiresKeyword?: boolean; } @@ -550,7 +551,7 @@ export { isRegressionAnalysis, isClassificationAnalysis, getPredictionFieldName, - ANALYSIS_CONFIG_TYPE, getDependentVar, getPredictedFieldName, + ANALYSIS_CONFIG_TYPE, }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 00d735d9a866e..83eebccd310e3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -14,7 +14,6 @@ export { UpdateDataFrameAnalyticsConfig, IndexPattern, REFRESH_ANALYTICS_LIST_STATE, - ANALYSIS_CONFIG_TYPE, OUTLIER_ANALYSIS_METHOD, RegressionEvaluateResponse, getValuesFromResponse, @@ -26,6 +25,7 @@ export { SEARCH_SIZE, defaultSearchQuery, SearchQuery, + ANALYSIS_CONFIG_TYPE, } from './analytics'; export { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx index 1e5dbee3499bd..1e6a616fedd64 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx @@ -8,7 +8,7 @@ import React, { Fragment, FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiSelect } from '@elastic/eui'; -import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx index 88c89df86b29a..310cd4e3b3a79 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx @@ -16,6 +16,7 @@ import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../com import { CATEGORICAL_TYPES } from './form_options_validation'; import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; const containsClassificationFieldsCb = ({ name, type }: Field) => !OMIT_FIELDS.includes(name) && @@ -32,13 +33,13 @@ const containsRegressionFieldsCb = ({ name, type }: Field) => const containsOutlierFieldsCb = ({ name, type }: Field) => !OMIT_FIELDS.includes(name) && name !== EVENT_RATE_FIELD_ID && BASIC_NUMERICAL_TYPES.has(type); -const callbacks: Record boolean> = { +const callbacks: Record boolean> = { [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: containsClassificationFieldsCb, [ANALYSIS_CONFIG_TYPE.REGRESSION]: containsRegressionFieldsCb, [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: containsOutlierFieldsCb, }; -const messages: Record = { +const messages: Record = { [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: ( = ({ jobId, analysisType }) => { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx index eea579ef1d064..84b1c4241aaf2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/exploration_results_table.tsx @@ -29,7 +29,6 @@ import { SEARCH_SIZE, defaultSearchQuery, getAnalysisType, - ANALYSIS_CONFIG_TYPE, } from '../../../../common'; import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/use_columns'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; @@ -39,6 +38,7 @@ import { IndexPatternPrompt } from '../index_pattern_prompt'; import { useExplorationResults } from './use_exploration_results'; import { useMlKibana } from '../../../../../contexts/kibana'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; const showingDocs = i18n.translate( 'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText', @@ -195,7 +195,7 @@ export const ExplorationResultsTable: FC = React.memo( {...classificationData} dataTestSubj="mlExplorationDataGrid" toastNotifications={getToastNotifications()} - analysisType={(analysisType as unknown) as ANALYSIS_CONFIG_TYPE} + analysisType={(analysisType as unknown) as DataFrameAnalysisConfigType} />
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index c8349084dbda8..f4f01330271fc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -26,11 +26,12 @@ import { OutlierExploration } from './components/outlier_exploration'; import { RegressionExploration } from './components/regression_exploration'; import { ClassificationExploration } from './components/classification_exploration'; -import { ANALYSIS_CONFIG_TYPE } from '../../common/analytics'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; export const Page: FC<{ jobId: string; - analysisType: ANALYSIS_CONFIG_TYPE; + analysisType: DataFrameAnalysisConfigType; }> = ({ jobId, analysisType }) => ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx index a3595b51d0a59..2363e6fbecc9d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_view/use_view_action.tsx @@ -7,24 +7,32 @@ import React, { useCallback, useMemo } from 'react'; import { getAnalysisType } from '../../../../common/analytics'; -import { useNavigateToPath } from '../../../../../contexts/kibana'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../../../contexts/kibana'; -import { - getResultsUrl, - DataFrameAnalyticsListAction, - DataFrameAnalyticsListRow, -} from '../analytics_list/common'; +import { DataFrameAnalyticsListAction, DataFrameAnalyticsListRow } from '../analytics_list/common'; import { getViewLinkStatus } from './get_view_link_status'; import { viewActionButtonText, ViewButton } from './view_button'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; export type ViewAction = ReturnType; export const useViewAction = () => { + const mlUrlGenerator = useMlUrlGenerator(); const navigateToPath = useNavigateToPath(); + const redirectToTab = async (jobId: string, analysisType: DataFrameAnalysisConfigType) => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { jobId, analysisType }, + }); + + await navigateToPath(path, false); + }; + const clickHandler = useCallback((item: DataFrameAnalyticsListRow) => { - const analysisType = getAnalysisType(item.config.analysis); - navigateToPath(getResultsUrl(item.id, analysisType)); + const analysisType = getAnalysisType(item.config.analysis) as DataFrameAnalysisConfigType; + redirectToTab(item.id, analysisType); }, []); const action: DataFrameAnalyticsListAction = useMemo( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 0c3bff58c25cd..2f8e087a6a3f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -15,12 +15,8 @@ import { EuiSearchBarProps, EuiSpacer, } from '@elastic/eui'; - -import { - DataFrameAnalyticsId, - useRefreshAnalyticsList, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; +import { DataFrameAnalyticsId, useRefreshAnalyticsList } from '../../../../common'; import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 994357412510d..37076d400f021 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -9,11 +9,8 @@ import { EuiTableActionsColumnType, Query, Ast } from '@elastic/eui'; import { DATA_FRAME_TASK_STATE } from './data_frame_task_state'; export { DATA_FRAME_TASK_STATE }; -import { - DataFrameAnalyticsId, - DataFrameAnalyticsConfig, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common'; +import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; export enum DATA_FRAME_MODE { BATCH = 'batch', @@ -111,10 +108,7 @@ export interface DataFrameAnalyticsListRow { checkpointing: object; config: DataFrameAnalyticsConfig; id: DataFrameAnalyticsId; - job_type: - | ANALYSIS_CONFIG_TYPE.CLASSIFICATION - | ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION - | ANALYSIS_CONFIG_TYPE.REGRESSION; + job_type: DataFrameAnalysisConfigType; mode: string; state: DataFrameAnalyticsStats['state']; stats: DataFrameAnalyticsStats; @@ -137,10 +131,6 @@ export function isCompletedAnalyticsJob(stats: DataFrameAnalyticsStats) { return stats.state === DATA_FRAME_TASK_STATE.STOPPED && progress === 100; } -export function getResultsUrl(jobId: string, analysisType: ANALYSIS_CONFIG_TYPE | string) { - return `#/data_frame_analytics/exploration?_g=(ml:(jobId:${jobId},analysisType:${analysisType}))`; -} - // The single Action type is not exported as is // from EUI so we use that code to get the single // Action type from the array of actions. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index ef1d373a55a12..1af99d2a1ed00 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -19,8 +19,6 @@ import { EuiLink, RIGHT_ALIGNMENT, } from '@elastic/eui'; -import { getJobIdUrl, TAB_IDS } from '../../../../../util/get_selected_ids_url'; - import { getAnalysisType, DataFrameAnalyticsId } from '../../../../common'; import { getDataFrameAnalyticsProgressPhase, @@ -32,6 +30,8 @@ import { DataFrameAnalyticsStats, } from './common'; import { useActions } from './use_actions'; +import { useMlLink } from '../../../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; enum TASK_STATE_COLOR { analyzing = 'primary', @@ -134,9 +134,14 @@ export const progressColumn = { 'data-test-subj': 'mlAnalyticsTableColumnProgress', }; -export const getDFAnalyticsJobIdLink = (item: DataFrameAnalyticsListRow) => ( - {item.id} -); +export const DFAnalyticsJobIdLink = ({ item }: { item: DataFrameAnalyticsListRow }) => { + const href = useMlLink({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, + pageState: { jobId: item.id }, + }); + + return {item.id}; +}; export const useColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], @@ -145,7 +150,6 @@ export const useColumns = ( isMlEnabledInSpace: boolean = true ) => { const { actions, modals } = useActions(isManagementTable); - function toggleDetails(item: DataFrameAnalyticsListRow) { const index = expandedRowItemIds.indexOf(item.config.id); if (index !== -1) { @@ -200,7 +204,7 @@ export const useColumns = ( 'data-test-subj': 'mlAnalyticsTableColumnId', scope: 'row', render: (item: DataFrameAnalyticsListRow) => - isManagementTable ? getDFAnalyticsJobIdLink(item) : item.id, + isManagementTable ? : item.id, }, { field: DataFrameAnalyticsListColumn.description, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx index 338b6444671a6..dbc7a23f2258b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/models_management/models_list.tsx @@ -29,21 +29,23 @@ import { useInferenceApiService } from '../../../../../services/ml_api_service/i import { ModelsTableToConfigMapping } from './index'; import { TIME_FORMAT } from '../../../../../../../common/constants/time_format'; import { DeleteModelsModal } from './delete_models_modal'; -import { useMlKibana, useNotifications } from '../../../../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator, useNotifications } from '../../../../../contexts/kibana'; import { ExpandedRow } from './expanded_row'; -import { getResultsUrl } from '../analytics_list/common'; import { ModelConfigResponse, ModelPipelines, TrainedModelStat, } from '../../../../../../../common/types/inference'; import { + getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, refreshAnalyticsList$, useRefreshAnalyticsList, } from '../../../../common'; import { useTableSettings } from '../analytics_list/use_table_settings'; import { filterAnalyticsModels, AnalyticsSearchBar } from '../analytics_search_bar'; +import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; type Stats = Omit; @@ -61,6 +63,7 @@ export const ModelsList: FC = () => { application: { navigateToUrl, capabilities }, }, } = useMlKibana(); + const urlGenerator = useMlUrlGenerator(); const canDeleteDataFrameAnalytics = capabilities.ml.canDeleteDataFrameAnalytics as boolean; @@ -278,12 +281,19 @@ export const ModelsList: FC = () => { type: 'icon', available: (item) => item.metadata?.analytics_config?.id, onClick: async (item) => { - await navigateToUrl( - getResultsUrl( - item.metadata?.analytics_config.id, - Object.keys(item.metadata?.analytics_config.analysis)[0] - ) - ); + if (item.metadata?.analytics_config === undefined) return; + + const url = await urlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { + jobId: item.metadata?.analytics_config.id as string, + analysisType: getAnalysisType( + item.metadata?.analytics_config.analysis + ) as DataFrameAnalysisConfigType, + }, + }); + + await navigateToUrl(url); }, isPrimary: true, }, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index 7cd9fcc052f1a..178638322bacd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -33,13 +33,13 @@ import { JOB_ID_MAX_LENGTH, ALLOWED_DATA_UNITS, } from '../../../../../../../common/constants/validation'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; import { getDependentVar, getNumTopFeatureImportanceValues, getTrainingPercent, isRegressionAnalysis, isClassificationAnalysis, - ANALYSIS_CONFIG_TYPE, NUM_TOP_FEATURE_IMPORTANCE_VALUES_MIN, TRAINING_PERCENT_MIN, TRAINING_PERCENT_MAX, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 4926decaa7f9c..2a89c5a5fd686 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -8,13 +8,14 @@ import { DeepPartial, DeepReadonly } from '../../../../../../../common/types/com import { checkPermission } from '../../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../../ml_nodes_check'; -import { ANALYSIS_CONFIG_TYPE, defaultSearchQuery } from '../../../../common/analytics'; +import { defaultSearchQuery, getAnalysisType } from '../../../../common/analytics'; import { CloneDataFrameAnalyticsConfig } from '../../components/action_clone'; import { DataFrameAnalyticsConfig, DataFrameAnalyticsId, + DataFrameAnalysisConfigType, } from '../../../../../../../common/types/data_frame_analytics'; - +import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; export enum DEFAULT_MODEL_MEMORY_LIMIT { regression = '100mb', outlier_detection = '50mb', @@ -28,7 +29,7 @@ export const UNSET_CONFIG_ITEM = '--'; export type EsIndexName = string; export type DependentVariable = string; export type IndexPatternTitle = string; -export type AnalyticsJobType = ANALYSIS_CONFIG_TYPE | undefined; +export type AnalyticsJobType = DataFrameAnalysisConfigType | undefined; type IndexPatternId = string; export type SourceIndexMap = Record< IndexPatternTitle, @@ -290,7 +291,7 @@ export function getFormStateFromJobConfig( analyticsJobConfig: Readonly, isClone: boolean = true ): Partial { - const jobType = Object.keys(analyticsJobConfig.analysis)[0] as ANALYSIS_CONFIG_TYPE; + const jobType = getAnalysisType(analyticsJobConfig.analysis) as DataFrameAnalysisConfigType; const resultState: Partial = { jobType, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index 41f3bab8113f0..14427dd5c6ef2 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -11,7 +11,7 @@ import { GetDataFrameAnalyticsStatsResponseOk, } from '../../../../../services/ml_api_service/data_frame_analytics'; import { - ANALYSIS_CONFIG_TYPE, + getAnalysisType, REFRESH_ANALYTICS_LIST_STATE, refreshAnalyticsList$, } from '../../../../common'; @@ -25,6 +25,7 @@ import { isDataFrameAnalyticsStopped, } from '../../components/analytics_list/common'; import { AnalyticStatsBarStats } from '../../../../../components/stats_bar'; +import { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; export const isGetDataFrameAnalyticsStatsResponseOk = ( arg: any @@ -143,7 +144,7 @@ export const getAnalyticsFactory = ( checkpointing: {}, config, id: config.id, - job_type: Object.keys(config.analysis)[0] as ANALYSIS_CONFIG_TYPE, + job_type: getAnalysisType(config.analysis) as DataFrameAnalysisConfigType, mode: DATA_FRAME_MODE.BATCH, state: stats.state, stats, diff --git a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx index 769b83c03110b..7c30dc0cac690 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/datavisualizer_selector.tsx @@ -52,7 +52,10 @@ function startTrialDescription() { export const DatavisualizerSelector: FC = () => { useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); const { - services: { licenseManagement }, + services: { + licenseManagement, + http: { basePath }, + }, } = useMlKibana(); const navigateToPath = useNavigateToPath(); @@ -183,7 +186,10 @@ export const DatavisualizerSelector: FC = () => { } description={startTrialDescription()} footer={ - + {getCombinedFieldLabel(combinedField)}; +} + +function getCombinedFieldLabel(combinedField: CombinedField) { + return `${combinedField.fieldNames.join(combinedField.delimiter)} => ${ + combinedField.combinedFieldName + } (${combinedField.mappingType})`; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx new file mode 100644 index 0000000000000..fdfe10c2acf02 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_form.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component } from 'react'; + +import { + EuiFormRow, + EuiPopover, + EuiContextMenu, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { CombinedField } from './types'; +import { GeoPointForm } from './geo_point'; +import { CombinedFieldLabel } from './combined_field_label'; +import { + addCombinedFieldsToMappings, + addCombinedFieldsToPipeline, + getNameCollisionMsg, + removeCombinedFieldsFromMappings, + removeCombinedFieldsFromPipeline, +} from './utils'; +import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer'; + +interface Props { + mappingsString: string; + pipelineString: string; + onMappingsStringChange(): void; + onPipelineStringChange(): void; + combinedFields: CombinedField[]; + onCombinedFieldsChange(combinedFields: CombinedField[]): void; + results: FindFileStructureResponse; + isDisabled: boolean; +} + +interface State { + isPopoverOpen: boolean; +} + +export class CombinedFieldsForm extends Component { + state: State = { + isPopoverOpen: false, + }; + + togglePopover = () => { + this.setState((prevState) => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + addCombinedField = (combinedField: CombinedField) => { + if (this.hasNameCollision(combinedField.combinedFieldName)) { + throw new Error(getNameCollisionMsg(combinedField.combinedFieldName)); + } + + const mappings = this.parseMappings(); + const pipeline = this.parsePipeline(); + + this.props.onMappingsStringChange( + // @ts-expect-error + JSON.stringify(addCombinedFieldsToMappings(mappings, [combinedField]), null, 2) + ); + this.props.onPipelineStringChange( + // @ts-expect-error + JSON.stringify(addCombinedFieldsToPipeline(pipeline, [combinedField]), null, 2) + ); + this.props.onCombinedFieldsChange([...this.props.combinedFields, combinedField]); + + this.closePopover(); + }; + + removeCombinedField = (index: number) => { + let mappings; + let pipeline; + try { + mappings = this.parseMappings(); + pipeline = this.parsePipeline(); + } catch (error) { + // how should remove error be surfaced? + return; + } + + const updatedCombinedFields = [...this.props.combinedFields]; + const removedCombinedFields = updatedCombinedFields.splice(index, 1); + + this.props.onMappingsStringChange( + // @ts-expect-error + JSON.stringify(removeCombinedFieldsFromMappings(mappings, removedCombinedFields), null, 2) + ); + this.props.onPipelineStringChange( + // @ts-expect-error + JSON.stringify(removeCombinedFieldsFromPipeline(pipeline, removedCombinedFields), null, 2) + ); + this.props.onCombinedFieldsChange(updatedCombinedFields); + }; + + parseMappings() { + try { + return JSON.parse(this.props.mappingsString); + } catch (error) { + throw new Error( + i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.mappingsParseError', { + defaultMessage: 'Error parsing mappings: {error}', + values: { error: error.message }, + }) + ); + } + } + + parsePipeline() { + try { + return JSON.parse(this.props.pipelineString); + } catch (error) { + throw new Error( + i18n.translate('xpack.ml.fileDatavisualizer.combinedFieldsForm.pipelineParseError', { + defaultMessage: 'Error parsing pipeline: {error}', + values: { error: error.message }, + }) + ); + } + } + + hasNameCollision = (name: string) => { + if (this.props.results.column_names?.includes(name)) { + // collision with column name + return true; + } + + if ( + this.props.combinedFields.some((combinedField) => combinedField.combinedFieldName === name) + ) { + // collision with combined field name + return true; + } + + const mappings = this.parseMappings(); + return mappings.properties.hasOwnProperty(name); + }; + + render() { + const geoPointLabel = i18n.translate('xpack.ml.fileDatavisualizer.geoPointCombinedFieldLabel', { + defaultMessage: 'Add geo point field', + }); + const panels = [ + { + id: 0, + items: [ + { + name: geoPointLabel, + panel: 1, + }, + ], + }, + { + id: 1, + title: geoPointLabel, + content: ( + + ), + }, + ]; + return ( + +
+ {this.props.combinedFields.map((combinedField: CombinedField, idx: number) => ( + + + + + {!this.props.isDisabled && ( + + + + )} + + ))} + + + + } + isOpen={this.state.isPopoverOpen} + closePopover={this.closePopover} + anchorPosition="rightCenter" + > + + +
+
+ ); + } +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx new file mode 100644 index 0000000000000..c37e27e39a7ab --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/combined_fields_read_only_form.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import { EuiFormRow } from '@elastic/eui'; + +import { CombinedField } from './types'; +import { CombinedFieldLabel } from './combined_field_label'; + +export function CombinedFieldsReadOnlyForm({ + combinedFields, +}: { + combinedFields: CombinedField[]; +}) { + return combinedFields.length ? ( + +
+ {combinedFields.map((combinedField: CombinedField, idx: number) => ( + + ))} +
+
+ ) : null; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx new file mode 100644 index 0000000000000..831ae8de8081a --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/geo_point.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import debounce from 'lodash/debounce'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { ChangeEvent, Component, Fragment } from 'react'; + +import { + EuiFormRow, + EuiFieldText, + EuiTextAlign, + EuiSpacer, + EuiButton, + EuiSelect, + EuiSelectOption, + EuiFormErrorText, +} from '@elastic/eui'; + +import { CombinedField } from './types'; +import { + createGeoPointCombinedField, + isWithinLatRange, + isWithinLonRange, + getFieldNames, + getNameCollisionMsg, +} from './utils'; +import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer'; + +interface Props { + addCombinedField: (combinedField: CombinedField) => void; + hasNameCollision: (name: string) => boolean; + results: FindFileStructureResponse; +} + +interface State { + latField: string; + lonField: string; + geoPointField: string; + geoPointFieldError: string; + latFields: EuiSelectOption[]; + lonFields: EuiSelectOption[]; + submitError: string; +} + +export class GeoPointForm extends Component { + constructor(props: Props) { + super(props); + + const latFields: EuiSelectOption[] = [{ value: '', text: '' }]; + const lonFields: EuiSelectOption[] = [{ value: '', text: '' }]; + getFieldNames(props.results).forEach((columnName: string) => { + if (isWithinLatRange(columnName, props.results.field_stats)) { + latFields.push({ value: columnName, text: columnName }); + } + if (isWithinLonRange(columnName, props.results.field_stats)) { + lonFields.push({ value: columnName, text: columnName }); + } + }); + + this.state = { + latField: '', + lonField: '', + geoPointField: '', + geoPointFieldError: '', + submitError: '', + latFields, + lonFields, + }; + } + + onLatFieldChange = (e: ChangeEvent) => { + this.setState({ latField: e.target.value }); + }; + + onLonFieldChange = (e: ChangeEvent) => { + this.setState({ lonField: e.target.value }); + }; + + onGeoPointFieldChange = (e: ChangeEvent) => { + const geoPointField = e.target.value; + this.setState({ geoPointField }); + this.hasNameCollision(geoPointField); + }; + + hasNameCollision = debounce((name: string) => { + try { + const geoPointFieldError = this.props.hasNameCollision(name) ? getNameCollisionMsg(name) : ''; + this.setState({ geoPointFieldError }); + } catch (error) { + this.setState({ submitError: error.message }); + } + }, 200); + + onSubmit = () => { + try { + this.props.addCombinedField( + createGeoPointCombinedField( + this.state.latField, + this.state.lonField, + this.state.geoPointField + ) + ); + this.setState({ submitError: '' }); + } catch (error) { + this.setState({ submitError: error.message }); + } + }; + + render() { + let error; + if (this.state.submitError) { + error = {this.state.submitError}; + } + return ( + + + + + + + + + + + + + + + + {error} + + + + + + + + ); + } +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/index.ts new file mode 100644 index 0000000000000..90b6bbab789f3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + addCombinedFieldsToPipeline, + addCombinedFieldsToMappings, + getDefaultCombinedFields, +} from './utils'; + +export { CombinedFieldsReadOnlyForm } from './combined_fields_read_only_form'; +export { CombinedFieldsForm } from './combined_fields_form'; +export { CombinedField } from './types'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/types.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/types.ts new file mode 100644 index 0000000000000..1ec66f5c96661 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CombinedField { + mappingType: string; + delimiter: string; + combinedFieldName: string; + fieldNames: string[]; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.test.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.test.ts new file mode 100644 index 0000000000000..17b39f9041ec0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + addCombinedFieldsToMappings, + addCombinedFieldsToPipeline, + createGeoPointCombinedField, + isWithinLatRange, + isWithinLonRange, + removeCombinedFieldsFromMappings, + removeCombinedFieldsFromPipeline, +} from './utils'; + +const combinedFields = [createGeoPointCombinedField('lat', 'lon', 'location')]; + +test('addCombinedFieldsToMappings', () => { + const mappings = { + _meta: { + created_by: '', + }, + properties: { + lat: { + type: 'number', + }, + lon: { + type: 'number', + }, + }, + }; + expect(addCombinedFieldsToMappings(mappings, combinedFields)).toEqual({ + _meta: { + created_by: '', + }, + properties: { + lat: { + type: 'number', + }, + lon: { + type: 'number', + }, + location: { + type: 'geo_point', + }, + }, + }); +}); + +test('removeCombinedFieldsFromMappings', () => { + const mappings = { + _meta: { + created_by: '', + }, + properties: { + lat: { + type: 'number', + }, + lon: { + type: 'number', + }, + location: { + type: 'geo_point', + }, + }, + }; + expect(removeCombinedFieldsFromMappings(mappings, combinedFields)).toEqual({ + _meta: { + created_by: '', + }, + properties: { + lat: { + type: 'number', + }, + lon: { + type: 'number', + }, + }, + }); +}); + +test('addCombinedFieldsToPipeline', () => { + const pipeline = { + description: '', + processors: [ + { + set: { + field: 'anotherfield', + value: '{{value}}', + }, + }, + ], + }; + expect(addCombinedFieldsToPipeline(pipeline, combinedFields)).toEqual({ + description: '', + processors: [ + { + set: { + field: 'anotherfield', + value: '{{value}}', + }, + }, + { + set: { + field: 'location', + value: '{{lat}},{{lon}}', + }, + }, + ], + }); +}); + +test('removeCombinedFieldsFromPipeline', () => { + const pipeline = { + description: '', + processors: [ + { + set: { + field: 'anotherfield', + value: '{{value}}', + }, + }, + { + set: { + field: 'location', + value: '{{lat}},{{lon}}', + }, + }, + ], + }; + expect(removeCombinedFieldsFromPipeline(pipeline, combinedFields)).toEqual({ + description: '', + processors: [ + { + set: { + field: 'anotherfield', + value: '{{value}}', + }, + }, + ], + }); +}); + +test('isWithinLatRange', () => { + expect(isWithinLatRange('fieldAlpha', {})).toBe(false); + expect( + isWithinLatRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 1 }], + }, + }) + ).toBe(false); + expect( + isWithinLatRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 100 }], + max_value: 100, + min_value: 0, + }, + }) + ).toBe(false); + expect( + isWithinLatRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: -100 }], + max_value: 0, + min_value: -100, + }, + }) + ).toBe(false); + expect( + isWithinLatRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 0 }], + max_value: 0, + min_value: 0, + }, + }) + ).toBe(true); +}); + +test('isWithinLonRange', () => { + expect(isWithinLonRange('fieldAlpha', {})).toBe(false); + expect( + isWithinLonRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 1 }], + }, + }) + ).toBe(false); + expect( + isWithinLonRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 200 }], + max_value: 200, + min_value: 0, + }, + }) + ).toBe(false); + expect( + isWithinLonRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: -200 }], + max_value: 0, + min_value: -200, + }, + }) + ).toBe(false); + expect( + isWithinLonRange('fieldAlpha', { + fieldAlpha: { + count: 1, + cardinality: 1, + top_hits: [{ count: 1, value: 0 }], + max_value: 0, + min_value: 0, + }, + }) + ).toBe(true); +}); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts new file mode 100644 index 0000000000000..5e7de14f451c2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/combined_fields/utils.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import uuid from 'uuid/v4'; +import { CombinedField } from './types'; +import { + FindFileStructureResponse, + IngestPipeline, + Mappings, +} from '../../../../../../common/types/file_datavisualizer'; + +const COMMON_LAT_NAMES = ['latitude', 'lat']; +const COMMON_LON_NAMES = ['longitude', 'long', 'lon']; + +export function getDefaultCombinedFields(results: FindFileStructureResponse) { + const combinedFields: CombinedField[] = []; + const geoPointField = getGeoPointField(results); + if (geoPointField) { + combinedFields.push(geoPointField); + } + return combinedFields; +} + +export function addCombinedFieldsToMappings( + mappings: Mappings, + combinedFields: CombinedField[] +): Mappings { + const updatedMappings = { ...mappings }; + combinedFields.forEach((combinedField) => { + updatedMappings.properties[combinedField.combinedFieldName] = { + type: combinedField.mappingType, + }; + }); + return updatedMappings; +} + +export function removeCombinedFieldsFromMappings( + mappings: Mappings, + combinedFields: CombinedField[] +) { + const updatedMappings = { ...mappings }; + combinedFields.forEach((combinedField) => { + delete updatedMappings.properties[combinedField.combinedFieldName]; + }); + return updatedMappings; +} + +export function addCombinedFieldsToPipeline( + pipeline: IngestPipeline, + combinedFields: CombinedField[] +) { + const updatedPipeline = _.cloneDeep(pipeline); + combinedFields.forEach((combinedField) => { + updatedPipeline.processors.push({ + set: { + field: combinedField.combinedFieldName, + value: combinedField.fieldNames + .map((fieldName) => { + return `{{${fieldName}}}`; + }) + .join(combinedField.delimiter), + }, + }); + }); + return updatedPipeline; +} + +export function removeCombinedFieldsFromPipeline( + pipeline: IngestPipeline, + combinedFields: CombinedField[] +) { + return { + ...pipeline, + processors: pipeline.processors.filter((processor) => { + return 'set' in processor + ? !combinedFields.some((combinedField) => { + return processor.set.field === combinedField.combinedFieldName; + }) + : true; + }), + }; +} + +export function isWithinLatRange( + fieldName: string, + fieldStats: FindFileStructureResponse['field_stats'] +) { + return ( + fieldName in fieldStats && + 'max_value' in fieldStats[fieldName] && + fieldStats[fieldName]!.max_value! <= 90 && + 'min_value' in fieldStats[fieldName] && + fieldStats[fieldName]!.min_value! >= -90 + ); +} + +export function isWithinLonRange( + fieldName: string, + fieldStats: FindFileStructureResponse['field_stats'] +) { + return ( + fieldName in fieldStats && + 'max_value' in fieldStats[fieldName] && + fieldStats[fieldName]!.max_value! <= 180 && + 'min_value' in fieldStats[fieldName] && + fieldStats[fieldName]!.min_value! >= -180 + ); +} + +export function createGeoPointCombinedField( + latField: string, + lonField: string, + geoPointField: string +): CombinedField { + return { + mappingType: 'geo_point', + delimiter: ',', + combinedFieldName: geoPointField, + fieldNames: [latField, lonField], + }; +} + +export function getNameCollisionMsg(name: string) { + return i18n.translate('xpack.ml.fileDatavisualizer.nameCollisionMsg', { + defaultMessage: '"{name}" already exists, please provide a unique name', + values: { name }, + }); +} + +export function getFieldNames(results: FindFileStructureResponse): string[] { + return results.column_names !== undefined + ? results.column_names + : Object.keys(results.field_stats); +} + +function getGeoPointField(results: FindFileStructureResponse) { + const fieldNames = getFieldNames(results); + + const latField = fieldNames.find((columnName) => { + return ( + COMMON_LAT_NAMES.includes(columnName.toLowerCase()) && + isWithinLatRange(columnName, results.field_stats) + ); + }); + + const lonField = fieldNames.find((columnName) => { + return ( + COMMON_LON_NAMES.includes(columnName.toLowerCase()) && + isWithinLonRange(columnName, results.field_stats) + ); + }); + + if (!latField || !lonField) { + return null; + } + + const combinedFieldNames = [ + 'location', + 'point_location', + `${latField}_${lonField}`, + `location_${uuid()}`, + ]; + // Use first combinedFieldNames that does not have a naming collision + const geoPointField = combinedFieldNames.find((name) => { + return !fieldNames.includes(name); + }); + + return geoPointField ? createGeoPointCombinedField(latField, lonField, geoPointField) : null; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx index a79a7d36f3294..2b49746170f46 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/advanced.tsx @@ -17,7 +17,9 @@ import { EuiFlexItem, } from '@elastic/eui'; +import { CombinedField, CombinedFieldsForm } from '../combined_fields'; import { MLJobEditor, ML_EDITOR_MODE } from '../../../../jobs/jobs_list/components/ml_job_editor'; +import { FindFileStructureResponse } from '../../../../../../common/types/file_datavisualizer'; const EDITOR_HEIGHT = '300px'; interface Props { @@ -36,6 +38,9 @@ interface Props { onPipelineStringChange(): void; indexNameError: string; indexPatternNameError: string; + combinedFields: CombinedField[]; + onCombinedFieldsChange(combinedFields: CombinedField[]): void; + results: FindFileStructureResponse; } export const AdvancedSettings: FC = ({ @@ -54,6 +59,9 @@ export const AdvancedSettings: FC = ({ onPipelineStringChange, indexNameError, indexPatternNameError, + combinedFields, + onCombinedFieldsChange, + results, }) => { return ( @@ -123,6 +131,17 @@ export const AdvancedSettings: FC = ({ />
+ + = ({ @@ -46,6 +51,9 @@ export const ImportSettings: FC = ({ onPipelineStringChange, indexNameError, indexPatternNameError, + combinedFields, + onCombinedFieldsChange, + results, }) => { const tabs = [ { @@ -64,6 +72,7 @@ export const ImportSettings: FC = ({ createIndexPattern={createIndexPattern} onCreateIndexPatternChange={onCreateIndexPatternChange} indexNameError={indexNameError} + combinedFields={combinedFields} /> ), @@ -93,6 +102,9 @@ export const ImportSettings: FC = ({ onPipelineStringChange={onPipelineStringChange} indexNameError={indexNameError} indexPatternNameError={indexPatternNameError} + combinedFields={combinedFields} + onCombinedFieldsChange={onCombinedFieldsChange} + results={results} /> ), diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx index 1e716824729e3..f6cd5909cbb80 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_settings/simple.tsx @@ -9,6 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC } from 'react'; import { EuiFieldText, EuiFormRow, EuiCheckbox, EuiSpacer } from '@elastic/eui'; +import { CombinedField, CombinedFieldsReadOnlyForm } from '../combined_fields'; interface Props { index: string; @@ -17,6 +18,7 @@ interface Props { createIndexPattern: boolean; onCreateIndexPatternChange(): void; indexNameError: string; + combinedFields: CombinedField[]; } export const SimpleSettings: FC = ({ @@ -26,6 +28,7 @@ export const SimpleSettings: FC = ({ createIndexPattern, onCreateIndexPatternChange, indexNameError, + combinedFields, }) => { return ( @@ -75,6 +78,10 @@ export const SimpleSettings: FC = ({ onChange={onCreateIndexPatternChange} data-test-subj="mlFileDataVisCreateIndexPatternCheckbox" /> + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index 36b77a5a25e09..08b61a5fa4eed 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -26,6 +26,11 @@ import { ImportProgress, IMPORT_STATUS } from '../import_progress'; import { ImportErrors } from '../import_errors'; import { ImportSummary } from '../import_summary'; import { ImportSettings } from '../import_settings'; +import { + addCombinedFieldsToPipeline, + addCombinedFieldsToMappings, + getDefaultCombinedFields, +} from '../combined_fields'; import { ExperimentalBadge } from '../experimental_badge'; import { getIndexPatternNames, loadIndexPatterns } from '../../../../util/index_utils'; import { ml } from '../../../../services/ml_api_service'; @@ -68,6 +73,7 @@ const DEFAULT_STATE = { timeFieldName: undefined, isFilebeatFlyoutVisible: false, checkingValidIndex: false, + combinedFields: [], }; export class ImportView extends Component { @@ -386,6 +392,10 @@ export class ImportView extends Component { }); }; + onCombinedFieldsChange = (combinedFields) => { + this.setState({ combinedFields }); + }; + setImportProgress = (progress) => { this.setState({ uploadProgress: progress, @@ -444,6 +454,7 @@ export class ImportView extends Component { timeFieldName, isFilebeatFlyoutVisible, checkingValidIndex, + combinedFields, } = this.state; const createPipeline = pipelineString !== ''; @@ -513,6 +524,9 @@ export class ImportView extends Component { onPipelineStringChange={this.onPipelineStringChange} indexNameError={indexNameError} indexPatternNameError={indexPatternNameError} + combinedFields={combinedFields} + onCombinedFieldsChange={this.onCombinedFieldsChange} + results={this.props.results} /> @@ -644,12 +658,22 @@ function getDefaultState(state, results) { ? JSON.stringify(DEFAULT_INDEX_SETTINGS, null, 2) : state.indexSettingsString; + const combinedFields = state.combinedFields.length + ? state.combinedFields + : getDefaultCombinedFields(results); + const mappingsString = - state.mappingsString === '' ? JSON.stringify(results.mappings, null, 2) : state.mappingsString; + state.mappingsString === '' + ? JSON.stringify(addCombinedFieldsToMappings(results.mappings, combinedFields), null, 2) + : state.mappingsString; const pipelineString = state.pipelineString === '' && results.ingest_pipeline !== undefined - ? JSON.stringify(results.ingest_pipeline, null, 2) + ? JSON.stringify( + addCombinedFieldsToPipeline(results.ingest_pipeline, combinedFields), + null, + 2 + ) : state.pipelineString; const timeFieldName = results.timestamp_field; @@ -660,6 +684,7 @@ function getDefaultState(state, results) { mappingsString, pipelineString, timeFieldName, + combinedFields, }; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx index efade08720cc2..5cba714afe47b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState, useEffect, useCallback } from 'react'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon } from '@elastic/eui'; @@ -12,7 +12,13 @@ import { ml } from '../../../../services/ml_api_service'; import { isFullLicense } from '../../../../license'; import { checkPermission } from '../../../../capabilities/check_capabilities'; import { mlNodesAvailable } from '../../../../ml_nodes_check/check_ml_nodes'; -import { useMlKibana } from '../../../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator, useNavigateToPath } from '../../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { MlCommonGlobalState } from '../../../../../../common/types/ml_url_generator'; +import { + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGeneratorState, +} from '../../../../../../../../../src/plugins/discover/public'; const RECHECK_DELAY_MS = 3000; @@ -36,12 +42,70 @@ export const ResultsLinks: FC = ({ to: 'now', }); const [showCreateJobLink, setShowCreateJobLink] = useState(false); - const [globalStateString, setGlobalStateString] = useState(''); + const [globalState, setGlobalState] = useState(); + + const [discoverLink, setDiscoverLink] = useState(''); const { services: { http: { basePath }, }, } = useMlKibana(); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const { + services: { + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = useMlKibana(); + + useEffect(() => { + let unmounted = false; + + const getDiscoverUrl = async (): Promise => { + const state: DiscoverUrlGeneratorState = { + indexPatternId, + }; + + if (globalState?.time) { + state.timeRange = globalState.time; + } + if (!unmounted) { + const discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); + const discoverUrl = await discoverUrlGenerator.createUrl(state); + setDiscoverLink(discoverUrl); + } + }; + getDiscoverUrl(); + + return () => { + unmounted = true; + }; + }, [indexPatternId, getUrlGenerator]); + + const openInDataVisualizer = useCallback(async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, + pageState: { + index: indexPatternId, + globalState, + }, + }); + await navigateToPath(path); + }, [indexPatternId, globalState]); + + const redirectToADCreateJobsSelectTypePage = useCallback(async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, + pageState: { + index: indexPatternId, + globalState, + }, + }); + await navigateToPath(path); + }, [indexPatternId, globalState]); useEffect(() => { setShowCreateJobLink(checkPermission('canCreateJob') && mlNodesAvailable()); @@ -49,11 +113,13 @@ export const ResultsLinks: FC = ({ }, []); useEffect(() => { - const _g = - timeFieldName !== undefined - ? `&_g=(time:(from:'${duration.from}',mode:quick,to:'${duration.to}'))` - : ''; - setGlobalStateString(_g); + const _globalState: MlCommonGlobalState = { + time: { + from: duration.from, + to: duration.to, + }, + }; + setGlobalState(_globalState); }, [duration]); async function updateTimeValues(recheck = true) { @@ -89,7 +155,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`${basePath.get()}/app/discover#/?&_a=(index:'${indexPatternId}')${globalStateString}`} + href={discoverLink} /> )} @@ -108,7 +174,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`#/jobs/new_job/step/job_type?index=${indexPatternId}${globalStateString}`} + onClick={redirectToADCreateJobsSelectTypePage} /> )} @@ -124,7 +190,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`#/jobs/new_job/datavisualizer?index=${indexPatternId}${globalStateString}`} + onClick={openInDataVisualizer} /> )} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx index 1f2c97b128e3f..ab738ca0f1545 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/actions_panel/actions_panel.tsx @@ -9,11 +9,11 @@ import React, { FC, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiTitle, EuiFlexGroup } from '@elastic/eui'; - +import { Link } from 'react-router-dom'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; -import { getBasePath } from '../../../../util/dependency_cache'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; interface Props { indexPattern: IndexPattern; @@ -21,7 +21,6 @@ interface Props { export const ActionsPanel: FC = ({ indexPattern }) => { const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); - const basePath = getBasePath(); const recognizerResults = { count: 0, @@ -29,12 +28,7 @@ export const ActionsPanel: FC = ({ indexPattern }) => { setRecognizerResultsCount(recognizerResults.count); }, }; - - function openAdvancedJobWizard() { - // TODO - pass the search string to the advanced job page as well as the index pattern - // (add in with new advanced job wizard?) - window.open(`${basePath.get()}/app/ml/jobs/new_job/advanced?index=${indexPattern.id}`, '_self'); - } + const createJobLink = `/${ML_PAGES.ANOMALY_DETECTION_CREATE_JOB}/advanced?index=${indexPattern.id}`; // Note we use display:none for the DataRecognizer section as it needs to be // passed the recognizerResults object, and then run the recognizer check which @@ -78,19 +72,19 @@ export const ActionsPanel: FC = ({ indexPattern }) => {

- + + +
); }; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap index c6503a639997d..826f7b707cfdf 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/__snapshots__/explorer_no_jobs_found.test.js.snap @@ -3,17 +3,20 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` - - + + + + } data-test-subj="mlNoJobsFound" iconType="alert" diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js index 6f391f9746f23..029ca0475015f 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.js @@ -7,25 +7,40 @@ /* * React component for rendering EuiEmptyPrompt when no jobs were found. */ - +import { Link } from 'react-router-dom'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useMlLink } from '../../../contexts/kibana/use_create_url'; -export const ExplorerNoJobsFound = () => ( - - - - } - actions={ - - - - } - data-test-subj="mlNoJobsFound" - /> -); +export const ExplorerNoJobsFound = () => { + const ADJobsManagementUrl = useMlLink({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + excludeBasePath: true, + }); + return ( + + + + } + actions={ + + + + + + } + data-test-subj="mlNoJobsFound" + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js index bcb11cad9674c..c9645b787a8e0 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_jobs_found/explorer_no_jobs_found.test.js @@ -8,6 +8,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { ExplorerNoJobsFound } from './explorer_no_jobs_found'; +jest.mock('../../../contexts/kibana/use_create_url', () => ({ + useMlLink: jest.fn().mockReturnValue('/jobs'), +})); describe('ExplorerNoInfluencersFound', () => { test('snapshot', () => { const wrapper = shallow(); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 4fb783bfb6006..8f03b1903800a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { EuiButtonEmpty, @@ -28,6 +28,10 @@ import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; +import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../common/constants/app'; +import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -51,7 +55,23 @@ function getChartId(series) { } // Wrapper for a single explorer chart -function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) { +function ExplorerChartContainer({ + series, + severity, + tooManyBuckets, + wrapLabel, + navigateToApp, + mlUrlGenerator, +}) { + const redirectToSingleMetricViewer = useCallback(async () => { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); + addItemToRecentlyAccessed('timeseriesexplorer', series.jobId, singleMetricViewerLink); + + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerLink, + }); + }, [mlUrlGenerator]); + const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -106,7 +126,7 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) iconSide="right" iconType="visLine" size="xs" - onClick={() => window.open(getExploreSeriesLink(series), '_blank')} + onClick={redirectToSingleMetricViewer} > @@ -150,12 +170,24 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export const ExplorerChartsContainer = ({ +export const ExplorerChartsContainerUI = ({ chartsPerRow, seriesToPlot, severity, tooManyBuckets, + kibana, }) => { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = kibana; + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; @@ -177,9 +209,13 @@ export const ExplorerChartsContainer = ({ severity={severity} tooManyBuckets={tooManyBuckets} wrapLabel={wrapLabel} + navigateToApp={navigateToApp} + mlUrlGenerator={mlUrlGenerator} /> ))} ); }; + +export const ExplorerChartsContainer = withKibana(ExplorerChartsContainerUI); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index 8257ac2b3a703..2da212c8f2f29 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -40,6 +40,12 @@ jest.mock('../../services/job_service', () => ({ }, })); +jest.mock('../../../../../../../src/plugins/kibana_react/public', () => ({ + withKibana: (comp) => { + return comp; + }, +})); + describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; const originalGetBBox = SVGElement.prototype.getBBox; @@ -47,10 +53,22 @@ describe('ExplorerChartsContainer', () => { beforeEach(() => (SVGElement.prototype.getBBox = () => mockedGetBBox)); afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); + const kibanaContextMock = { + services: { + application: { navigateToApp: jest.fn() }, + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, + }, + }; test('Minimal Initialization', () => { const wrapper = shallow( - + ); @@ -71,10 +89,11 @@ describe('ExplorerChartsContainer', () => { ], chartsPerRow: 1, tooManyBuckets: false, + severity: 10, }; const wrapper = mount( - + ); @@ -98,10 +117,11 @@ describe('ExplorerChartsContainer', () => { ], chartsPerRow: 1, tooManyBuckets: false, + severity: 10, }; const wrapper = mount( - + ); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index d0d0442dd4aee..85a342838a506 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -5,13 +5,20 @@ */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useCreateADLinks } from '../../../../components/custom_hooks/use_create_ad_links'; +import { Link } from 'react-router-dom'; +import { useMlKibana } from '../../../../contexts/kibana'; -export function ResultLinks({ jobs }) { +export function ResultLinks({ jobs, isManagementTable }) { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); const openJobsInSingleMetricViewerText = i18n.translate( 'xpack.ml.jobsList.resultActions.openJobsInSingleMetricViewerText', { @@ -37,29 +44,59 @@ export function ResultLinks({ jobs }) { const singleMetricEnabled = jobs.length === 1 && jobs[0].isSingleMetricViewerJob; const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; const { createLinkWithUserDefaults } = useCreateADLinks(); + const timeSeriesExplorerLink = useMemo( + () => createLinkWithUserDefaults('timeseriesexplorer', jobs), + [jobs] + ); + const anomalyExplorerLink = useMemo(() => createLinkWithUserDefaults('explorer', jobs), [jobs]); + return ( {singleMetricVisible && ( + {isManagementTable ? ( + + ) : ( + + + + )} + + )} + + {isManagementTable ? ( - - )} - - + ) : ( + + + + )}
diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index 8f89c4a049189..73b212b97b4cc 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -5,10 +5,10 @@ */ import React from 'react'; -import { EuiLink } from '@elastic/eui'; import { detectorToString } from '../../../../util/string_utils'; import { formatValues, filterObjects } from './format_values'; import { i18n } from '@kbn/i18n'; +import { Link } from 'react-router-dom'; export function extractJobDetails(job) { if (Object.keys(job).length === 0) { @@ -61,7 +61,7 @@ export function extractJobDetails(job) { if (job.calendars) { calendars.items = job.calendars.map((c) => [ '', - {c}, + {c}, ]); // remove the calendars list from the general section // so not to show it twice. diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js index b6157c8694a18..b32070fff73aa 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/forecasts_table/forecasts_table.js @@ -5,8 +5,6 @@ */ import PropTypes from 'prop-types'; -import rison from 'rison-node'; - import React, { Component } from 'react'; import { @@ -30,13 +28,19 @@ import { getLatestDataOrBucketTimestamp, isTimeSeriesViewJob, } from '../../../../../../../common/util/job_utils'; +import { withKibana } from '../../../../../../../../../../src/plugins/kibana_react/public'; +import { + ML_APP_URL_GENERATOR, + ML_PAGES, +} from '../../../../../../../common/constants/ml_url_generator'; +import { PLUGIN_ID } from '../../../../../../../common/constants/app'; const MAX_FORECASTS = 500; /** * Table component for rendering the lists of forecasts run on an ML job. */ -export class ForecastsTable extends Component { +export class ForecastsTableUI extends Component { constructor(props) { super(props); this.state = { @@ -78,7 +82,17 @@ export class ForecastsTable extends Component { } } - openSingleMetricView(forecast) { + async openSingleMetricView(forecast) { + const { + services: { + application: { navigateToApp }, + + share: { + urlGenerators: { getUrlGenerator }, + }, + }, + } = this.props.kibana; + // Creates the link to the Single Metric Viewer. // Set the total time range from the start of the job data to the end of the forecast, const dataCounts = this.props.job.data_counts; @@ -93,31 +107,7 @@ export class ForecastsTable extends Component { ? new Date(forecast.forecast_end_timestamp).toISOString() : new Date(resultLatest).toISOString(); - const _g = rison.encode({ - ml: { - jobIds: [this.props.job.job_id], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from, - to, - mode: 'absolute', - }, - }); - - const appState = { - query: { - query_string: { - analyze_wildcard: true, - query: '*', - }, - }, - }; - + let mlTimeSeriesExplorer = {}; if (forecast !== undefined) { // Set the zoom to show duration before the forecast equal to the length of the forecast. const forecastDurationMs = @@ -126,8 +116,7 @@ export class ForecastsTable extends Component { forecast.forecast_start_timestamp - forecastDurationMs, jobEarliest ); - - appState.mlTimeSeriesExplorer = { + mlTimeSeriesExplorer = { forecastId: forecast.forecast_id, zoom: { from: new Date(zoomFrom).toISOString(), @@ -136,11 +125,39 @@ export class ForecastsTable extends Component { }; } - const _a = rison.encode(appState); - - const url = `?_g=${_g}&_a=${_a}`; - addItemToRecentlyAccessed('timeseriesexplorer', this.props.job.job_id, url); - window.open(`#/timeseriesexplorer${url}`, '_self'); + const mlUrlGenerator = getUrlGenerator(ML_APP_URL_GENERATOR); + const singleMetricViewerForecastLink = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + timeRange: { + from, + to, + mode: 'absolute', + }, + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + jobIds: [this.props.job.job_id], + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, + }, + ...mlTimeSeriesExplorer, + }, + excludeBasePath: true, + }); + addItemToRecentlyAccessed( + 'timeseriesexplorer', + this.props.job.job_id, + singleMetricViewerForecastLink + ); + await navigateToApp(PLUGIN_ID, { + path: singleMetricViewerForecastLink, + }); } render() { @@ -322,6 +339,8 @@ export class ForecastsTable extends Component { ); } } -ForecastsTable.propTypes = { +ForecastsTableUI.propTypes = { job: PropTypes.object.isRequired, }; + +export const ForecastsTable = withKibana(ForecastsTableUI); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js index a5469357ba1a1..8b5d6009cc61e 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_description.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { JobGroup } from '../job_group'; -import { getGroupIdsUrl, TAB_IDS } from '../../../../util/get_selected_ids_url'; +import { AnomalyDetectionJobIdLink } from './job_id_link'; export function JobDescription({ job, isManagementTable }) { return ( @@ -17,11 +17,7 @@ export function JobDescription({ job, isManagementTable }) { {job.description}   {job.groups.map((group) => { if (isManagementTable === true) { - return ( - - - - ); + return ; } return ; })} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx new file mode 100644 index 0000000000000..0e84619899d71 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/job_id_link.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { useMlKibana, useMlUrlGenerator } from '../../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { AnomalyDetectionQueryState } from '../../../../../../common/types/ml_url_generator'; +// @ts-ignore +import { JobGroup } from '../job_group'; + +interface JobIdLink { + id: string; +} + +interface GroupIdLink { + groupId: string; + children: string; +} + +type AnomalyDetectionJobIdLinkProps = JobIdLink | GroupIdLink; + +function isGroupIdLink(props: JobIdLink | GroupIdLink): props is GroupIdLink { + return (props as GroupIdLink).groupId !== undefined; +} +export const AnomalyDetectionJobIdLink = (props: AnomalyDetectionJobIdLinkProps) => { + const mlUrlGenerator = useMlUrlGenerator(); + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToJobsManagementPage = async () => { + const pageState: AnomalyDetectionQueryState = {}; + if (isGroupIdLink(props)) { + pageState.groupIds = [props.groupId]; + } else { + pageState.jobId = props.id; + } + const url = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + pageState, + }); + await navigateToUrl(url); + }; + if (isGroupIdLink(props)) { + return ( + redirectToJobsManagementPage()}> + + + ); + } else { + return ( + redirectToJobsManagementPage()}> + {props.id} + + ); + } +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index fa4ea09b89ff9..8bc0057b27d6d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -14,12 +14,12 @@ import { toLocaleString } from '../../../../util/string_utils'; import { ResultLinks, actionsMenuContent } from '../job_actions'; import { JobDescription } from './job_description'; import { JobIcon } from '../../../../components/job_message_icon'; -import { getJobIdUrl, TAB_IDS } from '../../../../util/get_selected_ids_url'; import { TIME_FORMAT } from '../../../../../../common/constants/time_format'; -import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiLink, EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiBadge, EuiBasicTable, EuiButtonIcon, EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { AnomalyDetectionJobIdLink } from './job_id_link'; const PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [10, 25, 50]; @@ -71,7 +71,7 @@ export class JobsList extends Component { return id; } - return {id}; + return ; } getPageOfJobs(index, size, sortField, sortDirection) { @@ -241,7 +241,7 @@ export class JobsList extends Component { name: i18n.translate('xpack.ml.jobsList.actionsLabel', { defaultMessage: 'Actions', }), - render: (item) => , + render: (item) => , }, ]; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js index fdffa8b38ae04..81effe8d3ebeb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/new_job_button/new_job_button.js @@ -11,13 +11,13 @@ import React from 'react'; import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -function newJob() { - window.location.href = `#/jobs/new_job`; -} +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; export function NewJobButton() { const buttonEnabled = checkPermission('canCreateJob') && mlNodesAvailable(); + const newJob = useCreateAndNavigateToMlLink(ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX); + return ( { const { @@ -73,7 +74,7 @@ export const CalendarsSelection: FC = () => { }; const manageCalendarsHref = getUrlForApp(PLUGIN_ID, { - path: '/settings/calendars_list', + path: ML_PAGES.CALENDARS_MANAGE, }); return ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts new file mode 100644 index 0000000000000..a62e378222ff9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { StartDatafeedSwitch } from './start_datafeed_switch'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx new file mode 100644 index 0000000000000..4aa78cfc41009 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/start_datafeed_switch/start_datafeed_switch.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch, EuiFormRow, EuiSpacer } from '@elastic/eui'; +interface Props { + startDatafeed: boolean; + setStartDatafeed(start: boolean): void; + disabled?: boolean; +} + +export const StartDatafeedSwitch: FC = ({ + startDatafeed, + setStartDatafeed, + disabled = false, +}) => { + return ( + <> + + + setStartDatafeed(e.target.checked)} + disabled={disabled} + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx index 3000ce8449138..021039c06e320 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/summary.tsx @@ -28,6 +28,7 @@ import { DatafeedDetails } from './components/datafeed_details'; import { DetectorChart } from './components/detector_chart'; import { JobProgress } from './components/job_progress'; import { PostSaveOptions } from './components/post_save_options'; +import { StartDatafeedSwitch } from './components/start_datafeed_switch'; import { toastNotificationServiceProvider } from '../../../../../services/toast_notification_service'; import { convertToAdvancedJob, @@ -38,7 +39,10 @@ import { JobSectionTitle, DatafeedSectionTitle } from './components/common'; export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => { const { - services: { notifications }, + services: { + notifications, + http: { basePath }, + }, } = useMlKibana(); const navigateToPath = useNavigateToPath(); @@ -50,6 +54,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => const [creatingJob, setCreatingJob] = useState(false); const [isValid, setIsValid] = useState(jobValidator.validationSummary.basic); const [jobRunner, setJobRunner] = useState(null); + const [startDatafeed, setStartDatafeed] = useState(true); const isAdvanced = isAdvancedJobCreator(jobCreator); const jsonEditorMode = isAdvanced ? EDITOR_MODE.EDITABLE : EDITOR_MODE.READONLY; @@ -59,15 +64,17 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => }, []); async function start() { + setCreatingJob(true); if (isAdvanced) { - await startAdvanced(); + await createAdvancedJob(); + } else if (startDatafeed === true) { + await createAndStartJob(); } else { - await startInline(); + await createAdvancedJob(false); } } - async function startInline() { - setCreatingJob(true); + async function createAndStartJob() { try { const jr = await jobCreator.createAndStartJob(); setJobRunner(jr); @@ -76,12 +83,11 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => } } - async function startAdvanced() { - setCreatingJob(true); + async function createAdvancedJob(showStartModal: boolean = true) { try { await jobCreator.createJob(); await jobCreator.createDatafeed(); - advancedStartDatafeed(jobCreator, navigateToPath); + advancedStartDatafeed(showStartModal ? jobCreator : null, navigateToPath); } catch (error) { handleJobCreationError(error); } @@ -105,7 +111,7 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => jobCreator.end, isSingleMetricJobCreator(jobCreator) === true ? 'timeseriesexplorer' : 'explorer' ); - window.open(url, '_blank'); + navigateToPath(`${basePath.get()}/app/ml/${url}`); } function clickResetJob() { @@ -131,6 +137,14 @@ export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => + {isAdvanced === false && ( + + )} + {isAdvanced && ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts index 69df2773f9f8d..cedaaa3b5dfaa 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/preconfigured_job_redirect.ts @@ -4,19 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ApplicationStart } from 'kibana/public'; import { IndexPatternsContract } from '../../../../../../../../../src/plugins/data/public'; import { mlJobService } from '../../../../services/job_service'; import { loadIndexPatterns, getIndexPatternIdFromName } from '../../../../util/index_utils'; import { CombinedJob } from '../../../../../../common/types/anomaly_detection_jobs'; import { CREATED_BY_LABEL, JOB_TYPE } from '../../../../../../common/constants/new_job'; -export async function preConfiguredJobRedirect(indexPatterns: IndexPatternsContract) { +export async function preConfiguredJobRedirect( + indexPatterns: IndexPatternsContract, + basePath: string, + navigateToUrl: ApplicationStart['navigateToUrl'] +) { const { job } = mlJobService.tempJobCloningObjects; if (job) { try { await loadIndexPatterns(indexPatterns); const redirectUrl = getWizardUrlFromCloningJob(job); - window.location.href = `#/${redirectUrl}`; + await navigateToUrl(`${basePath}/app/ml/${redirectUrl}`); return Promise.reject(); } catch (error) { return Promise.resolve(); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx index be0135ec3f1e0..1a91f6d51ed4d 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/job_type/page.tsx @@ -19,6 +19,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useNavigateToPath } from '../../../../contexts/kibana'; + import { useMlContext } from '../../../../contexts/ml'; import { isSavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { DataRecognizer } from '../../../../components/data_recognizer'; @@ -26,10 +27,15 @@ import { addItemToRecentlyAccessed } from '../../../../util/recently_accessed'; import { timeBasedIndexCheck } from '../../../../util/index_utils'; import { CreateJobLinkCard } from '../../../../components/create_job_link_card'; import { CategorizationIcon } from './categorization_job_icon'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; export const Page: FC = () => { const mlContext = useMlContext(); const navigateToPath = useNavigateToPath(); + const onSelectDifferentIndex = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX + ); const [recognizerResultsCount, setRecognizerResultsCount] = useState(0); @@ -193,7 +199,7 @@ export const Page: FC = () => { defaultMessage="Anomaly detection can only be run over indices which are time based." />
- + = ({ moduleId, existingGroupIds }) => { const { services: { notifications }, } = useMlKibana(); + const urlGenerator = useMlUrlGenerator(); + // #region State const [jobPrefix, setJobPrefix] = useState(''); const [jobs, setJobs] = useState([]); @@ -185,14 +189,20 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { }) ); setKibanaObjects(merge(kibanaObjects, kibanaResponse)); - setResultsUrl( - mlJobService.createResultsUrl( - jobsResponse.filter(({ success }) => success).map(({ id }) => id), - resultTimeRange.start, - resultTimeRange.end, - 'explorer' - ) - ); + + const url = await urlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_EXPLORER, + pageState: { + jobIds: jobsResponse.filter(({ success }) => success).map(({ id }) => id), + timeRange: { + from: moment(resultTimeRange.start).format(TIME_FORMAT), + to: moment(resultTimeRange.end).format(TIME_FORMAT), + mode: 'absolute', + }, + }, + }); + + setResultsUrl(url); const failedJobsCount = jobsResponse.reduce((count, { success }) => { return success ? count : count + 1; }, 0); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts index e3b0fd4cefe0c..97a03fa21035f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/resolvers.ts @@ -6,33 +6,40 @@ import { i18n } from '@kbn/i18n'; import { getToastNotifications, getSavedObjectsClient } from '../../../util/dependency_cache'; -import { mlJobService } from '../../../services/job_service'; import { ml } from '../../../services/ml_api_service'; import { KibanaObjects } from './page'; +import { NavigateToPath } from '../../../contexts/kibana'; +import { CreateLinkWithUserDefaults } from '../../../components/custom_hooks/use_create_ad_links'; /** * Checks whether the jobs in a data recognizer module have been created. * Redirects to the Anomaly Explorer to view the jobs if they have been created, * or the recognizer job wizard for the module if not. */ -export function checkViewOrCreateJobs(moduleId: string, indexPatternId: string): Promise { +export function checkViewOrCreateJobs( + moduleId: string, + indexPatternId: string, + createLinkWithUserDefaults: CreateLinkWithUserDefaults, + navigateToPath: NavigateToPath +): Promise { return new Promise((resolve, reject) => { // Load the module, and check if the job(s) in the module have been created. // If so, load the jobs in the Anomaly Explorer. // Otherwise open the data recognizer wizard for the module. // Always want to call reject() so as not to load original page. ml.dataRecognizerModuleJobsExist({ moduleId }) - .then((resp: any) => { + .then(async (resp: any) => { if (resp.jobsExist === true) { - const resultsPageUrl = mlJobService.createResultsUrlForJobs(resp.jobs, 'explorer'); - window.location.href = resultsPageUrl; + // also honor user's time filter setting in Advanced Settings + const url = createLinkWithUserDefaults('explorer', resp.jobs); + await navigateToPath(url); reject(); } else { - window.location.href = `#/jobs/new_job/recognize?id=${moduleId}&index=${indexPatternId}`; + await navigateToPath(`/jobs/new_job/recognize?id=${moduleId}&index=${indexPatternId}`); reject(); } }) - .catch((err: Error) => { + .catch(async (err: Error) => { // eslint-disable-next-line no-console console.error(`Error checking whether jobs in module ${moduleId} exists`, err); const toastNotifications = getToastNotifications(); @@ -46,8 +53,7 @@ export function checkViewOrCreateJobs(moduleId: string, indexPatternId: string): 'An error occurred trying to check whether the jobs in the module have been created.', }), }); - - window.location.href = '#/jobs'; + await navigateToPath(`/jobs`); reject(); }); }); 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 0af6030df28b1..9c9096dfdfc21 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 @@ -31,6 +31,7 @@ import { getDocLinks } from '../../../../util/dependency_cache'; import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view/index'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; +import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; interface Tab { 'data-test-subj': string; @@ -75,8 +76,9 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { export const JobsListPage: FC<{ coreStart: CoreStart; + share: SharePluginStart; history: ManagementAppMountParams['history']; -}> = ({ coreStart, history }) => { +}> = ({ coreStart, share, history }) => { const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); @@ -136,7 +138,7 @@ export const JobsListPage: FC<{ return ( - + { - ReactDOM.render(React.createElement(JobsListPage, { coreStart, history }), element); + ReactDOM.render(React.createElement(JobsListPage, { coreStart, history, share }), element); return () => { unmountComponentAtNode(element); clearCache(); @@ -30,7 +32,7 @@ export async function mountApp( core: CoreSetup, params: ManagementAppMountParams ) { - const [coreStart] = await core.getStartServices(); + const [coreStart, pluginsStart] = await core.getStartServices(); setDependencyCache({ docLinks: coreStart.docLinks!, @@ -41,5 +43,5 @@ export async function mountApp( params.setBreadcrumbs(getJobsListBreadcrumbs()); - return renderApp(params.element, params.history, coreStart); + return renderApp(params.element, params.history, coreStart, pluginsStart.share); } diff --git a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts index 1792999eee4c2..d0cfd16d7562f 100644 --- a/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts +++ b/x-pack/plugins/ml/public/application/ml_nodes_check/check_ml_nodes.ts @@ -9,7 +9,7 @@ import { ml } from '../services/ml_api_service'; let mlNodeCount: number = 0; let userHasPermissionToViewMlNodeCount: boolean = false; -export async function checkMlNodesAvailable() { +export async function checkMlNodesAvailable(redirectToJobsManagementPage: () => Promise) { try { const nodes = await getMlNodeCount(); if (nodes.count !== undefined && nodes.count > 0) { @@ -20,7 +20,7 @@ export async function checkMlNodesAvailable() { } catch (error) { // eslint-disable-next-line no-console console.error(error); - window.location.href = '#/jobs'; + await redirectToJobsManagementPage(); Promise.reject(); } } diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx index 395a570083c0d..4f0cbc0adddf2 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/actions.tsx @@ -4,30 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, FC } from 'react'; +import React, { FC, useMemo } from 'react'; import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useNavigateToPath } from '../../../contexts/kibana'; +import { Link } from 'react-router-dom'; +import { useMlLink } from '../../../contexts/kibana'; import { getAnalysisType } from '../../../data_frame_analytics/common/analytics'; -import { - getResultsUrl, - DataFrameAnalyticsListRow, -} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; +import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { getViewLinkStatus } from '../../../data_frame_analytics/pages/analytics_management/components/action_view/get_view_link_status'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; interface Props { item: DataFrameAnalyticsListRow; } export const ViewLink: FC = ({ item }) => { - const navigateToPath = useNavigateToPath(); - - const clickHandler = useCallback(() => { - const analysisType = getAnalysisType(item.config.analysis); - navigateToPath(getResultsUrl(item.id, analysisType)); - }, []); - const { disabled, tooltipContent } = getViewLinkStatus(item); const viewJobResultsButtonText = i18n.translate( @@ -38,23 +31,34 @@ export const ViewLink: FC = ({ item }) => { ); const tooltipText = disabled === false ? viewJobResultsButtonText : tooltipContent; + const analysisType = useMemo(() => getAnalysisType(item.config.analysis), [item]); + + const viewAnalyticsResultsLink = useMlLink({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION, + pageState: { + jobId: item.id, + analysisType: analysisType as DataFrameAnalysisConfigType, + }, + excludeBasePath: true, + }); return ( - - {i18n.translate('xpack.ml.overview.analytics.viewActionName', { - defaultMessage: 'View', - })} - + + + {i18n.translate('xpack.ml.overview.analytics.viewActionName', { + defaultMessage: 'View', + })} + + ); }; diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx index be8038cc5049d..4d810c47415a7 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/analytics_panel.tsx @@ -23,6 +23,8 @@ import { AnalyticsTable } from './table'; import { getAnalyticsFactory } from '../../../data_frame_analytics/pages/analytics_management/services/analytics_service'; import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { AnalyticStatsBarStats, StatsBar } from '../../../components/stats_bar'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; interface Props { jobCreationDisabled: boolean; @@ -35,6 +37,16 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { const [errorMessage, setErrorMessage] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToDataFrameAnalyticsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + const getAnalytics = getAnalyticsFactory( setAnalytics, setAnalyticsStats, @@ -75,7 +87,6 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { {isInitialized === false && ( )} -      {errorMessage === undefined && isInitialized === true && analytics.length === 0 && ( = ({ jobCreationDisabled }) => { } actions={ = ({ jobCreationDisabled }) => { )} {isInitialized === true && analytics.length > 0 && ( <> + @@ -136,7 +148,7 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { defaultMessage: 'Refresh', })} - + {i18n.translate('xpack.ml.overview.analyticsList.manageJobsButtonText', { defaultMessage: 'Manage jobs', })} diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx index a71141d0356d0..dfba7c9651266 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx @@ -7,6 +7,7 @@ import React, { FC } from 'react'; import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { Link } from 'react-router-dom'; import { MlSummaryJobs } from '../../../../../common/types/anomaly_detection_jobs'; import { useCreateADLinks } from '../../../components/custom_hooks/use_create_ad_links'; @@ -26,19 +27,20 @@ export const ExplorerLink: FC = ({ jobsList }) => { return ( - - {i18n.translate('xpack.ml.overview.anomalyDetection.viewActionName', { - defaultMessage: 'View', - })} - + + + {i18n.translate('xpack.ml.overview.anomalyDetection.viewActionName', { + defaultMessage: 'View', + })} + + ); }; diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx index 0bfd2c2e49232..1cb6bab7fd768 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -16,12 +16,13 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; -import { useMlKibana } from '../../../contexts/kibana'; +import { useMlKibana, useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; import { AnomalyDetectionTable } from './table'; import { ml } from '../../../services/ml_api_service'; import { getGroupsFromJobs, getStatsBarData, getJobsWithTimerange } from './utils'; import { Dictionary } from '../../../../../common/types/common'; import { MlSummaryJobs, MlSummaryJob } from '../../../../../common/types/anomaly_detection_jobs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; export type GroupsDictionary = Dictionary; @@ -39,8 +40,6 @@ type MaxScoresByGroup = Dictionary<{ index?: number; }>; -const createJobLink = '#/jobs/new_job/step/index_or_search'; - function getDefaultAnomalyScores(groups: Group[]): MaxScoresByGroup { const anomalyScores: MaxScoresByGroup = {}; groups.forEach((group) => { @@ -58,6 +57,23 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { const { services: { notifications }, } = useMlKibana(); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToJobsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + + const redirectToCreateJobSelectIndexPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX, + }); + await navigateToPath(path, true); + }; + const [isLoading, setIsLoading] = useState(false); const [groups, setGroups] = useState({}); const [groupsCount, setGroupsCount] = useState(0); @@ -157,7 +173,7 @@ export const AnomalyDetectionPanel: FC = ({ jobCreationDisabled }) => { return ( {typeof errorMessage !== 'undefined' && errorDisplay} - {isLoading && }    + {isLoading && } {isLoading === false && typeof errorMessage === 'undefined' && groupsCount === 0 && ( = ({ jobCreationDisabled }) => { actions={ = ({ jobCreationDisabled }) => { defaultMessage: 'Refresh', })} - + {i18n.translate('xpack.ml.overview.anomalyDetection.manageJobsButtonText', { defaultMessage: 'Manage jobs', })} diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx index 945116b0534bb..8515431d49b17 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/table.tsx @@ -88,7 +88,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData {i18n.translate('xpack.ml.overview.anomalyDetection.tableMaxScore', { defaultMessage: 'Max anomaly score', - })}{' '} + })} @@ -203,6 +203,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData return ( + diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index d0a4f999af758..398ec5b4759d2 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -54,6 +54,20 @@ export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ href: '/jobs/new_job', }); +export const CALENDAR_MANAGEMENT_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', { + defaultMessage: 'Calendar management', + }), + href: '/settings/calendars_list', +}); + +export const FILTER_LISTS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', { + defaultMessage: 'Filter lists', + }), + href: '/settings/filter_lists', +}); + const breadcrumbs = { ML_BREADCRUMB, SETTINGS_BREADCRUMB, @@ -61,6 +75,8 @@ const breadcrumbs = { DATA_FRAME_ANALYTICS_BREADCRUMB, DATA_VISUALIZER_BREADCRUMB, CREATE_JOB_BREADCRUMB, + CALENDAR_MANAGEMENT_BREADCRUMB, + FILTER_LISTS_BREADCRUMB, }; type Breadcrumb = keyof typeof breadcrumbs; @@ -76,10 +92,12 @@ export const breadcrumbOnClickFactory = ( export const getBreadcrumbWithUrlForApp = ( breadcrumbName: Breadcrumb, - navigateToPath: NavigateToPath + navigateToPath: NavigateToPath, + basePath: string ): EuiBreadcrumb => { return { - ...breadcrumbs[breadcrumbName], + text: breadcrumbs[breadcrumbName].text, + href: `${basePath}/app/ml${breadcrumbs[breadcrumbName].href}`, onClick: breadcrumbOnClickFactory(breadcrumbs[breadcrumbName].href, navigateToPath), }; }; diff --git a/x-pack/plugins/ml/public/application/routing/resolvers.ts b/x-pack/plugins/ml/public/application/routing/resolvers.ts index 958221df8a636..9cebb67166a66 100644 --- a/x-pack/plugins/ml/public/application/routing/resolvers.ts +++ b/x-pack/plugins/ml/public/application/routing/resolvers.ts @@ -21,13 +21,17 @@ export interface ResolverResults { interface BasicResolverDependencies { indexPatterns: IndexPatternsContract; + redirectToMlAccessDeniedPage: () => Promise; } -export const basicResolvers = ({ indexPatterns }: BasicResolverDependencies): Resolvers => ({ +export const basicResolvers = ({ + indexPatterns, + redirectToMlAccessDeniedPage, +}: BasicResolverDependencies): Resolvers => ({ checkFullLicense, getMlNodeCount, loadMlServerInfo, loadIndexPatterns: () => loadIndexPatterns(indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), loadSavedSearches, }); diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index 22a17c4ea089a..7cb3a2f07c2ee 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -12,7 +12,7 @@ import { AppMountParameters, IUiSettingsClient, ChromeStart } from 'kibana/publi import { ChromeBreadcrumb } from 'kibana/public'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { useNavigateToPath } from '../contexts/kibana'; +import { useMlKibana, useNavigateToPath } from '../contexts/kibana'; import { MlContext, MlContextValue } from '../contexts/ml'; import { UrlStateProvider } from '../util/url_state'; @@ -39,6 +39,7 @@ interface PageDependencies { history: AppMountParameters['history']; indexPatterns: IndexPatternsContract; setBreadcrumbs: ChromeStart['setBreadcrumbs']; + redirectToMlAccessDeniedPage: () => Promise; } export const PageLoader: FC<{ context: MlContextValue }> = ({ context, children }) => { @@ -75,10 +76,16 @@ const MlRoutes: FC<{ pageDeps: PageDependencies; }> = ({ pageDeps }) => { const navigateToPath = useNavigateToPath(); + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); + return ( <> {Object.entries(routes).map(([name, routeFactory]) => { - const route = routeFactory(navigateToPath); + const route = routeFactory(navigateToPath, basePath.get()); return ( ({ +export const analyticsJobsCreationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/new_job', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameManagementLabel', { defaultMessage: 'Data Frame Analytics', diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx index 47cc002ab4d83..f9f2ebe48f4aa 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_exploration.tsx @@ -10,21 +10,25 @@ import { decode } from 'rison-node'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useMlKibana, useMlUrlGenerator } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_exploration'; -import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common/analytics'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; -export const analyticsJobExplorationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const analyticsJobExplorationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/exploration', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameExplorationLabel', { defaultMessage: 'Exploration', @@ -38,16 +42,31 @@ const PageWrapper: FC = ({ location, deps }) => { const { context } = useResolver('', undefined, deps.config, basicResolvers(deps)); const { _g }: Record = parse(location.search, { sort: false }); + const urlGenerator = useMlUrlGenerator(); + const { + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + const redirectToAnalyticsManagementPage = async () => { + const url = await urlGenerator.createUrl({ page: ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE }); + await navigateToUrl(url); + }; + let globalState: any = null; try { globalState = decode(_g); } catch (error) { // eslint-disable-next-line no-console - console.error('Could not parse global state'); - window.location.href = '#data_frame_analytics'; + console.error( + 'Could not parse global state. Redirecting to Data Frame Analytics Management Page.' + ); + redirectToAnalyticsManagementPage(); + return <>; } const jobId: string = globalState.ml.jobId; - const analysisType: ANALYSIS_CONFIG_TYPE = globalState.ml.analysisType; + const analysisType: DataFrameAnalysisConfigType = globalState.ml.analysisType; return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx index b6ef9ea81b4ba..80706a82121d5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_jobs_list.tsx @@ -15,12 +15,15 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_management'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const analyticsJobsListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const analyticsJobsListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameListLabel', { defaultMessage: 'Job Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx index 7bf7784d1b559..b1fd6e93a744c 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/models_list.tsx @@ -15,12 +15,15 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../data_frame_analytics/pages/analytics_management'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const modelsListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const modelsListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/data_frame_analytics/models', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_FRAME_ANALYTICS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.modelsListLabel', { defaultMessage: 'Model Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx index efe5c3cba04a5..f40b754a23ccb 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/datavisualizer.tsx @@ -21,19 +21,25 @@ import { checkBasicLicense } from '../../../license'; import { checkFindFileStructurePrivilegeResolver } from '../../../capabilities/check_capabilities'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const selectorRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const selectorRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/datavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkBasicLicense, - checkFindFileStructurePrivilege: checkFindFileStructurePrivilegeResolver, + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx index 485af52c45a55..837616a8a76d2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/file_based.tsx @@ -24,12 +24,15 @@ import { loadIndexPatterns } from '../../../util/index_utils'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const fileBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const fileBasedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/filedatavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataVisualizer.fileBasedLabel', { defaultMessage: 'File', @@ -40,10 +43,13 @@ export const fileBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute = }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver('', undefined, deps.config, { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkFindFileStructurePrivilege: checkFindFileStructurePrivilegeResolver, + checkFindFileStructurePrivilege: () => + checkFindFileStructurePrivilegeResolver(redirectToMlAccessDeniedPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx index 358b8773e3460..e3d0e5050fca5 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/datavisualizer/index_based.tsx @@ -20,13 +20,18 @@ import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_ca import { loadIndexPatterns } from '../../../util/index_utils'; import { checkMlNodesAvailable } from '../../../ml_nodes_check'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; -export const indexBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const indexBasedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/datavisualizer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.indexLabel', { defaultMessage: 'Index', @@ -37,12 +42,17 @@ export const indexBasedRouteFactory = (navigateToPath: NavigateToPath): MlRoute }); const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); const { context } = useResolver(index, savedSearchId, deps.config, { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 30b9bc2af219f..00d64a2f1bd1d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -35,12 +35,15 @@ import { useTimefilter } from '../../contexts/kibana'; import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; -export const explorerRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const explorerRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/explorer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.anomalyExplorerLabel', { defaultMessage: 'Anomaly Explorer', diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index 38a7900916ba8..2863e59508e35 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -20,12 +20,12 @@ import { JobsPage } from '../../jobs/jobs_list'; import { useTimefilter } from '../../contexts/kibana'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; -export const jobListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/jobs', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.jobManagementLabel', { defaultMessage: 'Job Management', diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx index d8605c4cc9115..0ef3b384dcf5d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/index_or_search.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useMlKibana } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; @@ -19,6 +19,8 @@ import { checkBasicLicense } from '../../../license'; import { loadIndexPatterns } from '../../../util/index_utils'; import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; import { checkMlNodesAvailable } from '../../../ml_nodes_check'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; enum MODE { NEW_JOB, @@ -30,9 +32,9 @@ interface IndexOrSearchPageProps extends PageProps { mode: MODE; } -const getBreadcrumbs = (navigateToPath: NavigateToPath) => [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), +const getBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabel', { defaultMessage: 'Create job', @@ -41,7 +43,10 @@ const getBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -export const indexOrSearchRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const indexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/step/index_or_search', render: (props, deps) => ( ), - breadcrumbs: getBreadcrumbs(navigateToPath), + breadcrumbs: getBreadcrumbs(navigateToPath, basePath), }); -export const dataVizIndexOrSearchRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const dataVizIndexOrSearchRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/datavisualizer_index_select', render: (props, deps) => ( ), - breadcrumbs: getBreadcrumbs(navigateToPath), + breadcrumbs: getBreadcrumbs(navigateToPath, basePath), }); const PageWrapper: FC = ({ nextStepPath, deps, mode }) => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = useMlKibana(); + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const newJobResolvers = { ...basicResolvers(deps), - preConfiguredJobRedirect: () => preConfiguredJobRedirect(deps.indexPatterns), + preConfiguredJobRedirect: () => + preConfiguredJobRedirect(deps.indexPatterns, basePath.get(), navigateToUrl), }; const dataVizResolvers = { checkBasicLicense, loadIndexPatterns: () => loadIndexPatterns(deps.indexPatterns), - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }; const { context } = useResolver( diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx index b8ab29d40fa1f..543e01fbd326d 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/job_type.tsx @@ -17,12 +17,12 @@ import { basicResolvers } from '../../resolvers'; import { Page } from '../../../jobs/new_job/pages/job_type'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const jobTypeRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const jobTypeRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/jobs/new_job/step/job_type', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectJobType', { defaultMessage: 'Create job', diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx index 6be58828ee1a5..654d7184cfcf2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/recognize.tsx @@ -9,7 +9,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { NavigateToPath } from '../../../contexts/kibana'; +import { NavigateToPath, useNavigateToPath } from '../../../contexts/kibana'; import { MlRoute, PageLoader, PageProps } from '../../router'; import { useResolver } from '../../use_resolver'; @@ -18,14 +18,18 @@ import { Page } from '../../../jobs/new_job/recognize'; import { checkViewOrCreateJobs } from '../../../jobs/new_job/recognize/resolvers'; import { mlJobService } from '../../../services/job_service'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateADLinks } from '../../../components/custom_hooks/use_create_ad_links'; -export const recognizeRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const recognizeRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/recognize', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectIndexOrSearchLabelRecognize', { defaultMessage: 'Recognized index', @@ -60,10 +64,14 @@ const CheckViewOrCreateWrapper: FC = ({ location, deps }) => { const { id: moduleId, index: indexPatternId }: Record = parse(location.search, { sort: false, }); + const { createLinkWithUserDefaults } = useCreateADLinks(); + + const navigateToPath = useNavigateToPath(); // the single resolver checkViewOrCreateJobs redirects only. so will always reject useResolver(undefined, undefined, deps.config, { - checkViewOrCreateJobs: () => checkViewOrCreateJobs(moduleId, indexPatternId), + checkViewOrCreateJobs: () => + checkViewOrCreateJobs(moduleId, indexPatternId, createLinkWithUserDefaults, navigateToPath), }); return null; }; diff --git a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx index 35085fd557577..8a82a9a8dbc49 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/new_job/wizard.tsx @@ -19,19 +19,21 @@ import { mlJobService } from '../../../services/job_service'; import { loadNewJobCapabilities } from '../../../services/new_job_capabilities_service'; import { checkCreateJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; interface WizardPageProps extends PageProps { jobType: JOB_TYPE; } -const getBaseBreadcrumbs = (navigateToPath: NavigateToPath) => [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath), +const getBaseBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CREATE_JOB_BREADCRUMB', navigateToPath, basePath), ]; -const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.singleMetricLabel', { defaultMessage: 'Single metric', @@ -40,8 +42,8 @@ const getSingleMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.multiMetricLabel', { defaultMessage: 'Multi-metric', @@ -50,8 +52,8 @@ const getMultiMetricBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.populationLabel', { defaultMessage: 'Population', @@ -60,8 +62,8 @@ const getPopulationBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.advancedConfigurationLabel', { defaultMessage: 'Advanced configuration', @@ -70,8 +72,8 @@ const getAdvancedBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath) => [ - ...getBaseBreadcrumbs(navigateToPath), +const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [ + ...getBaseBreadcrumbs(navigateToPath, basePath), { text: i18n.translate('xpack.ml.jobsBreadcrumbs.categorizationLabel', { defaultMessage: 'Categorization', @@ -80,41 +82,60 @@ const getCategorizationBreadcrumbs = (navigateToPath: NavigateToPath) => [ }, ]; -export const singleMetricRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const singleMetricRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/single_metric', render: (props, deps) => , - breadcrumbs: getSingleMetricBreadcrumbs(navigateToPath), + breadcrumbs: getSingleMetricBreadcrumbs(navigateToPath, basePath), }); -export const multiMetricRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const multiMetricRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/multi_metric', render: (props, deps) => , - breadcrumbs: getMultiMetricBreadcrumbs(navigateToPath), + breadcrumbs: getMultiMetricBreadcrumbs(navigateToPath, basePath), }); -export const populationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const populationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/population', render: (props, deps) => , - breadcrumbs: getPopulationBreadcrumbs(navigateToPath), + breadcrumbs: getPopulationBreadcrumbs(navigateToPath, basePath), }); -export const advancedRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const advancedRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/advanced', render: (props, deps) => , - breadcrumbs: getAdvancedBreadcrumbs(navigateToPath), + breadcrumbs: getAdvancedBreadcrumbs(navigateToPath, basePath), }); -export const categorizationRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const categorizationRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/jobs/new_job/categorization', render: (props, deps) => , - breadcrumbs: getCategorizationBreadcrumbs(navigateToPath), + breadcrumbs: getCategorizationBreadcrumbs(navigateToPath, basePath), }); const PageWrapper: FC = ({ location, jobType, deps }) => { + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); const { context, results } = useResolver(index, savedSearchId, deps.config, { ...basicResolvers(deps), - privileges: checkCreateJobsCapabilitiesResolver, + privileges: () => checkCreateJobsCapabilitiesResolver(redirectToJobsManagementPage), jobCaps: () => loadNewJobCapabilities(index, savedSearchId, deps.indexPatterns), existingJobsAndGroups: mlJobService.getJobAndGroupIds, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx index 174e9804b9689..0e07b0edfbe56 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/overview.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/overview.tsx @@ -22,11 +22,14 @@ import { loadMlServerInfo } from '../../services/ml_server_info'; import { useTimefilter } from '../../contexts/kibana'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../breadcrumbs'; -export const overviewRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const overviewRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/overview', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.overview.overviewLabel', { defaultMessage: 'Overview', @@ -37,9 +40,11 @@ export const overviewRouteFactory = (navigateToPath: NavigateToPath): MlRoute => }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, loadMlServerInfo, }); @@ -52,7 +57,7 @@ const PageWrapper: FC = ({ deps }) => { ); }; -export const appRootRouteFactory = (): MlRoute => ({ +export const appRootRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ path: '/', render: () => , breadcrumbs: [], diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx index f2ae57f1ec961..2460971239618 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_list.tsx @@ -10,7 +10,6 @@ */ import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; import { NavigateToPath } from '../../../contexts/kibana'; @@ -25,27 +24,27 @@ import { } from '../../../capabilities/check_capabilities'; import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { CalendarsList } from '../../../settings/calendars'; -import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const calendarListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const calendarListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), - { - text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagementLabel', { - defaultMessage: 'Calendar management', - }), - onClick: breadcrumbOnClickFactory('/settings/calendars_list', navigateToPath), - }, + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx index a5c30e1eaaacc..4e0a8340590a4 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/calendar_new_edit.tsx @@ -26,6 +26,8 @@ import { import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; import { NewCalendar } from '../../../settings/calendars'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; enum MODE { NEW, @@ -36,12 +38,16 @@ interface NewCalendarPageProps extends PageProps { mode: MODE; } -export const newCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const newCalendarRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list/new_calendar', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.createLabel', { defaultMessage: 'Create', @@ -51,12 +57,16 @@ export const newCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute ], }); -export const editCalendarRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const editCalendarRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/calendars_list/edit_calendar/:calendarId', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('CALENDAR_MANAGEMENT_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.calendarManagement.editLabel', { defaultMessage: 'Edit', @@ -72,11 +82,15 @@ const PageWrapper: FC = ({ location, mode, deps }) => { const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/); calendarId = pathMatch && pathMatch.length > 1 ? pathMatch[1] : undefined; } + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx index d734e18d72bab..4e39cfce82e36 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list.tsx @@ -10,7 +10,6 @@ */ import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; import { NavigateToPath } from '../../../contexts/kibana'; @@ -26,27 +25,27 @@ import { import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { FilterLists } from '../../../settings/filter_lists'; -import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const filterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const filterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), - { - text: i18n.translate('xpack.ml.settings.breadcrumbs.filterListsLabel', { - defaultMessage: 'Filter lists', - }), - onClick: breadcrumbOnClickFactory('/settings/filter_lists', navigateToPath), - }, + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx index c6f17bc7f6f68..5fe56b024e413 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/filter_list_new_edit.tsx @@ -27,6 +27,8 @@ import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; import { EditFilterList } from '../../../settings/filter_lists'; import { breadcrumbOnClickFactory, getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; enum MODE { NEW, @@ -37,12 +39,17 @@ interface NewFilterPageProps extends PageProps { mode: MODE; } -export const newFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const newFilterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists/new_filter_list', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), + { text: i18n.translate('xpack.ml.settings.breadcrumbs.filterLists.createLabel', { defaultMessage: 'Create', @@ -52,12 +59,16 @@ export const newFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRou ], }); -export const editFilterListRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const editFilterListRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings/filter_lists/edit_filter_list/:filterId', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('FILTER_LISTS_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.settings.breadcrumbs.filterLists.editLabel', { defaultMessage: 'Edit', @@ -73,11 +84,15 @@ const PageWrapper: FC = ({ location, mode, deps }) => { const pathMatch: string[] | null = location.pathname.match(/.+\/(.+)$/); filterId = pathMatch && pathMatch.length > 1 ? pathMatch[1] : undefined; } + const { redirectToMlAccessDeniedPage } = deps; + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, - checkMlNodesAvailable, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + checkMlNodesAvailable: () => checkMlNodesAvailable(redirectToJobsManagementPage), }); useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx index 3f4b269851469..3159c2ae88166 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/settings/settings.tsx @@ -26,19 +26,24 @@ import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { AnomalyDetectionSettingsContext, Settings } from '../../../settings'; import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; -export const settingsRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const settingsRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/settings', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('SETTINGS_BREADCRUMB', navigateToPath, basePath), ], }); const PageWrapper: FC = ({ deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + const { context } = useResolver(undefined, undefined, deps.config, { checkFullLicense, - checkGetJobsCapabilities: checkGetJobsCapabilitiesResolver, + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), getMlNodeCount, }); diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx index 11ec074bac1db..b60a265560455 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.test.tsx @@ -19,6 +19,11 @@ jest.mock('../../contexts/kibana/kibana_context', () => { useMlKibana: () => { return { services: { + chrome: { docTitle: { change: jest.fn() } }, + application: { getUrlForApp: jest.fn(), navigateToUrl: jest.fn() }, + share: { + urlGenerators: { getUrlGenerator: jest.fn() }, + }, uiSettings: { get: jest.fn() }, data: { query: { diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 817c975415997..03588872d6be0 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -39,12 +39,15 @@ import { basicResolvers } from '../resolvers'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; -export const timeSeriesExplorerRouteFactory = (navigateToPath: NavigateToPath): MlRoute => ({ +export const timeSeriesExplorerRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ path: '/timeseriesexplorer', render: (props, deps) => , breadcrumbs: [ - getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath), - getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath), + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('ANOMALY_DETECTION_BREADCRUMB', navigateToPath, basePath), { text: i18n.translate('xpack.ml.anomalyDetection.singleMetricViewerLabel', { defaultMessage: 'Single Metric Viewer', diff --git a/x-pack/plugins/ml/public/application/routing/use_resolver.ts b/x-pack/plugins/ml/public/application/routing/use_resolver.ts index 4967e3a684a6b..e4cd90145bee4 100644 --- a/x-pack/plugins/ml/public/application/routing/use_resolver.ts +++ b/x-pack/plugins/ml/public/application/routing/use_resolver.ts @@ -16,6 +16,8 @@ import { createSearchItems } from '../jobs/new_job/utils/new_job_utils'; import { ResolverResults, Resolvers } from './resolvers'; import { MlContextValue } from '../contexts/ml'; import { useNotifications } from '../contexts/kibana'; +import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; export const useResolver = ( indexPatternId: string | undefined, @@ -34,6 +36,9 @@ export const useResolver = ( const [context, setContext] = useState(null); const [results, setResults] = useState(tempResults); + const redirectToJobsManagementPage = useCreateAndNavigateToMlLink( + ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE + ); useEffect(() => { (async () => { @@ -73,7 +78,7 @@ export const useResolver = ( defaultMessage: 'An error has occurred', }), }); - window.location.href = '#/'; + await redirectToJobsManagementPage(); } } else { setContext({}); diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index dfa1b5f4e68cd..ea97492ae0f5a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -797,7 +797,6 @@ function createResultsUrl(jobIds, start, end, resultsPage, mode = 'absolute') { let path = ''; if (resultsPage !== undefined) { - path += '#/'; path += resultsPage; } diff --git a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx index 16d7e1605263c..57caa56b2f10e 100644 --- a/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx +++ b/x-pack/plugins/ml/public/application/settings/anomaly_detection_settings.tsx @@ -25,6 +25,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyDetectionSettingsContext } from './anomaly_detection_settings_context'; import { useNotifications } from '../contexts/kibana'; import { ml } from '../services/ml_api_service'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; +import { useCreateAndNavigateToMlLink } from '../contexts/kibana/use_create_url'; export const AnomalyDetectionSettings: FC = () => { const [calendarsCount, setCalendarsCount] = useState(0); @@ -35,6 +37,10 @@ export const AnomalyDetectionSettings: FC = () => { ); const { toasts } = useNotifications(); + const redirectToCalendarList = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); + const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const redirectToFilterLists = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_MANAGE); + const redirectToNewFilterListPage = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_NEW); useEffect(() => { loadSummaryStats(); @@ -126,7 +132,7 @@ export const AnomalyDetectionSettings: FC = () => { flush="left" size="l" color="primary" - href="#/settings/calendars_list" + onClick={redirectToCalendarList} isDisabled={canGetCalendars === false} > { flush="left" size="l" color="primary" - href="#/settings/calendars_list/new_calendar" + onClick={redirectToNewCalendarPage} isDisabled={canCreateCalendar === false} > {

@@ -194,7 +200,7 @@ export const AnomalyDetectionSettings: FC = () => { flush="left" size="l" color="primary" - href="#/settings/filter_lists" + onClick={redirectToFilterLists} isDisabled={canGetFilters === false} > { data-test-subj="mlFilterListsCreateButton" size="l" color="primary" - href="#/settings/filter_lists/new_filter_list" + onClick={redirectToNewFilterListPage} isDisabled={canCreateFilter === false} > + + + } + labelType="label" + > + + + + } + labelType="label" + > + + @@ -137,7 +202,6 @@ exports[`CalendarForm Renders calendar form 1`] = ` grow={false} > - +

{description}

@@ -81,6 +83,7 @@ export const CalendarForm = ({ const error = isNewCalendarIdValid === false && !isEdit ? [msg] : undefined; const saveButtonDisabled = canCreateCalendar === false || saving || !isNewCalendarIdValid || calendarId === ''; + const redirectToCalendarsManagementPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_MANAGE); return ( @@ -113,6 +116,7 @@ export const CalendarForm = ({ value={calendarId} onChange={onCalendarIdChange} disabled={isEdit === true || saving === true} + data-test-subj="mlCalendarIdInput" /> @@ -129,6 +133,7 @@ export const CalendarForm = ({ value={description} onChange={onDescriptionChange} disabled={isEdit === true || saving === true} + data-test-subj="mlCalendarDescriptionInput" />
@@ -215,7 +220,7 @@ export const CalendarForm = ({ - + ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); const testProps = { calendarId: '', canCreateCalendar: true, @@ -31,6 +34,7 @@ const testProps = { selectedGroupOptions: [], selectedJobOptions: [], showNewEventModal: jest.fn(), + isGlobalCalendar: false, }; describe('CalendarForm', () => { diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 1fe16e4588bd7..a5eb212ba127e 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -20,6 +20,7 @@ import { ImportModal } from './import_modal'; import { ml } from '../../../services/ml_api_service'; import { withKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { GLOBAL_CALENDAR } from '../../../../../common/constants/calendars'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; class NewCalendarUI extends Component { static propTypes = { @@ -55,6 +56,16 @@ class NewCalendarUI extends Component { this.formSetup(); } + returnToCalendarsManagementPage = async () => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = this.props.kibana; + await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.CALENDARS_MANAGE}`, true); + }; + async formSetup() { try { const { jobIds, groupIds, calendars } = await getCalendarSettingsData(); @@ -146,7 +157,7 @@ class NewCalendarUI extends Component { try { await ml.addCalendar(calendar); - window.location = '#/settings/calendars_list'; + await this.returnToCalendarsManagementPage(); } catch (error) { console.log('Error saving calendar', error); this.setState({ saving: false }); @@ -167,7 +178,7 @@ class NewCalendarUI extends Component { try { await ml.updateCalendar(calendar); - window.location = '#/settings/calendars_list'; + await this.returnToCalendarsManagementPage(); } catch (error) { console.log('Error saving calendar', error); this.setState({ saving: false }); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js index 2cff255bd1ce3..068d443300088 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.test.js @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +jest.mock('../../../contexts/kibana/use_create_url', () => ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); + jest.mock('../../../components/navigation_menu', () => ({ NavigationMenu: () =>
, })); diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js index d80e248674a8f..0b5d2b7b5a3ea 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js @@ -257,7 +257,12 @@ export class NewEventModal extends Component { return ( - + @@ -293,13 +299,18 @@ export class NewEventModal extends Component { - + - + c.calendar_id).join(', '), + }} /> } onCancel={this.closeDestroyModal} @@ -130,18 +135,7 @@ export class CalendarsListUI extends Component { } buttonColor="danger" defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -

- c.calendar_id).join(', '), - }} - /> -

-
+ /> ); } diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap index cc1c524c19b57..50cacd7b3545a 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/table/__snapshots__/table.test.js.snap @@ -77,7 +77,6 @@ exports[`CalendarsListTable renders the table with all calendars 1`] = ` "toolsRight": Array [ diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js index 77331c4a987dc..6b4403aef7c7b 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/table/table.js @@ -7,12 +7,14 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { EuiButton, EuiLink, EuiInMemoryTable } from '@elastic/eui'; - +import { EuiButton, EuiInMemoryTable } from '@elastic/eui'; +import { Link } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { GLOBAL_CALENDAR } from '../../../../../../common/constants/calendars'; +import { useCreateAndNavigateToMlLink } from '../../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; export const CalendarsListTable = ({ calendarsList, @@ -24,6 +26,8 @@ export const CalendarsListTable = ({ mlNodesAvailable, itemsSelected, }) => { + const redirectToNewCalendarPage = useCreateAndNavigateToMlLink(ML_PAGES.CALENDARS_NEW); + const sorting = { sort: { field: 'calendar_id', @@ -46,12 +50,9 @@ export const CalendarsListTable = ({ truncateText: true, scope: 'row', render: (id) => ( - + {id} - + ), 'data-test-subj': 'mlCalendarListColumnId', }, @@ -101,7 +102,7 @@ export const CalendarsListTable = ({ size="s" data-test-subj="mlCalendarButtonCreate" key="new_calendar_button" - href="#/settings/calendars_list/new_calendar" + onClick={redirectToNewCalendarPage} isDisabled={canCreateCalendar === false || mlNodesAvailable === false} > @@ -115,6 +116,7 @@ export const CalendarsListTable = ({ canDeleteCalendar === false || mlNodesAvailable === false || itemsSelected === false } data-test-subj="mlCalendarButtonDelete" + key="delete_calendar_button" > ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); const calendars = [ { @@ -42,7 +47,11 @@ describe('CalendarsListTable', () => { }); test('New button enabled if permission available', () => { - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); @@ -56,7 +65,11 @@ describe('CalendarsListTable', () => { canCreateCalendar: false, }; - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); @@ -70,7 +83,11 @@ describe('CalendarsListTable', () => { mlNodesAvailable: false, }; - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl( + + + + ); const buttons = wrapper.find('[data-test-subj="mlCalendarButtonCreate"]'); const button = buttons.find('EuiButton'); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap index 6e9cd17deabee..969406724537d 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap @@ -7,7 +7,7 @@ exports[`AddItemPopover calls addItems with multiple items on clicking Add butto button={ @@ -71,6 +72,7 @@ exports[`AddItemPopover calls addItems with multiple items on clicking Add butto grow={false} > @@ -93,7 +95,7 @@ exports[`AddItemPopover opens the popover onButtonClick 1`] = ` button={ @@ -157,6 +160,7 @@ exports[`AddItemPopover opens the popover onButtonClick 1`] = ` grow={false} > @@ -179,7 +183,7 @@ exports[`AddItemPopover renders the popover 1`] = ` button={ @@ -243,6 +248,7 @@ exports[`AddItemPopover renders the popover 1`] = ` grow={false} > diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js index 07e060d87b36a..53a3877e2f1bd 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js @@ -84,7 +84,7 @@ export class AddItemPopover extends Component { iconSide="right" onClick={this.onButtonClick} isDisabled={this.props.canCreateFilter === false} - data-test-subj="mlFilterListAddItemButton" + data-test-subj="mlFilterListOpenNewItemsPopoverButton" > } > - + @@ -127,6 +131,7 @@ export class AddItemPopover extends Component { } + data-test-subj="mlFilterListDeleteConfirmation" defaultFocusedButton="confirm" onCancel={[Function]} onConfirm={[Function]} diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js index 75fdce8e2bac8..5aafe79645f6a 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js @@ -86,6 +86,7 @@ export class DeleteFilterListModal extends Component { } buttonColor="danger" defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + data-test-subj={'mlFilterListDeleteConfirmation'} /> ); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap index 9904e90a5afae..268b93923a432 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap @@ -47,6 +47,7 @@ exports[`FilterListUsagePopover opens the popover onButtonClick 1`] = ` labelType="label" > diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js index 06ace034ca819..b7bcb201f2438 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js @@ -102,6 +102,7 @@ export class EditDescriptionPopover extends Component { name="filter_list_description" value={value} onChange={this.onChange} + data-test-subj={'mlFilterListDescriptionInput'} /> diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap index c2fab64473228..f6a4f76975553 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap @@ -80,6 +80,7 @@ exports[`EditFilterList adds new items to filter list 1`] = ` grow={false} > - +

A test filter list

@@ -180,6 +183,7 @@ exports[`EditFilterListHeader renders the header when creating a new filter list labelType="label" > - +

A test filter list

diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js index 41b7aa63f55ef..9ea470a388f02 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js @@ -34,6 +34,7 @@ import { ItemsGrid } from '../../../components/items_grid'; import { NavigationMenu } from '../../../components/navigation_menu'; import { isValidFilterListId, saveFilterList } from './utils'; import { ml } from '../../../services/ml_api_service'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; const DEFAULT_ITEMS_PER_PAGE = 50; @@ -67,10 +68,6 @@ function getActivePage(activePageState, itemsPerPage, numMatchingItems) { return activePage; } -function returnToFiltersList() { - window.location.href = `#/settings/filter_lists`; -} - export class EditFilterListUI extends Component { static displayName = 'EditFilterList'; static propTypes = { @@ -105,6 +102,16 @@ export class EditFilterListUI extends Component { } } + returnToFiltersList = async () => { + const { + services: { + http: { basePath }, + application: { navigateToUrl }, + }, + } = this.props.kibana; + await navigateToUrl(`${basePath.get()}/app/ml/${ML_PAGES.FILTER_LISTS_MANAGE}`, true); + }; + loadFilterList = (filterId) => { ml.filters .filters({ filterId }) @@ -279,7 +286,7 @@ export class EditFilterListUI extends Component { saveFilterList(filterId, description, items, loadedFilter) .then((savedFilter) => { this.setLoadedFilterState(savedFilter); - returnToFiltersList(); + this.returnToFiltersList(); }) .catch((resp) => { console.log(`Error saving filter ${filterId}:`, resp); @@ -355,7 +362,10 @@ export class EditFilterListUI extends Component { /> - + this.returnToFiltersList()} + > updateNewFilterId(e.target.value)} + data-test-subj={'mlNewFilterListIdInput'} /> ); @@ -96,7 +97,7 @@ export const EditFilterListHeader = ({ if (description !== undefined && description.length > 0) { descriptionField = ( - +

{description}

); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js b/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js index 9b6fb2b3572a5..9e1457483cb2c 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js @@ -10,6 +10,7 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { Link } from 'react-router-dom'; import { EuiButton, @@ -17,7 +18,6 @@ import { EuiFlexItem, EuiIcon, EuiInMemoryTable, - EuiLink, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -26,6 +26,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { DeleteFilterListModal } from '../components/delete_filter_list_modal'; +import { useCreateAndNavigateToMlLink } from '../../../contexts/kibana/use_create_url'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; function UsedByIcon({ usedBy }) { // Renders a tick or cross in the 'usedBy' column to indicate whether @@ -61,10 +63,12 @@ UsedByIcon.propTypes = { }; function NewFilterButton({ canCreateFilter }) { + const redirectToNewFilterListPage = useCreateAndNavigateToMlLink(ML_PAGES.FILTER_LISTS_NEW); + return ( @@ -84,12 +88,9 @@ function getColumns() { defaultMessage: 'ID', }), render: (id) => ( - + {id} - + ), sortable: true, scope: 'row', @@ -213,7 +214,7 @@ export function FilterListsTable({ isSelectable={true} data-test-subj="mlFilterListsTable" rowProps={(item) => ({ - 'data-test-subj': `mlCalendarListRow row-${item.filter_id}`, + 'data-test-subj': `mlFilterListRow row-${item.filter_id}`, })} />
diff --git a/x-pack/plugins/ml/public/application/settings/settings.test.tsx b/x-pack/plugins/ml/public/application/settings/settings.test.tsx index f16bf62632152..a5e69f233e2df 100644 --- a/x-pack/plugins/ml/public/application/settings/settings.test.tsx +++ b/x-pack/plugins/ml/public/application/settings/settings.test.tsx @@ -22,6 +22,10 @@ jest.mock('../contexts/kibana', () => ({ }, })); +jest.mock('../contexts/kibana/use_create_url', () => ({ + useCreateAndNavigateToMlLink: jest.fn(), +})); + describe('Settings', () => { function runCheckButtonsDisabledTest( canGetFilters: boolean, diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx index deecb9fb45b51..88bf769aa2936 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.tsx @@ -12,26 +12,40 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; +import { useMlUrlGenerator, useNavigateToPath } from '../../../contexts/kibana'; +import { ML_PAGES } from '../../../../../common/constants/ml_url_generator'; -export const TimeseriesexplorerNoJobsFound = () => ( - - - - } - actions={ - - - - } - /> -); +export const TimeseriesexplorerNoJobsFound = () => { + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + + const redirectToJobsManagementPage = async () => { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, + }); + await navigateToPath(path, true); + }; + + return ( + + + + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 4ec7c5cb6d819..ca55bb10b13d5 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -8,11 +8,9 @@ import d3 from 'd3'; import { calculateTextWidth } from './string_utils'; import { MULTI_BUCKET_IMPACT } from '../../../common/constants/multi_bucket_impact'; import moment from 'moment'; -import rison from 'rison-node'; - import { getTimefilter } from './dependency_cache'; - import { CHART_TYPE } from '../explorer/explorer_constants'; +import { ML_PAGES } from '../../../common/constants/ml_url_generator'; export const LINE_CHART_ANOMALY_RADIUS = 7; export const MULTI_BUCKET_SYMBOL_SIZE = 100; // In square pixels for use with d3 symbol.size @@ -212,7 +210,7 @@ export function getChartType(config) { return chartType; } -export function getExploreSeriesLink(series) { +export async function getExploreSeriesLink(mlUrlGenerator, series) { // Open the Single Metric dashboard over the same overall bounds and // zoomed in to the same time as the current chart. const timefilter = getTimefilter(); @@ -227,46 +225,44 @@ export function getExploreSeriesLink(series) { // to identify the particular series to view. // Initially pass them in the mlTimeSeriesExplorer part of the AppState. // TODO - do we want to pass the entities via the filter? - const entityCondition = {}; - series.entityFields.forEach((entity) => { - entityCondition[entity.fieldName] = entity.fieldValue; - }); + let entityCondition; + if (series.entityFields.length > 0) { + entityCondition = {}; + series.entityFields.forEach((entity) => { + entityCondition[entity.fieldName] = entity.fieldValue; + }); + } - // Use rison to build the URL . - const _g = rison.encode({ - ml: { + const url = await mlUrlGenerator.createUrl({ + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { jobIds: [series.jobId], - }, - refreshInterval: { - display: 'Off', - pause: false, - value: 0, - }, - time: { - from: from, - to: to, - mode: 'absolute', - }, - }); - - const _a = rison.encode({ - mlTimeSeriesExplorer: { + refreshInterval: { + display: 'Off', + pause: false, + value: 0, + }, + timeRange: { + from: from, + to: to, + mode: 'absolute', + }, zoom: { from: zoomFrom, to: zoomTo, }, detectorIndex: series.detectorIndex, entities: entityCondition, - }, - query: { - query_string: { - analyze_wildcard: true, - query: '*', + query: { + query_string: { + analyze_wildcard: true, + query: '*', + }, }, }, + excludeBasePath: true, }); - - return `#/timeseriesexplorer?_g=${_g}&_a=${encodeURIComponent(_a)}`; + return url; } export function showMultiBucketAnomalyMarker(point) { diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.test.js b/x-pack/plugins/ml/public/application/util/chart_utils.test.js index b7cf11c088a1e..955dd7cbea0a1 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.test.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.test.js @@ -35,7 +35,6 @@ import { render } from '@testing-library/react'; import { chartLimits, getChartType, - getExploreSeriesLink, getTickValues, getXTransform, isLabelLengthAboveThreshold, @@ -238,20 +237,6 @@ describe('ML - chart utils', () => { }); }); - describe('getExploreSeriesLink', () => { - test('get timeseriesexplorer link', () => { - const link = getExploreSeriesLink(seriesConfig); - const expectedLink = - `#/timeseriesexplorer?_g=(ml:(jobIds:!(population-03)),` + - `refreshInterval:(display:Off,pause:!f,value:0),time:(from:'2017-02-23T00:00:00.000Z',mode:absolute,` + - `to:'2017-02-23T23:59:59.999Z'))&_a=(mlTimeSeriesExplorer%3A(detectorIndex%3A0%2Centities%3A` + - `(nginx.access.remote_ip%3A'72.57.0.53')%2Czoom%3A(from%3A'2017-02-19T20%3A00%3A00.000Z'%2Cto%3A'2017-02-27T04%3A00%3A00.000Z'))` + - `%2Cquery%3A(query_string%3A(analyze_wildcard%3A!t%2Cquery%3A'*')))`; - - expect(link).toBe(expectedLink); - }); - }); - describe('numTicks', () => { test('returns 10 for 1000', () => { expect(numTicks(1000)).toBe(10); diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts index 6a5583ecbb8ac..fa6329b4e8652 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -456,7 +456,7 @@ describe('ML - custom URL utils', () => { ); }); - test('return expected url for Security app', () => { + test('return expected URL for Security app', () => { const urlConfig = { url_name: 'Hosts Details by process name', url_value: @@ -508,6 +508,45 @@ describe('ML - custom URL utils', () => { ); }); + test('return expected URL for Metrics app', () => { + const urlConfig = { + url_name: 'Hosts Details by process name', + url_value: + 'metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))', + }; + + const testRecord = { + job_id: 'hosts_memory_usage', + result_type: 'record', + probability: 0.0001288876418224276, + multi_bucket_impact: -5, + record_score: 88.26287, + initial_record_score: 61.553927615180186, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1599571800000, + function: 'max', + function_description: 'max', + typical: [0.23685835059986396], + actual: [0.258], + field_name: 'system.memory.actual.used.pct', + influencers: [ + { + influencer_field_name: 'host.name', + influencer_field_values: ['gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl'], + }, + ], + 'host.name': ['gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl'], + earliest: '2019-09-08T12:00:00.000Z', + latest: '2019-09-08T14:59:59.999Z', + }; + + expect(getUrlForRecord(urlConfig, testRecord)).toBe( + "metrics/detail/host/gke-dev-next-oblt-dev-next-oblt-pool-404d7f0c-2bfl?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:'2019-09-08T12:00:00.000Z',interval:>=1m,to:'2019-09-08T14:59:59.999Z'))" + ); + }); + test('removes an empty path component with a trailing slash', () => { const urlConfig = { url_name: 'APM', diff --git a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts index 18ba1e4ee337b..cf24fdb22a651 100644 --- a/x-pack/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/plugins/ml/public/application/util/custom_url_utils.ts @@ -117,6 +117,7 @@ function isKibanaUrl(urlConfig: UrlConfig) { urlValue.startsWith('dashboards#/') || urlValue.startsWith('apm#/') || // BrowserRouter based plugins + urlValue.startsWith('metrics/') || urlValue.startsWith('security/') || // Legacy links urlValue.startsWith('siem#/') diff --git a/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts b/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts deleted file mode 100644 index 806626577008e..0000000000000 --- a/x-pack/plugins/ml/public/application/util/get_selected_ids_url.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import rison from 'rison-node'; -import { getBasePath } from './dependency_cache'; - -export enum TAB_IDS { - DATA_FRAME_ANALYTICS = 'data_frame_analytics', - ANOMALY_DETECTION = 'jobs', -} - -function getSelectedIdsUrl(tabId: TAB_IDS, settings: { [key: string]: string | string[] }): string { - // Create url for filtering by job id or group ids for kibana management table - const encoded = rison.encode(settings); - const url = `?mlManagement=${encoded}`; - const basePath = getBasePath(); - - return `${basePath.get()}/app/ml#/${tabId}${url}`; -} - -// Create url for filtering by group ids for kibana management table -export function getGroupIdsUrl(tabId: TAB_IDS, ids: string[]): string { - const settings = { - groupIds: ids, - }; - - return getSelectedIdsUrl(tabId, settings); -} - -// Create url for filtering by job id for kibana management table -export function getJobIdUrl(tabId: TAB_IDS, id: string): string { - const settings = { - jobId: id, - }; - - return getSelectedIdsUrl(tabId, settings); -} diff --git a/x-pack/plugins/ml/public/application/util/recently_accessed.ts b/x-pack/plugins/ml/public/application/util/recently_accessed.ts index ab879e421cb09..04ccd84c561bb 100644 --- a/x-pack/plugins/ml/public/application/util/recently_accessed.ts +++ b/x-pack/plugins/ml/public/application/util/recently_accessed.ts @@ -37,7 +37,7 @@ export function addItemToRecentlyAccessed(page: string, itemId: string, url: str return; } - url = `ml#/${page}/${url}`; + url = url.startsWith('/') ? `/app/ml${url}` : `/app/ml/${page}/${url}`; const recentlyAccessed = getRecentlyAccessed(); recentlyAccessed.add(url, `ML - ${itemId} - ${pageLabel}`, id); } diff --git a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts index c4aebb108e7b9..6a44756412fe3 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/anomaly_detection_urls_generator.ts @@ -11,13 +11,14 @@ import { ExplorerAppState, ExplorerGlobalState, ExplorerUrlState, + MlCommonGlobalState, MlGenericUrlState, TimeSeriesExplorerAppState, TimeSeriesExplorerGlobalState, TimeSeriesExplorerUrlState, } from '../../common/types/ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; -import { createIndexBasedMlUrl } from './common'; +import { createGenericMlUrl } from './common'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; /** * Creates URL to the Anomaly Detection Job management page @@ -30,18 +31,29 @@ export function createAnomalyDetectionJobManagementUrl( if (!params || isEmpty(params)) { return url; } - const { jobId, groupIds } = params; - const queryState: AnomalyDetectionQueryState = { - jobId, - groupIds, - }; + const { jobId, groupIds, globalState } = params; + if (jobId || groupIds) { + const queryState: AnomalyDetectionQueryState = { + jobId, + groupIds, + }; - url = setStateToKbnUrl( - 'mlManagement', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); + url = setStateToKbnUrl( + 'mlManagement', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + } + + if (globalState) { + url = setStateToKbnUrl>( + '_g', + globalState, + { useHash: false, storeInHashQuery: false }, + url + ); + } return url; } @@ -49,13 +61,24 @@ export function createAnomalyDetectionCreateJobSelectType( appBasePath: string, pageState: MlGenericUrlState['pageState'] ): string { - return createIndexBasedMlUrl( + return createGenericMlUrl( appBasePath, ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_TYPE, pageState ); } +export function createAnomalyDetectionCreateJobSelectIndex( + appBasePath: string, + pageState: MlGenericUrlState['pageState'] +): string { + return createGenericMlUrl( + appBasePath, + ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX, + pageState + ); +} + /** * Creates URL to the Anomaly Explorer page */ @@ -75,36 +98,35 @@ export function createExplorerUrl( query, mlExplorerSwimlane = {}, mlExplorerFilter = {}, + globalState, } = params; const appState: Partial = { mlExplorerSwimlane, mlExplorerFilter, }; + let queryState: Partial = {}; + if (globalState) queryState = globalState; if (query) appState.query = query; - if (jobIds) { - const queryState: Partial = { - ml: { - jobIds, - }, + queryState.ml = { + jobIds, }; - - if (timeRange) queryState.time = timeRange; - if (refreshInterval) queryState.refreshInterval = refreshInterval; - - url = setStateToKbnUrl>( - '_g', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); - url = setStateToKbnUrl>( - '_a', - appState, - { useHash: false, storeInHashQuery: false }, - url - ); } + if (refreshInterval) queryState.refreshInterval = refreshInterval; + if (timeRange) queryState.time = timeRange; + + url = setStateToKbnUrl>( + '_g', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + url = setStateToKbnUrl>( + '_a', + appState, + { useHash: false, storeInHashQuery: false }, + url + ); return url; } @@ -120,19 +142,36 @@ export function createSingleMetricViewerUrl( if (!params) { return url; } - const { timeRange, jobIds, refreshInterval, zoom, query, detectorIndex, entities } = params; + const { + timeRange, + jobIds, + refreshInterval, + zoom, + query, + detectorIndex, + forecastId, + entities, + globalState, + } = params; + + let queryState: Partial = {}; + if (globalState) queryState = globalState; - const queryState: TimeSeriesExplorerGlobalState = { - ml: { + if (jobIds) { + queryState.ml = { jobIds, - }, - refreshInterval, - time: timeRange, - }; + }; + } + if (refreshInterval) queryState.refreshInterval = refreshInterval; + if (timeRange) queryState.time = timeRange; const appState: Partial = {}; const mlTimeSeriesExplorer: Partial = {}; + if (forecastId !== undefined) { + mlTimeSeriesExplorer.forecastId = forecastId; + } + if (detectorIndex !== undefined) { mlTimeSeriesExplorer.detectorIndex = detectorIndex; } @@ -146,7 +185,7 @@ export function createSingleMetricViewerUrl( appState.query = { query_string: query, }; - url = setStateToKbnUrl( + url = setStateToKbnUrl>( '_g', queryState, { useHash: false, storeInHashQuery: false }, diff --git a/x-pack/plugins/ml/public/ml_url_generator/common.ts b/x-pack/plugins/ml/public/ml_url_generator/common.ts index 57cfc52045282..f929e513e618a 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/common.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/common.ts @@ -19,37 +19,40 @@ export function extractParams(urlState: UrlState) { * Creates generic index based search ML url * e.g. `jobs/new_job/datavisualizer?index=3da93760-e0af-11ea-9ad3-3bcfc330e42a` */ -export function createIndexBasedMlUrl( +export function createGenericMlUrl( appBasePath: string, page: MlGenericUrlState['page'], pageState: MlGenericUrlState['pageState'] ): string { - const { globalState, appState, index, savedSearchId, ...restParams } = pageState; let url = `${appBasePath}/${page}`; - if (index !== undefined && savedSearchId === undefined) { - url = `${url}?index=${index}`; - } - if (index === undefined && savedSearchId !== undefined) { - url = `${url}?savedSearchId=${savedSearchId}`; - } + if (pageState) { + const { globalState, appState, index, savedSearchId, ...restParams } = pageState; + if (index !== undefined && savedSearchId === undefined) { + url = `${url}?index=${index}`; + } + if (index === undefined && savedSearchId !== undefined) { + url = `${url}?savedSearchId=${savedSearchId}`; + } - if (!isEmpty(restParams)) { - Object.keys(restParams).forEach((key) => { - url = setStateToKbnUrl( - key, - restParams[key], - { useHash: false, storeInHashQuery: false }, - url - ); - }); - } + if (!isEmpty(restParams)) { + Object.keys(restParams).forEach((key) => { + url = setStateToKbnUrl( + key, + restParams[key], + { useHash: false, storeInHashQuery: false }, + url + ); + }); + } - if (globalState) { - url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url); - } - if (appState) { - url = setStateToKbnUrl('_a', appState, { useHash: false, storeInHashQuery: false }, url); + if (globalState) { + url = setStateToKbnUrl('_g', globalState, { useHash: false, storeInHashQuery: false }, url); + } + if (appState) { + url = setStateToKbnUrl('_a', appState, { useHash: false, storeInHashQuery: false }, url); + } } + return url; } diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts index 8cf10a2acb64f..88761edf241a9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/data_frame_analytics_urls_generator.ts @@ -12,6 +12,7 @@ import { DataFrameAnalyticsExplorationUrlState, DataFrameAnalyticsQueryState, DataFrameAnalyticsUrlState, + MlCommonGlobalState, } from '../../common/types/ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; import { setStateToKbnUrl } from '../../../../../src/plugins/kibana_utils/public'; @@ -23,18 +24,28 @@ export function createDataFrameAnalyticsJobManagementUrl( let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_JOBS_MANAGE}`; if (mlUrlGeneratorState) { - const { jobId, groupIds } = mlUrlGeneratorState; - const queryState: Partial = { - jobId, - groupIds, - }; + const { jobId, groupIds, globalState } = mlUrlGeneratorState; + if (jobId || groupIds) { + const queryState: Partial = { + jobId, + groupIds, + }; - url = setStateToKbnUrl>( - 'mlManagement', - queryState, - { useHash: false, storeInHashQuery: false }, - url - ); + url = setStateToKbnUrl>( + 'mlManagement', + queryState, + { useHash: false, storeInHashQuery: false }, + url + ); + } + if (globalState) { + url = setStateToKbnUrl>( + '_g', + globalState, + { useHash: false, storeInHashQuery: false }, + url + ); + } } return url; @@ -50,12 +61,14 @@ export function createDataFrameAnalyticsExplorationUrl( let url = `${appBasePath}/${ML_PAGES.DATA_FRAME_ANALYTICS_EXPLORATION}`; if (mlUrlGeneratorState) { - const { jobId, analysisType } = mlUrlGeneratorState; + const { jobId, analysisType, globalState } = mlUrlGeneratorState; + const queryState: DataFrameAnalyticsExplorationQueryState = { ml: { jobId, analysisType, }, + ...globalState, }; url = setStateToKbnUrl( diff --git a/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts deleted file mode 100644 index 24693df5025d9..0000000000000 --- a/x-pack/plugins/ml/public/ml_url_generator/data_visualizer_urls_generator.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Creates URL to the Data Visualizer page - */ -import { DataVisualizerUrlState, MlGenericUrlState } from '../../common/types/ml_url_generator'; -import { createIndexBasedMlUrl } from './common'; -import { ML_PAGES } from '../../common/constants/ml_url_generator'; - -export function createDataVisualizerUrl( - appBasePath: string, - { page }: DataVisualizerUrlState -): string { - return `${appBasePath}/${page}`; -} - -/** - * Creates URL to the Index Data Visualizer - */ -export function createIndexDataVisualizerUrl( - appBasePath: string, - pageState: MlGenericUrlState['pageState'] -): string { - return createIndexBasedMlUrl(appBasePath, ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER, pageState); -} diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 55bc6d3668de7..754f5bec57a07 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -6,7 +6,7 @@ import { MlUrlGenerator } from './ml_url_generator'; import { ML_PAGES } from '../../common/constants/ml_url_generator'; -import { ANALYSIS_CONFIG_TYPE } from '../../common/types/ml_url_generator'; +import { ANALYSIS_CONFIG_TYPE } from '../../common/constants/data_frame_analytics'; describe('MlUrlGenerator', () => { const urlGenerator = new MlUrlGenerator({ diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts index b69260d8d4157..abec5cc2b7d1e 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.ts @@ -16,6 +16,7 @@ import { MlUrlGeneratorState } from '../../common/types/ml_url_generator'; import { createAnomalyDetectionJobManagementUrl, createAnomalyDetectionCreateJobSelectType, + createAnomalyDetectionCreateJobSelectIndex, createExplorerUrl, createSingleMetricViewerUrl, } from './anomaly_detection_urls_generator'; @@ -23,10 +24,8 @@ import { createDataFrameAnalyticsJobManagementUrl, createDataFrameAnalyticsExplorationUrl, } from './data_frame_analytics_urls_generator'; -import { - createIndexDataVisualizerUrl, - createDataVisualizerUrl, -} from './data_visualizer_urls_generator'; +import { createGenericMlUrl } from './common'; +import { createEditCalendarUrl, createEditFilterUrl } from './settings_urls_generator'; declare module '../../../../../src/plugins/share/public' { export interface UrlGeneratorStateMapping { @@ -44,8 +43,12 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition => { - const appBasePath = this.params.appBasePath; + public readonly createUrl = async ( + mlUrlGeneratorParams: MlUrlGeneratorState + ): Promise => { + const { excludeBasePath, ...mlUrlGeneratorState } = mlUrlGeneratorParams; + const appBasePath = excludeBasePath === true ? '' : this.params.appBasePath; + switch (mlUrlGeneratorState.page) { case ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE: return createAnomalyDetectionJobManagementUrl(appBasePath, mlUrlGeneratorState.pageState); @@ -56,18 +59,39 @@ export class MlUrlGenerator implements UrlGeneratorsDefinition { const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); + if (isMlEnabled(license)) { // add ML to home page if (pluginsSetup.home) { registerFeature(pluginsSetup.home); } + // the mlUrlGenerator should be registered even without full license + // for other plugins to access ML links + registerUrlGenerator(pluginsSetup.share, core); + const { capabilities } = coreStart.application; // register ML for the index pattern management no data screen. @@ -129,7 +134,6 @@ export class MlPlugin implements Plugin { } registerEmbeddables(pluginsSetup.embeddable, core); registerMlUiActions(pluginsSetup.uiActions, core); - registerUrlGenerator(pluginsSetup.share, core); } else if (managementApp) { managementApp.disable(); } diff --git a/x-pack/plugins/ml/public/register_feature.ts b/x-pack/plugins/ml/public/register_feature.ts index 942e9b7f33a40..2332752964f37 100644 --- a/x-pack/plugins/ml/public/register_feature.ts +++ b/x-pack/plugins/ml/public/register_feature.ts @@ -41,7 +41,7 @@ export const registerFeature = (home: HomePublicPluginSetup) => { defaultMessage: 'Import your own CSV, NDJSON, or log file.', }), icon: 'document', - path: '/app/ml#/filedatavisualizer', + path: '/app/ml/filedatavisualizer', showOnHomePage: true, category: FeatureCatalogueCategory.DATA, order: 520, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json new file mode 100644 index 0000000000000..2e57038bbc639 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "metricsApp" +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json new file mode 100644 index 0000000000000..29ac288c0649f --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "metrics_ui_hosts", + "title": "Metrics Hosts", + "description": "Detect anomalous memory and network behavior on hosts.", + "type": "Metricbeat Data", + "logoFile": "logo.json", + "jobs": [ + { + "id": "hosts_memory_usage", + "file": "hosts_memory_usage.json" + }, + { + "id": "hosts_network_in", + "file": "hosts_network_in.json" + }, + { + "id": "hosts_network_out", + "file": "hosts_network_out.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-hosts_memory_usage", + "file": "datafeed_hosts_memory_usage.json", + "job_id": "hosts_memory_usage" + }, + { + "id": "datafeed-hosts_network_in", + "file": "datafeed_hosts_network_in.json", + "job_id": "hosts_network_in" + }, + { + "id": "datafeed-hosts_network_out", + "file": "datafeed_hosts_network_out.json", + "job_id": "hosts_network_out" + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json new file mode 100644 index 0000000000000..db883a6ce36f9 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_memory_usage.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.memory"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json new file mode 100644 index 0000000000000..7eb430632a81f --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_in.json @@ -0,0 +1,40 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "host.name": {"terms": {"field": "host.name", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_in_max": {"max": {"field": "system.network.in.bytes"}}, + "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"in_derivative": "bytes_in_derivative.value"}, + "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json new file mode 100644 index 0000000000000..427cb678ce663 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/datafeed_hosts_network_out.json @@ -0,0 +1,40 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "system.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "host.name": {"terms": {"field": "host.name", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_out_max": {"max": {"field": "system.network.out.bytes"}}, + "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"out_derivative": "bytes_out_derivative.value"}, + "script": "params.out_derivative > 0.0 ? params.out_derivative : 0.0" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json new file mode 100644 index 0000000000000..186c9dcdb27e5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_memory_usage.json @@ -0,0 +1,50 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "hosts", + "metrics" + ], + "description": "Metrics: Hosts - Identify unusual spikes in memory usage across hosts.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max('system.memory.actual.used.pct')", + "function": "max", + "field_name": "system.memory.actual.used.pct", + "custom_rules": [ + { + "actions": [ + "skip_result" + ], + "conditions": [ + { + "applies_to": "actual", + "operator": "lt", + "value": 0.1 + } + ] + } + ] + } + ], + "influencers": [ + "host.name" + ] + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json new file mode 100644 index 0000000000000..0054d90b1df33 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_in.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Hosts - Identify unusual spikes in inbound traffic across hosts.", + "groups": [ + "hosts", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_in_derivative)", + "function": "max", + "field_name": "bytes_in_derivative" + } + ], + "influencers": [ + "host.name" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json new file mode 100644 index 0000000000000..601cc3807c441 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_hosts/ml/hosts_network_out.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Hosts - Identify unusual spikes in outbound traffic across hosts.", + "groups": [ + "hosts", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_out_derivative)", + "function": "max", + "field_name": "bytes_out_derivative" + } + ], + "influencers": [ + "host.name" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-hosts", + "custom_urls": [ + { + "url_name": "Host Metrics", + "url_value": "metrics/detail/host/$host.name$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json new file mode 100644 index 0000000000000..63105a28c0ab1 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "metricsApp" +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json new file mode 100644 index 0000000000000..15336069e092b --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "metrics_ui_k8s", + "title": "Metrics Kubernetes", + "description": "Detect anomalous memory and network behavior on Kubernetes pods.", + "type": "Metricbeat Data", + "logoFile": "logo.json", + "jobs": [ + { + "id": "k8s_memory_usage", + "file": "k8s_memory_usage.json" + }, + { + "id": "k8s_network_in", + "file": "k8s_network_in.json" + }, + { + "id": "k8s_network_out", + "file": "k8s_network_out.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-k8s_memory_usage", + "file": "datafeed_k8s_memory_usage.json", + "job_id": "k8s_memory_usage" + }, + { + "id": "datafeed-k8s_network_in", + "file": "datafeed_k8s_network_in.json", + "job_id": "k8s_network_in" + }, + { + "id": "datafeed-k8s_network_out", + "file": "datafeed_k8s_network_out.json", + "job_id": "k8s_network_out" + } + ] + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json new file mode 100644 index 0000000000000..14590f743528e --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_memory_usage.json @@ -0,0 +1,17 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.uid"}}, + {"exists": {"field": "kubernetes.pod.memory"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json new file mode 100644 index 0000000000000..4fa4c603ea049 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_in.json @@ -0,0 +1,44 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25}, + "aggregations": { + "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_in_max": {"max": {"field": "kubernetes.pod.network.rx.bytes"}}, + "bytes_in_derivative": {"derivative": {"buckets_path": "bytes_in_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"in_derivative": "bytes_in_derivative.value"}, + "script": "params.in_derivative > 0.0 ? params.in_derivative : 0.0" + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json new file mode 100644 index 0000000000000..633dd6bf490e7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/datafeed_k8s_network_out.json @@ -0,0 +1,44 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "indices_options": { + "allow_no_indices": true + }, + "query": { + "bool": { + "must": [ + {"exists": {"field": "kubernetes.pod.network"}} + ] + } + }, + "chunking_config": { + "mode": "manual", + "time_span": "900s" + }, + "aggregations": { + "kubernetes.namespace": {"terms": {"field": "kubernetes.namespace", "size": 25}, + "aggregations": { + "kubernetes.pod.uid": {"terms": {"field": "kubernetes.pod.uid", "size": 100}, + "aggregations": { + "buckets": { + "date_histogram": {"field": "@timestamp","fixed_interval": "5m"}, + "aggregations": { + "@timestamp": {"max": {"field": "@timestamp"}}, + "bytes_out_max": {"max": {"field": "kubernetes.pod.network.tx.bytes"}}, + "bytes_out_derivative": {"derivative": {"buckets_path": "bytes_out_max"}}, + "positive_only":{ + "bucket_script": { + "buckets_path": {"pos_derivative": "bytes_out_derivative.value"}, + "script": "params.pos_derivative > 0.0 ? params.pos_derivative : 0.0" + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json new file mode 100644 index 0000000000000..d3f58086e2fd5 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_memory_usage.json @@ -0,0 +1,53 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "k8s", + "metrics" + ], + "description": "Metrics: Kubernetes - Identify unusual spikes in memory usage across Kubernetes pods.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max('kubernetes.pod.memory.usage.node.pct')", + "function": "max", + "field_name": "kubernetes.pod.memory.usage.node.pct", + "partition_field_name": "kubernetes.namespace", + "custom_rules": [ + { + "actions": [ + "skip_result" + ], + "conditions": [ + { + "applies_to": "actual", + "operator": "lt", + "value": 0.1 + } + ] + } + ] + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.pod.uid" + ] + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json new file mode 100644 index 0000000000000..212b2681beb77 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_in.json @@ -0,0 +1,39 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Kubernetes - Identify unusual spikes in inbound traffic across Kubernetes pods.", + "groups": [ + "k8s", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_in_derivative)", + "function": "max", + "field_name": "bytes_in_derivative", + "partition_field_name": "kubernetes.namespace" + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.pod.uid" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json new file mode 100644 index 0000000000000..b06b0ed5089ef --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/metrics_ui_k8s/ml/k8s_network_out.json @@ -0,0 +1,39 @@ +{ + "job_type": "anomaly_detector", + "description": "Metrics: Kubernetes - Identify unusual spikes in outbound traffic across Kubernetes pods.", + "groups": [ + "k8s", + "metrics" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "max(bytes_out_derivative)", + "function": "max", + "field_name": "bytes_out_derivative", + "partition_field_name": "kubernetes.namespace" + } + ], + "influencers": [ + "kubernetes.namespace", + "kubernetes.pod.uid" + ], + "summary_count_field_name": "doc_count" + }, + "data_description": { + "time_field": "@timestamp" + }, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "custom_settings": { + "created_by": "ml-module-metrics-ui-k8s", + "custom_urls": [ + { + "url_name": "Pod Metrics", + "url_value": "metrics/detail/pod/$kubernetes.pod.uid$?metricTime=(autoReload:!f,refreshInterval:5000,time:(from:%27$earliest$%27,interval:%3E%3D1m,to:%27$latest$%27))" + } + ] + } +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json index 245b7e0819c7d..bb0323ed9ae78 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_auditbeat/ml/linux_rare_user_compiler.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privliege elevation via locally run exploits or malware activity.", + "description": "Security: Auditbeat - Looks for compiler activity by a user context which does not normally run compilers. This can be ad-hoc software changes or unauthorized software deployment. This can also be due to local privilege elevation via locally run exploits or malware activity.", "groups": [ "security", "auditbeat", diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index cf248fcc60896..7224eacf84e90 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -15,6 +15,7 @@ import { CapabilitiesStart, IClusterClient, } from 'kibana/server'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { PluginsSetup, RouteInitialization } from './types'; import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; import { MlCapabilities } from '../common/types/capabilities'; @@ -74,6 +75,7 @@ export class MlServerPlugin implements Plugin new Plugin(initContext); export const config: PluginConfigDescriptor> = { diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index c66adfcabd671..a3ff4b952ce97 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Collector } from '../../../../../../src/plugins/usage_collection/server'; + import { KIBANA_SETTINGS_TYPE } from '../../../common/constants'; import { MonitoringConfig } from '../../config'; @@ -38,11 +40,19 @@ export async function checkForEmailValue( } } +interface EmailSettingData { + xpack: { default_admin_email: string | null }; +} + +export interface KibanaSettingsCollector extends Collector { + getEmailValueStructure(email: string | null): EmailSettingData; +} + export function getSettingsCollector(usageCollection: any, config: MonitoringConfig) { return usageCollection.makeStatsCollector({ type: KIBANA_SETTINGS_TYPE, isReady: () => true, - async fetch() { + async fetch(this: KibanaSettingsCollector) { let kibanaSettingsData; const defaultAdminEmail = await checkForEmailValue(config); @@ -64,7 +74,7 @@ export function getSettingsCollector(usageCollection: any, config: MonitoringCon // returns undefined if there was no result return kibanaSettingsData; }, - getEmailValueStructure(email: string) { + getEmailValueStructure(email: string | null) { return { xpack: { default_admin_email: email, diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts index dcd35b0d323eb..aa4853ab226f4 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/index.ts @@ -8,6 +8,8 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { getSettingsCollector } from './get_settings_collector'; import { MonitoringConfig } from '../../config'; +export { KibanaSettingsCollector } from './get_settings_collector'; + export function registerCollectors( usageCollection: UsageCollectionSetup, config: MonitoringConfig diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index fb0bf4ac530b1..294f52cc3678f 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -21,6 +21,7 @@ import { CustomHttpResponseOptions, ResponseError, } from 'kibana/server'; +import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { LOGGING_TAG, KIBANA_MONITORING_LOGGING_TAG, @@ -245,6 +246,7 @@ export class Plugin { name: i18n.translate('xpack.monitoring.featureRegistry.monitoringFeatureName', { defaultMessage: 'Stack Monitoring', }), + category: DEFAULT_APP_CATEGORIES.management, icon: 'monitoringApp', navLinkId: 'monitoring', app: ['monitoring', 'kibana'], diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index 02e841ec50ee2..b430b7d5959fe 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -11,6 +11,7 @@ import { EuiIconTip, EuiLink, EuiText, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; @@ -80,13 +81,14 @@ export function AlertsSection({ alerts }: Props) { const isLastElement = index === alerts.length - 1; return ( + - {alert.name} + {alert.name} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 80173cd1560d7..0a82f37d10a7b 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { BehaviorSubject } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { AppMountParameters, + AppUpdater, CoreSetup, DEFAULT_APP_CATEGORIES, Plugin as PluginClass, @@ -28,6 +30,8 @@ interface SetupPlugins { export type ObservabilityPluginStart = void; export class Plugin implements PluginClass { + private readonly appUpdater$ = new BehaviorSubject(() => ({})); + constructor(context: PluginInitializerContext) {} public setup(core: CoreSetup, plugins: SetupPlugins) { @@ -37,6 +41,7 @@ export class Plugin implements PluginClass) => { @@ -79,7 +84,7 @@ export class Plugin implements PluginClass { - const update = jest.fn(); - afterEach(() => { - update.mockClear(); + let applicationStart: ReturnType; + let subjectMock: jest.Mocked>; + + beforeEach(() => { + applicationStart = applicationServiceMock.createStartContract(); + subjectMock = { + next: jest.fn(), + } as any; }); + it('hides overview menu', () => { - const core = ({ - application: { - capabilities: { - navLinks: { - apm: false, - logs: false, - metrics: false, - uptime: false, - }, - }, + applicationStart.capabilities = { + management: {}, + catalogue: {}, + navLinks: { + apm: false, + logs: false, + metrics: false, + uptime: false, }, - chrome: { navLinks: { update } }, - } as unknown) as CoreStart; - toggleOverviewLinkInNav(core); - expect(update).toHaveBeenCalledWith('observability-overview', { hidden: true }); + }; + + toggleOverviewLinkInNav(subjectMock, applicationStart); + + expect(subjectMock.next).toHaveBeenCalledTimes(1); + const updater = subjectMock.next.mock.calls[0][0]!; + expect(updater({} as any)).toEqual({ + navLinkStatus: AppNavLinkStatus.hidden, + }); }); it('shows overview menu', () => { - const core = ({ - application: { - capabilities: { - navLinks: { - apm: true, - logs: false, - metrics: false, - uptime: false, - }, - }, + applicationStart.capabilities = { + management: {}, + catalogue: {}, + navLinks: { + apm: true, + logs: false, + metrics: false, + uptime: false, }, - chrome: { navLinks: { update } }, - } as unknown) as CoreStart; - toggleOverviewLinkInNav(core); - expect(update).not.toHaveBeenCalled(); + }; + + toggleOverviewLinkInNav(subjectMock, applicationStart); + + expect(subjectMock.next).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/observability/public/toggle_overview_link_in_nav.tsx b/x-pack/plugins/observability/public/toggle_overview_link_in_nav.tsx index c33ca45e4fcd8..ad8b6a323a897 100644 --- a/x-pack/plugins/observability/public/toggle_overview_link_in_nav.tsx +++ b/x-pack/plugins/observability/public/toggle_overview_link_in_nav.tsx @@ -4,12 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart } from 'kibana/public'; +import { Subject } from 'rxjs'; +import { AppNavLinkStatus, AppUpdater, ApplicationStart } from '../../../../src/core/public'; -export function toggleOverviewLinkInNav(core: CoreStart) { - const { apm, logs, metrics, uptime } = core.application.capabilities.navLinks; +export function toggleOverviewLinkInNav( + updater$: Subject, + { capabilities }: ApplicationStart +) { + const { apm, logs, metrics, uptime } = capabilities.navLinks; const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible); if (!someVisible) { - core.chrome.navLinks.update('observability-overview', { hidden: true }); + updater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, + })); } } diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 79449e5d229b8..07a239494da23 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -54,6 +54,8 @@ export const KBN_SCREENSHOT_HEADER_BLOCK_LIST = [ export const KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN = ['proxy-']; export const UI_SETTINGS_CUSTOM_PDF_LOGO = 'xpackReporting:customPdfLogo'; +export const UI_SETTINGS_CSV_SEPARATOR = 'csv:separator'; +export const UI_SETTINGS_CSV_QUOTE_VALUES = 'csv:quoteValues'; export const PDF_JOB_TYPE = 'printable_pdf'; export const PNG_JOB_TYPE = 'PNG'; diff --git a/x-pack/plugins/reporting/common/schema_utils.ts b/x-pack/plugins/reporting/common/schema_utils.ts new file mode 100644 index 0000000000000..f9b5c90e3c366 --- /dev/null +++ b/x-pack/plugins/reporting/common/schema_utils.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ByteSizeValue } from '@kbn/config-schema'; +import moment from 'moment'; + +/* + * For cleaner code: use these functions when a config schema value could be + * one type or another. This allows you to treat the value as one type. + */ + +export const durationToNumber = (value: number | moment.Duration): number => { + if (typeof value === 'number') { + return value; + } + return value.asMilliseconds(); +}; + +export const byteSizeValueToNumber = (value: number | ByteSizeValue) => { + if (typeof value === 'number') { + return value; + } + + return value.getValueInBytes(); +}; diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 18b0ac2a72802..24c126bfe0571 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -7,7 +7,12 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { ReportingConfigType } from '../server/config'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -export { LayoutInstance } from '../server/lib/layouts'; +import { LayoutParams } from '../server/lib/layouts'; +export { LayoutParams }; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +export { ReportDocument, ReportSource } from '../server/lib/store/report'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +export { BaseParams } from '../server/types'; export type JobId = string; export type JobStatus = @@ -17,45 +22,43 @@ export type JobStatus = | 'processing' | 'failed'; -export interface SourceJob { - _id: JobId; - _source: { - status: JobStatus; - output: { - max_size_reached: boolean; - csv_contains_formulas: boolean; - }; - payload: { - type: string; - title: string; - }; - }; -} - export interface JobContent { content: string; } -export interface JobSummary { - id: JobId; - status: JobStatus; - title: string; - type: string; - maxSizeReached: boolean; - csvContainsFormulas: boolean; -} - -export interface JobStatusBuckets { - completed: JobSummary[]; - failed: JobSummary[]; +export interface ReportApiJSON { + id: string; + index: string; + kibana_name: string; + kibana_id: string; + browser_type: string | undefined; + created_at: string; + priority?: number; + jobtype: string; + created_by: string | false; + timeout?: number; + output?: { + content_type: string; + size: number; + warnings?: string[]; + }; + process_expiration?: string; + completed_at: string | undefined; + payload: { + layout?: LayoutParams; + title: string; + browserTimezone?: string; + }; + meta: { + layout?: string; + objectType: string; + }; + max_attempts: number; + started_at: string | undefined; + attempts: number; + status: string; } -type DownloadLink = string; -export type DownloadReportFn = (jobId: JobId) => DownloadLink; - -type ManagementLink = string; -export type ManagementLinkFn = () => ManagementLink; - export interface PollerOptions { functionToPoll: () => Promise; pollFrequencyInMillis: number; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index 33141eec46299..93f914a78fe10 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "optionalPlugins": [ "security", + "spaces", "usageCollection" ], "configPath": ["xpack", "reporting"], diff --git a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx index 941baa5af6776..068cb7d44b0a1 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx @@ -15,10 +15,11 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; +import React, { Component, Fragment } from 'react'; +import { ReportApiJSON } from '../../../common/types'; import { USES_HEADLESS_JOB_TYPES } from '../../../constants'; -import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client'; +import { ReportingAPIClient } from '../../lib/reporting_api_client'; interface Props { jobId: string; @@ -29,14 +30,14 @@ interface State { isLoading: boolean; isFlyoutVisible: boolean; calloutTitle: string; - info: JobInfo | null; + info: ReportApiJSON | null; error: Error | null; } const NA = 'n/a'; const UNKNOWN = 'unknown'; -const getDimensions = (info: JobInfo): string => { +const getDimensions = (info: ReportApiJSON): string => { const defaultDimensions = { width: null, height: null }; const { width, height } = get(info, 'payload.layout.dimensions', defaultDimensions); if (width && height) { @@ -121,10 +122,6 @@ export class ReportInfoButton extends Component { title: 'Title', description: get(info, 'payload.title') || NA, }, - { - title: 'Type', - description: get(info, 'payload.type') || NA, - }, { title: 'Layout', description: get(info, 'meta.layout') || NA, @@ -263,7 +260,7 @@ export class ReportInfoButton extends Component { private loadInfo = async () => { this.setState({ isLoading: true }); try { - const info: JobInfo = await this.props.apiClient.getInfo(this.props.jobId); + const info: ReportApiJSON = await this.props.apiClient.getInfo(this.props.jobId); if (this.mounted) { this.setState({ isLoading: false, info }); } diff --git a/x-pack/plugins/reporting/public/components/job_download_button.tsx b/x-pack/plugins/reporting/public/components/job_download_button.tsx index 7dff2cafa047b..8cf3ce8644add 100644 --- a/x-pack/plugins/reporting/public/components/job_download_button.tsx +++ b/x-pack/plugins/reporting/public/components/job_download_button.tsx @@ -7,7 +7,8 @@ import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { JobId, JobSummary } from '../../common/types'; +import { JobSummary } from '../'; +import { JobId } from '../../common/types'; interface Props { getUrl: (jobId: JobId) => string; diff --git a/x-pack/plugins/reporting/public/components/job_failure.tsx b/x-pack/plugins/reporting/public/components/job_failure.tsx index 0da67ea367437..8d8f32f692343 100644 --- a/x-pack/plugins/reporting/public/components/job_failure.tsx +++ b/x-pack/plugins/reporting/public/components/job_failure.tsx @@ -9,8 +9,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary, ManagementLinkFn } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobSummary, ManagementLinkFn } from '../../common/types'; export const getFailureToast = ( errorText: string, @@ -22,7 +22,7 @@ export const getFailureToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/components/job_success.tsx index 7f33321ee3645..05cf2c4c5784a 100644 --- a/x-pack/plugins/reporting/public/components/job_success.tsx +++ b/x-pack/plugins/reporting/public/components/job_success.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getSuccessToast = ( ), color: 'success', diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx index e2afae1feaa01..8cccc94e98dcd 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getWarningFormulasToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx index 6c0d6118dfff2..c350eef0e5a54 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getWarningMaxSizeToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 65db13f22788b..cea402d6a98f2 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -6,8 +6,8 @@ import { EuiBasicTable, - EuiFlexItem, EuiFlexGroup, + EuiFlexItem, EuiPageContent, EuiSpacer, EuiText, @@ -23,6 +23,7 @@ import { Subscription } from 'rxjs'; import { ApplicationStart, ToastsSetup } from 'src/core/public'; import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { Poller } from '../../common/poller'; +import { durationToNumber } from '../../common/schema_utils'; import { JobStatuses } from '../../constants'; import { checkLicense } from '../lib/license_check'; import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client'; @@ -40,17 +41,17 @@ export interface Job { type: string; object_type: string; object_title: string; - created_by?: string; + created_by?: string | false; created_at: string; started_at?: string; completed_at?: string; status: string; statusLabel: string; - max_size_reached: boolean; + max_size_reached?: boolean; attempts: number; max_attempts: number; csv_contains_formulas: boolean; - warnings: string[]; + warnings?: string[]; } export interface Props { @@ -183,17 +184,19 @@ class ReportListingUi extends Component { public componentDidMount() { this.mounted = true; + const { pollConfig, license$ } = this.props; + const pollFrequencyInMillis = durationToNumber(pollConfig.jobsRefresh.interval); this.poller = new Poller({ functionToPoll: () => { return this.fetchJobs(); }, - pollFrequencyInMillis: this.props.pollConfig.jobsRefresh.interval, + pollFrequencyInMillis, trailing: false, continuePollingOnError: true, - pollFrequencyErrorMultiplier: this.props.pollConfig.jobsRefresh.intervalErrorMultiplier, + pollFrequencyErrorMultiplier: pollConfig.jobsRefresh.intervalErrorMultiplier, }); this.poller.start(); - this.licenseSubscription = this.props.license$.subscribe(this.licenseHandler); + this.licenseSubscription = license$.subscribe(this.licenseHandler); } private licenseHandler = (license: ILicense) => { diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx index eddf151167be8..22b97f45db186 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -7,10 +7,11 @@ import { EuiButton, EuiCopy, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component, ReactElement } from 'react'; -import url from 'url'; import { ToastsSetup } from 'src/core/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import url from 'url'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; +import { BaseParams } from '../../common/types'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface Props { apiClient: ReportingAPIClient; @@ -19,7 +20,7 @@ interface Props { layoutId: string | undefined; objectId?: string; objectType: string; - getJobParams: () => any; + getJobParams: () => BaseParams; options?: ReactElement; isDirty: boolean; onClose: () => void; diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx index 9fb74a70ff1ac..4a62ab2b76508 100644 --- a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; import { ToastsSetup } from 'src/core/public'; -import { ReportingPanelContent } from './reporting_panel_content'; +import { BaseParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ReportingPanelContent } from './reporting_panel_content'; interface Props { apiClient: ReportingAPIClient; @@ -17,7 +18,7 @@ interface Props { reportType: string; objectId?: string; objectType: string; - getJobParams: () => any; + getJobParams: () => BaseParams; isDirty: boolean; onClose: () => void; } @@ -83,7 +84,7 @@ export class ScreenCapturePanelContent extends Component { ); }; - private handlePrintLayoutChange = (evt: any) => { + private handlePrintLayoutChange = (evt: EuiSwitchEvent) => { this.setState({ usePrintLayout: evt.target.checked }); }; diff --git a/x-pack/plugins/reporting/public/index.ts b/x-pack/plugins/reporting/public/index.ts index 185367a85bdc0..251fd14ee4d57 100644 --- a/x-pack/plugins/reporting/public/index.ts +++ b/x-pack/plugins/reporting/public/index.ts @@ -7,6 +7,7 @@ import { PluginInitializerContext } from 'src/core/public'; import { ReportingPublicPlugin } from './plugin'; import * as jobCompletionNotifications from './lib/job_completion_notifications'; +import { JobId, JobStatus } from '../common/types'; export function plugin(initializerContext: PluginInitializerContext) { return new ReportingPublicPlugin(initializerContext); @@ -14,3 +15,23 @@ export function plugin(initializerContext: PluginInitializerContext) { export { ReportingPublicPlugin as Plugin }; export { jobCompletionNotifications }; + +export interface JobSummary { + id: JobId; + status: JobStatus; + title: string; + jobtype: string; + maxSizeReached?: boolean; + csvContainsFormulas?: boolean; +} + +export interface JobSummarySet { + completed: JobSummary[]; + failed: JobSummary[]; +} + +type DownloadLink = string; +export type DownloadReportFn = (jobId: JobId) => DownloadLink; + +type ManagementLink = string; +export type ManagementLinkFn = () => ManagementLink; diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 6b95a00ea0009..f1d9d747a7236 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -6,20 +6,20 @@ Object { Object { "csvContainsFormulas": false, "id": "job-source-mock1", + "jobtype": undefined, "maxSizeReached": false, "status": "completed", "title": "specimen", - "type": "spectacular", }, ], "failed": Array [ Object { "csvContainsFormulas": false, "id": "job-source-mock2", + "jobtype": undefined, "maxSizeReached": false, "status": "failed", "title": "specimen", - "type": "spectacular", }, ], } @@ -49,9 +49,9 @@ Array [ Object { "csvContainsFormulas": true, "id": "yas3", + "jobtype": "yas", "status": "completed", "title": "Yas", - "type": "yas", } } /> @@ -149,10 +149,10 @@ Array [ job={ Object { "id": "yas2", + "jobtype": "yas", "maxSizeReached": true, "status": "completed", "title": "Yas", - "type": "yas", } } /> @@ -191,9 +191,9 @@ Array [ job={ Object { "id": "yas1", + "jobtype": "yas", "status": "completed", "title": "Yas", - "type": "yas", } } /> diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index 2f813bd811c6c..2853caaaaa1b5 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -7,10 +7,11 @@ import { stringify } from 'query-string'; import rison from 'rison-node'; import { HttpSetup } from 'src/core/public'; -import { JobId, SourceJob } from '../../common/types'; +import { DownloadReportFn, ManagementLinkFn } from '../'; +import { JobId, ReportApiJSON, ReportDocument, ReportSource } from '../../common/types'; import { - API_BASE_URL, API_BASE_GENERATE, + API_BASE_URL, API_LIST_URL, REPORTING_MANAGEMENT_HOME, } from '../../constants'; @@ -18,7 +19,7 @@ import { add } from './job_completion_notifications'; export interface JobQueueEntry { _id: string; - _source: any; + _source: ReportSource; } export interface JobContent { @@ -26,40 +27,6 @@ export interface JobContent { content_type: boolean; } -export interface JobInfo { - kibana_name: string; - kibana_id: string; - browser_type: string; - created_at: string; - priority: number; - jobtype: string; - created_by: string; - timeout: number; - output: { - content_type: string; - size: number; - warnings: string[]; - }; - process_expiration: string; - completed_at: string; - payload: { - layout: { id: string; dimensions: { width: number; height: number } }; - objects: Array<{ relativeUrl: string }>; - type: string; - title: string; - forceNow: string; - browserTimezone: string; - }; - meta: { - layout: string; - objectType: string; - }; - max_attempts: number; - started_at: string; - attempts: number; - status: string; -} - interface JobParams { [paramName: string]: any; } @@ -121,13 +88,13 @@ export class ReportingAPIClient { }); } - public getInfo(jobId: string): Promise { + public getInfo(jobId: string): Promise { return this.http.get(`${API_LIST_URL}/info/${jobId}`, { asSystemRequest: true, }); } - public findForJobIds = (jobIds: JobId[]): Promise => { + public findForJobIds = (jobIds: JobId[]): Promise => { return this.http.fetch(`${API_LIST_URL}/list`, { query: { page: 0, ids: jobIds.join(',') }, method: 'GET', @@ -159,9 +126,10 @@ export class ReportingAPIClient { return resp; }; - public getManagementLink = () => this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); + public getManagementLink: ManagementLinkFn = () => + this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); - public getDownloadLink = (jobId: JobId) => + public getDownloadLink: DownloadReportFn = (jobId: JobId) => this.http.basePath.prepend(`${API_LIST_URL}/download/${jobId}`); /* diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 998f0711b1355..f91517e4397f9 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -6,7 +6,8 @@ import sinon, { stub } from 'sinon'; import { NotificationsStart } from 'src/core/public'; -import { JobSummary, SourceJob } from '../../common/types'; +import { JobSummary } from '../'; +import { ReportDocument } from '../../common/types'; import { ReportingAPIClient } from './reporting_api_client'; import { ReportingNotifierStreamHandler } from './stream_handler'; @@ -23,7 +24,7 @@ const mockJobsFound = [ _source: { status: 'completed', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, { @@ -31,7 +32,7 @@ const mockJobsFound = [ _source: { status: 'failed', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, { @@ -39,14 +40,14 @@ const mockJobsFound = [ _source: { status: 'pending', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, ]; const jobQueueClientMock: ReportingAPIClient = { findForJobIds: async (jobIds: string[]) => { - return mockJobsFound as SourceJob[]; + return mockJobsFound as ReportDocument[]; }, getContent: (): Promise => { return Promise.resolve({ content: 'this is the completed report data' }); @@ -109,7 +110,7 @@ describe('stream handler', () => { { id: 'yas1', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', } as JobSummary, ], @@ -130,7 +131,7 @@ describe('stream handler', () => { { id: 'yas2', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', maxSizeReached: true, } as JobSummary, @@ -152,7 +153,7 @@ describe('stream handler', () => { { id: 'yas3', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', csvContainsFormulas: true, } as JobSummary, @@ -175,7 +176,7 @@ describe('stream handler', () => { { id: 'yas7', title: 'Yas 7', - type: 'yas', + jobtype: 'yas', status: 'failed', } as JobSummary, ], @@ -195,20 +196,20 @@ describe('stream handler', () => { { id: 'yas8', title: 'Yas 8', - type: 'yas', + jobtype: 'yas', status: 'completed', } as JobSummary, { id: 'yas9', title: 'Yas 9', - type: 'yas', + jobtype: 'yas', status: 'completed', csvContainsFormulas: true, } as JobSummary, { id: 'yas10', title: 'Yas 10', - type: 'yas', + jobtype: 'yas', status: 'completed', maxSizeReached: true, } as JobSummary, @@ -217,7 +218,7 @@ describe('stream handler', () => { { id: 'yas13', title: 'Yas 13', - type: 'yas', + jobtype: 'yas', status: 'failed', } as JobSummary, ], diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 80ba02e17d56d..d97c0a7a2d11e 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -8,7 +8,8 @@ import { i18n } from '@kbn/i18n'; import * as Rx from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { NotificationsSetup } from 'src/core/public'; -import { JobId, JobStatusBuckets, JobSummary, SourceJob } from '../../common/types'; +import { JobSummarySet, JobSummary } from '../'; +import { JobId, ReportDocument } from '../../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JOB_STATUS_COMPLETED, @@ -28,14 +29,14 @@ function updateStored(jobIds: JobId[]): void { sessionStorage.setItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JSON.stringify(jobIds)); } -function summarizeJob(src: SourceJob): JobSummary { +function getReportStatus(src: ReportDocument): JobSummary { return { id: src._id, status: src._source.status, title: src._source.payload.title, - type: src._source.payload.type, - maxSizeReached: src._source.output.max_size_reached, - csvContainsFormulas: src._source.output.csv_contains_formulas, + jobtype: src._source.jobtype, + maxSizeReached: src._source.output?.max_size_reached, + csvContainsFormulas: src._source.output?.csv_contains_formulas, }; } @@ -48,7 +49,7 @@ export class ReportingNotifierStreamHandler { public showNotifications({ completed: completedJobs, failed: failedJobs, - }: JobStatusBuckets): Rx.Observable { + }: JobSummarySet): Rx.Observable { const showNotificationsAsync = async () => { // notifications with download link for (const job of completedJobs) { @@ -92,9 +93,9 @@ export class ReportingNotifierStreamHandler { * An observable that finds jobs that are known to be "processing" (stored in * session storage) but have non-processing job status on the server */ - public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable { + public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable { return Rx.from(this.apiClient.findForJobIds(storedJobs)).pipe( - map((jobs: SourceJob[]) => { + map((jobs: ReportDocument[]) => { const completedJobs: JobSummary[] = []; const failedJobs: JobSummary[] = []; const pending: JobId[] = []; @@ -107,9 +108,9 @@ export class ReportingNotifierStreamHandler { } = job; if (storedJobs.includes(jobId)) { if (jobStatus === JOB_STATUS_COMPLETED || jobStatus === JOB_STATUS_WARNINGS) { - completedJobs.push(summarizeJob(job)); + completedJobs.push(getReportStatus(job)); } else if (jobStatus === JOB_STATUS_FAILED) { - failedJobs.push(summarizeJob(job)); + failedJobs.push(getReportStatus(job)); } else { pending.push(jobId); } diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index d003d4c581699..cc5964f737988 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -26,8 +26,10 @@ import { import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; -import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; +import { durationToNumber } from '../common/schema_utils'; +import { JobId, ReportingConfigType } from '../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants'; +import { JobSummarySet } from './'; import { getGeneralErrorToast } from './components'; import { ReportListing } from './components/report_listing'; import { ReportingAPIClient } from './lib/reporting_api_client'; @@ -45,10 +47,7 @@ function getStored(): JobId[] { return sessionValue ? JSON.parse(sessionValue) : []; } -function handleError( - notifications: NotificationsSetup, - err: Error -): Rx.Observable { +function handleError(notifications: NotificationsSetup, err: Error): Rx.Observable { notifications.toasts.addDanger( getGeneralErrorToast( i18n.translate('xpack.reporting.publicNotifier.pollingErrorMessage', { @@ -158,8 +157,7 @@ export class ReportingPublicPlugin implements Plugin { const { http, notifications } = core; const apiClient = new ReportingAPIClient(http); const streamHandler = new StreamHandler(notifications, apiClient); - const { interval } = this.config.poll.jobsRefresh; - + const interval = durationToNumber(this.config.poll.jobsRefresh.interval); Rx.timer(0, interval) .pipe( takeUntil(this.stop$), // stop the interval when stop method is called diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 4ad35fd768825..451d907199c4c 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { JobParamsDiscoverCsv, SearchRequest } from '../../server/export_types/csv/types'; +import { JobParamsCSV, SearchRequest } from '../../server/export_types/csv/types'; import { ReportingPanelContent } from '../components/reporting_panel_content'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -59,7 +59,7 @@ export const csvReportingProvider = ({ return []; } - const jobParams: JobParamsDiscoverCsv = { + const jobParams: JobParamsCSV = { browserTimezone, objectType, title: sharingData.title as string, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index e10d04ea5fc6b..2dab66187bb25 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { LayoutInstance } from '../../common/types'; +import { LayoutParams } from '../../common/types'; import { JobParamsPNG } from '../../server/export_types/png/types'; import { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; @@ -80,7 +80,7 @@ export const reportingPDFPNGProvider = ({ objectType, browserTimezone, relativeUrls: [relativeUrl], // multi URL for PDF - layout: sharingData.layout as LayoutInstance, + layout: sharingData.layout as LayoutParams, title: sharingData.title as string, }; }; @@ -96,7 +96,7 @@ export const reportingPDFPNGProvider = ({ objectType, browserTimezone, relativeUrl, // single URL for PNG - layout: sharingData.layout as LayoutInstance, + layout: sharingData.layout as LayoutParams, title: sharingData.title as string, }; }; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 0a76c7fcfd3b2..04ab572a53dbc 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -10,10 +10,10 @@ import open from 'opn'; import { ElementHandle, EvaluateFn, Page, Response, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; import { getDisallowedOutgoingUrlError } from '../'; +import { ConditionalHeaders, ConditionalHeadersConditions } from '../../../export_types/common'; import { LevelLogger } from '../../../lib'; import { ViewZoomWidthHeight } from '../../../lib/layouts/layout'; import { ElementPosition } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; import { allowRequest, NetworkPolicy } from '../../network_policy'; export interface ChromiumDriverOptions { @@ -34,8 +34,6 @@ interface EvaluateMetaOpts { context: string; } -type ConditionalHeadersConditions = ConditionalHeaders['conditions']; - interface InterceptedRequest { requestId: string; request: { diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 88be86d1ecc30..efef323612322 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -21,6 +21,7 @@ import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; import { getChromiumDisconnectedError } from '../'; import { BROWSER_TYPE } from '../../../../common/constants'; +import { durationToNumber } from '../../../../common/schema_utils'; import { CaptureConfig } from '../../../../server/types'; import { LevelLogger } from '../../../lib'; import { safeChildProcess } from '../../safe_child_process'; @@ -63,7 +64,7 @@ export class HeadlessChromiumDriverFactory { * Return an observable to objects which will drive screenshot capture for a page */ createPage( - { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone: string }, + { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone?: string }, pLogger: LevelLogger ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable }> { return Rx.Observable.create(async (observer: InnerSubscriber) => { @@ -90,7 +91,7 @@ export class HeadlessChromiumDriverFactory { // Set the default timeout for all navigation methods to the openUrl timeout (30 seconds) // All waitFor methods have their own timeout config passed in to them - page.setDefaultTimeout(this.captureConfig.timeouts.openUrl); + page.setDefaultTimeout(durationToNumber(this.captureConfig.timeouts.openUrl)); logger.debug(`Browser page driver created`); } catch (err) { diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index a89b952702e1b..b9c6f8e7591e3 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -17,6 +17,8 @@ export const config: PluginConfigDescriptor = { unused('capture.concurrency'), unused('capture.settleTime'), unused('capture.timeout'), + unused('poll.jobCompletionNotifier.intervalErrorMultiplier'), + unused('poll.jobsRefresh.intervalErrorMultiplier'), unused('kibanaApp'), ], }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts index 69e4d443cf040..9fc3d4329879e 100644 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -8,101 +8,242 @@ import { ConfigSchema } from './schema'; describe('Reporting Config Schema', () => { it(`context {"dev":false,"dist":false} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ - capture: { - browser: { - autoDownload: true, - chromium: { proxy: { enabled: false } }, - type: 'chromium', + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchInlineSnapshot(` + Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "loadDelay": "PT3S", + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + "timeouts": Object { + "openUrl": "PT1M", + "renderComplete": "PT30S", + "waitForElements": "PT30S", + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "escapeFormulaValues": false, + "maxSizeBytes": ByteSizeValue { + "valueInBytes": 10485760, + }, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + "useByteOrderMarkEncoding": false, }, - loadDelay: 3000, - maxAttempts: 1, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": "PT3S", + "pollIntervalErrorMultiplier": 10, + "timeout": "PT2M", + }, + "roles": Object { + "allow": Array [ + "reporting_user", ], }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); + } + `); }); it(`context {"dev":false,"dist":true} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ - capture: { - browser: { - autoDownload: false, - chromium: { - inspect: false, - proxy: { enabled: false }, - }, - type: 'chromium', + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchInlineSnapshot(` + Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "inspect": false, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "loadDelay": "PT3S", + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "host": undefined, + "protocol": "http:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "https:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "ws:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "wss:", + }, + Object { + "allow": true, + "host": undefined, + "protocol": "data:", + }, + Object { + "allow": false, + "host": undefined, + "protocol": undefined, + }, + ], + }, + "timeouts": Object { + "openUrl": "PT1M", + "renderComplete": "PT30S", + "waitForElements": "PT30S", + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "escapeFormulaValues": false, + "maxSizeBytes": ByteSizeValue { + "valueInBytes": 10485760, + }, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + "useByteOrderMarkEncoding": false, }, - loadDelay: 3000, - maxAttempts: 3, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": "PT3S", + "pollIntervalErrorMultiplier": 10, + "timeout": "PT2M", + }, + "roles": Object { + "allow": Array [ + "reporting_user", ], }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); + } + `); + }); + + it('allows Duration values for certain keys', () => { + expect(ConfigSchema.validate({ queue: { timeout: '2m' } }).queue.timeout).toMatchInlineSnapshot( + `"PT2M"` + ); + + expect( + ConfigSchema.validate({ capture: { loadDelay: '3s' } }).capture.loadDelay + ).toMatchInlineSnapshot(`"PT3S"`); + + expect( + ConfigSchema.validate({ + capture: { timeouts: { openUrl: '1m', waitForElements: '30s', renderComplete: '10s' } }, + }).capture.timeouts + ).toMatchInlineSnapshot(` + Object { + "openUrl": "PT1M", + "renderComplete": "PT10S", + "waitForElements": "PT30S", + } + `); + }); + + it('allows ByteSizeValue values for certain keys', () => { + expect(ConfigSchema.validate({ csv: { maxSizeBytes: '12mb' } }).csv.maxSizeBytes) + .toMatchInlineSnapshot(` + ByteSizeValue { + "valueInBytes": 12582912, + } + `); }); it(`allows optional settings`, () => { diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index a81ffd754946b..8276e8b49d348 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; import moment from 'moment'; const KibanaServerSchema = schema.object({ @@ -33,9 +33,13 @@ const KibanaServerSchema = schema.object({ const QueueSchema = schema.object({ indexInterval: schema.string({ defaultValue: 'week' }), pollEnabled: schema.boolean({ defaultValue: true }), - pollInterval: schema.number({ defaultValue: 3000 }), + pollInterval: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 3 }), + }), pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), - timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), + timeout: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ minutes: 2 }), + }), }); const RulesSchema = schema.object({ @@ -46,9 +50,15 @@ const RulesSchema = schema.object({ const CaptureSchema = schema.object({ timeouts: schema.object({ - openUrl: schema.number({ defaultValue: 60000 }), - waitForElements: schema.number({ defaultValue: 30000 }), - renderComplete: schema.number({ defaultValue: 30000 }), + openUrl: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ minutes: 1 }), + }), + waitForElements: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 30 }), + }), + renderComplete: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 30 }), + }), }), networkPolicy: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -68,9 +78,9 @@ const CaptureSchema = schema.object({ width: schema.number({ defaultValue: 1950 }), height: schema.number({ defaultValue: 1200 }), }), - loadDelay: schema.number({ - defaultValue: moment.duration(3, 's').asMilliseconds(), - }), // TODO: use schema.duration + loadDelay: schema.oneOf([schema.number(), schema.duration()], { + defaultValue: moment.duration({ seconds: 3 }), + }), browser: schema.object({ autoDownload: schema.conditional( schema.contextRef('dist'), @@ -116,13 +126,13 @@ const CsvSchema = schema.object({ checkForFormulas: schema.boolean({ defaultValue: true }), escapeFormulaValues: schema.boolean({ defaultValue: false }), enablePanelActionDownload: schema.boolean({ defaultValue: true }), - maxSizeBytes: schema.number({ - defaultValue: 1024 * 1024 * 10, // 10MB - }), // TODO: use schema.byteSize + maxSizeBytes: schema.oneOf([schema.number(), schema.byteSize()], { + defaultValue: ByteSizeValue.parse('10mb'), + }), useByteOrderMarkEncoding: schema.boolean({ defaultValue: false }), scroll: schema.object({ duration: schema.string({ - defaultValue: '30s', + defaultValue: '30s', // this value is passed directly to ES, so string only format is preferred validate(value) { if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { return 'must be a duration string'; @@ -146,18 +156,16 @@ const RolesSchema = schema.object({ const IndexSchema = schema.string({ defaultValue: '.reporting' }); +// Browser side polling: job completion notifier, management table auto-refresh +// NOTE: can not use schema.duration, a bug prevents it being passed to the browser correctly const PollSchema = schema.object({ jobCompletionNotifier: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(10, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + interval: schema.number({ defaultValue: 10000 }), + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused }), jobsRefresh: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(5, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + interval: schema.number({ defaultValue: 5000 }), + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), // unused }), }); diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 3657d323b3edf..c7a1c79748b5b 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Hapi from 'hapi'; import * as Rx from 'rxjs'; import { first, map, take } from 'rxjs/operators'; import { @@ -14,24 +15,27 @@ import { SavedObjectsClientContract, SavedObjectsServiceStart, UiSettingsServiceStart, -} from 'src/core/server'; +} from '../../../../src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; +import { DEFAULT_SPACE_ID } from '../../spaces/common/constants'; +import { SpacesPluginSetup } from '../../spaces/server'; import { ReportingConfig } from './'; import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factory'; -import { checkLicense, getExportTypesRegistry } from './lib'; +import { checkLicense, getExportTypesRegistry, LevelLogger } from './lib'; import { ESQueueInstance } from './lib/create_queue'; import { screenshotsObservableFactory, ScreenshotsObservableFn } from './lib/screenshots'; import { ReportingStore } from './lib/store'; export interface ReportingInternalSetup { + basePath: Pick; + router: IRouter; features: FeaturesPluginSetup; elasticsearch: ElasticsearchServiceSetup; licensing: LicensingPluginSetup; - basePath: BasePath['get']; - router: IRouter; security?: SecurityPluginSetup; + spaces?: SpacesPluginSetup; } export interface ReportingInternalStart { @@ -50,7 +54,7 @@ export class ReportingCore { private exportTypesRegistry = getExportTypesRegistry(); private config?: ReportingConfig; - constructor() {} + constructor(private logger: LevelLogger) {} /* * Register setupDeps @@ -180,9 +184,9 @@ export class ReportingCore { return this.getPluginSetupDeps().elasticsearch; } - public async getSavedObjectsClient(fakeRequest: KibanaRequest) { + private async getSavedObjectsClient(request: KibanaRequest) { const { savedObjects } = await this.getPluginStartDeps(); - return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClientContract; + return savedObjects.getScopedClient(request) as SavedObjectsClientContract; } public async getUiSettingsServiceFactory(savedObjectsClient: SavedObjectsClientContract) { @@ -190,4 +194,48 @@ export class ReportingCore { const scopedUiSettingsService = uiSettingsService.asScopedToClient(savedObjectsClient); return scopedUiSettingsService; } + + public getSpaceId(request: KibanaRequest): string | undefined { + const spacesService = this.getPluginSetupDeps().spaces?.spacesService; + if (spacesService) { + const spaceId = spacesService?.getSpaceId(request); + + if (spaceId !== DEFAULT_SPACE_ID) { + this.logger.info(`Request uses Space ID: ` + spaceId); + return spaceId; + } else { + this.logger.info(`Request uses default Space`); + } + } + } + + public getFakeRequest(baseRequest: object, spaceId?: string) { + const fakeRequest = KibanaRequest.from({ + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + ...baseRequest, + } as Hapi.Request); + + const spacesService = this.getPluginSetupDeps().spaces?.spacesService; + if (spacesService) { + if (spaceId && spaceId !== DEFAULT_SPACE_ID) { + this.logger.info(`Generating request for space: ` + spaceId); + this.getPluginSetupDeps().basePath.set(fakeRequest, `/s/${spaceId}`); + } + } + + return fakeRequest; + } + + public async getUiSettingsClient(request: KibanaRequest) { + const spacesService = this.getPluginSetupDeps().spaces?.spacesService; + const spaceId = this.getSpaceId(request); + if (spacesService && spaceId) { + this.logger.info(`Creating UI Settings Client for space: ${spaceId}`); + } + const savedObjectsClient = await this.getSavedObjectsClient(request); + return await this.getUiSettingsServiceFactory(savedObjectsClient); + } } diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts index 908817a2ccf81..db1e622df4e21 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cryptoFactory, LevelLogger } from '../../lib'; +import { cryptoFactory } from '../../lib'; +import { createMockLevelLogger } from '../../test_helpers'; import { decryptJobHeaders } from './'; +const logger = createMockLevelLogger(); + const encryptHeaders = async (encryptionKey: string, headers: Record) => { const crypto = cryptoFactory(encryptionKey); return await crypto.encrypt(headers); @@ -15,15 +18,11 @@ const encryptHeaders = async (encryptionKey: string, headers: Record { test(`fails if it can't decrypt headers`, async () => { const getDecryptedHeaders = () => - decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', - job: { - headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', - }, - logger: ({ - error: jest.fn(), - } as unknown) as LevelLogger, - }); + decryptJobHeaders( + 'abcsecretsauce', + 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', + logger + ); await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); @@ -36,15 +35,7 @@ describe('headers', () => { }; const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); - const decryptedHeaders = await decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', - job: { - title: 'cool-job-bro', - type: 'csv', - headers: encryptedHeaders, - }, - logger: {} as LevelLogger, - }); + const decryptedHeaders = await decryptJobHeaders('abcsecretsauce', encryptedHeaders, logger); expect(decryptedHeaders).toEqual(headers); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts index 5ab029bfd9f29..131a7936e3463 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts @@ -7,25 +7,13 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory, LevelLogger } from '../../lib'; -interface HasEncryptedHeaders { - headers?: string; -} - -// TODO merge functionality with CSV execute job -export const decryptJobHeaders = async < - JobParamsType, - TaskPayloadType extends HasEncryptedHeaders ->({ - encryptionKey, - job, - logger, -}: { - encryptionKey?: string; - job: TaskPayloadType; - logger: LevelLogger; -}): Promise> => { +export const decryptJobHeaders = async ( + encryptionKey: string | undefined, + headers: string, + logger: LevelLogger +): Promise> => { try { - if (typeof job.headers !== 'string') { + if (typeof headers !== 'string') { throw new Error( i18n.translate('xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage', { defaultMessage: 'Job headers are missing', @@ -33,7 +21,7 @@ export const decryptJobHeaders = async < ); } const crypto = cryptoFactory(encryptionKey); - const decryptedHeaders = (await crypto.decrypt(job.headers)) as Record; + const decryptedHeaders = (await crypto.decrypt(headers)) as Record; return decryptedHeaders; } catch (err) { logger.error(err); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.test.ts index cb792fbd6ae03..0b06beabfd24d 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.test.ts @@ -7,7 +7,7 @@ import { getAbsoluteUrlFactory } from './get_absolute_url'; const defaultOptions = { - defaultBasePath: 'sbp', + basePath: 'sbp', protocol: 'http:', hostname: 'localhost', port: 5601, @@ -64,8 +64,8 @@ test(`uses the provided hash with queryString`, () => { }); test(`uses the provided basePath`, () => { - const getAbsoluteUrl = getAbsoluteUrlFactory(defaultOptions); - const absoluteUrl = getAbsoluteUrl({ basePath: '/s/marketing' }); + const getAbsoluteUrl = getAbsoluteUrlFactory({ ...defaultOptions, basePath: '/s/marketing' }); + const absoluteUrl = getAbsoluteUrl(); expect(absoluteUrl).toBe(`http://localhost:5601/s/marketing/app/kibana`); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.ts b/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.ts index f996a49e5eadc..72305f47e7189 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_absolute_url.ts @@ -7,7 +7,7 @@ import url from 'url'; interface AbsoluteURLFactoryOptions { - defaultBasePath: string; + basePath: string; protocol: string; hostname: string; port: string | number; @@ -17,14 +17,9 @@ export const getAbsoluteUrlFactory = ({ protocol, hostname, port, - defaultBasePath, + basePath, }: AbsoluteURLFactoryOptions) => { - return function getAbsoluteUrl({ - basePath = defaultBasePath, - hash = '', - path = '/app/kibana', - search = '', - } = {}) { + return function getAbsoluteUrl({ hash = '', path = '/app/kibana', search = '' } = {}) { return url.format({ protocol, hostname, diff --git a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts index 754bc7bc75cb5..b1d6f6fdf79c1 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts @@ -4,29 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; import { ReportingConfig } from '../../'; -import { ReportingCore } from '../../core'; -import { createMockReportingCore } from '../../test_helpers'; -import { BasePayload } from '../../types'; -import { TaskPayloadPDF } from '../printable_pdf/types'; -import { getConditionalHeaders, getCustomLogo } from './'; +import { createMockConfig, createMockConfigSchema } from '../../test_helpers'; +import { getConditionalHeaders } from './'; let mockConfig: ReportingConfig; -let mockReportingPlugin: ReportingCore; - -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); beforeEach(async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('custom-hostname'); - mockConfig = getMockConfig(mockConfigGet); - mockReportingPlugin = await createMockReportingCore(mockConfig); + const reportingConfig = { kibanaServer: { hostname: 'custom-hostname' } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); }); describe('conditions', () => { @@ -36,11 +23,7 @@ describe('conditions', () => { baz: 'quix', }; - const conditionalHeaders = await getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); + const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders); expect(conditionalHeaders.conditions.hostname).toEqual( mockConfig.get('kibanaServer', 'hostname') @@ -55,116 +38,13 @@ describe('conditions', () => { }); }); -test('uses basePath from job when creating saved object service', async () => { - const mockGetSavedObjectsClient = jest.fn(); - mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - const conditionalHeaders = await getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); - const jobBasePath = '/sbp/s/marketing'; - await getCustomLogo({ - reporting: mockReportingPlugin, - job: { basePath: jobBasePath } as TaskPayloadPDF, - conditionalHeaders, - config: mockConfig, - }); - - const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; - expect(getBasePath()).toBe(jobBasePath); -}); - -test(`uses basePath from server if job doesn't have a basePath when creating saved object service`, async () => { - const mockGetSavedObjectsClient = jest.fn(); - mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; - - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); - mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); - mockConfig = getMockConfig(mockConfigGet); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - const conditionalHeaders = await getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); - - await getCustomLogo({ - reporting: mockReportingPlugin, - job: {} as TaskPayloadPDF, - conditionalHeaders, - config: mockConfig, - }); - - const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; - expect(getBasePath()).toBe(`/sbp`); - expect(mockGetSavedObjectsClient.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "getBasePath": [Function], - "headers": Object { - "baz": "quix", - "foo": "bar", - }, - "path": "/", - "raw": Object { - "req": Object { - "url": "/", - }, - }, - "route": Object { - "settings": Object {}, - }, - "url": Object { - "href": "/", - }, - }, - ] - `); -}); - describe('config formatting', () => { - test(`lowercases server.host`, async () => { - const mockConfigGet = sinon.stub().withArgs('server', 'host').returns('COOL-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: {}, - config: mockConfig, - }); - expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); - }); - test(`lowercases kibanaServer.hostname`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('GREAT-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - const conditionalHeaders = await getConditionalHeaders({ - job: { - title: 'cool-job-bro', - type: 'csv', - jobParams: { - savedObjectId: 'abc-123', - isImmediate: false, - savedObjectType: 'search', - }, - }, - filteredHeaders: {}, - config: mockConfig, - }); + const reportingConfig = { kibanaServer: { hostname: 'GREAT-HOSTNAME' } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); + + const conditionalHeaders = getConditionalHeaders(mockConfig, {}); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts index ce83323914eb8..d167ac21635b1 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts @@ -5,17 +5,12 @@ */ import { ReportingConfig } from '../../'; -import { ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from './'; -export const getConditionalHeaders = ({ - config, - job, - filteredHeaders, -}: { - config: ReportingConfig; - job: TaskPayloadType; - filteredHeaders: Record; -}) => { +export const getConditionalHeaders = ( + config: ReportingConfig, + filteredHeaders: Record +) => { const { kbnConfig } = config; const [hostname, port, basePath, protocol] = [ config.get('kibanaServer', 'hostname'), diff --git a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts deleted file mode 100644 index ee61d76c8a933..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ReportingConfig, ReportingCore } from '../../'; -import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ConditionalHeaders } from '../../types'; -import { TaskPayloadPDF } from '../printable_pdf/types'; // Logo is PDF only - -export const getCustomLogo = async ({ - reporting, - config, - job, - conditionalHeaders, -}: { - reporting: ReportingCore; - config: ReportingConfig; - job: TaskPayloadPDF; - conditionalHeaders: ConditionalHeaders; -}) => { - const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); - const fakeRequest: any = { - headers: conditionalHeaders.headers, - // This is used by the spaces SavedObjectClientWrapper to determine the existing space. - // We use the basePath from the saved job, which we'll have post spaces being implemented; - // or we use the server base path, which uses the default space - getBasePath: () => job.basePath || serverBasePath, - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - }; - - const savedObjectsClient = await reporting.getSavedObjectsClient(fakeRequest); - const uiSettings = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const logo: string = await uiSettings.get(UI_SETTINGS_CUSTOM_PDF_LOGO); - return { conditionalHeaders, logo }; -}; diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts index 355536000326e..6a4e21b08996e 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts @@ -5,22 +5,12 @@ */ import { ReportingConfig } from '../../'; +import { createMockConfig } from '../../test_helpers'; import { TaskPayloadPNG } from '../png/types'; import { TaskPayloadPDF } from '../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; -interface FullUrlsOpts { - job: TaskPayloadPNG & TaskPayloadPDF; - config: ReportingConfig; -} - let mockConfig: ReportingConfig; -const getMockConfig = (mockConfigGet: jest.Mock) => { - return { - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, - }; -}; beforeEach(() => { const reportingConfig: Record = { @@ -29,16 +19,13 @@ beforeEach(() => { 'kibanaServer.protocol': 'http', 'server.basePath': '/sbp', }; - const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { - return reportingConfig[keys.join('.') as string]; - }); - mockConfig = getMockConfig(mockConfigGet); + mockConfig = createMockConfig(reportingConfig); }); const getMockJob = (base: object) => base as TaskPayloadPNG & TaskPayloadPDF; test(`fails if no URL is passed`, async () => { - const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({})); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -47,11 +34,7 @@ test(`fails if no URL is passed`, async () => { test(`fails if URLs are file-protocols for PNGs`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'file://etc/passwd/#/something'; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` ); @@ -61,11 +44,7 @@ test(`fails if URLs are absolute for PNGs`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` ); @@ -75,13 +54,13 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => - getFullUrls({ - job: getMockJob({ + getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` ); @@ -92,13 +71,13 @@ test(`fails if URLs are absolute for PDF`, async () => { const relativeUrl = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => - getFullUrls({ - job: getMockJob({ + getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` ); @@ -112,22 +91,14 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { 'file://etc/passwd/#/something', ]; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrls, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrls, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` ); }); test(`fails if URL does not route to a visualization`, async () => { - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl: '/app/phoney' }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/phoney' })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` ); @@ -135,10 +106,10 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }) + ); expect(urls[0]).toEqual( 'http://localhost:5601/sbp/app/kibana#/something?forceNow=2000-01-01T00%3A00%3A00.000Z' @@ -148,10 +119,10 @@ test(`adds forceNow to hash's query, if it exists`, async () => { test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }) + ); expect(urls[0]).toEqual( 'http://localhost:5601/sbp/app/kibana#/something?_g=something&forceNow=2000-01-01T00%3A00%3A00.000Z' @@ -159,18 +130,16 @@ test(`appends forceNow to hash's query, if it exists`, async () => { }); test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something' }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something' })); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); }); test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -178,9 +147,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(urls).toEqual([ 'http://localhost:5601/sbp/app/kibana#/something_aaa?forceNow=2000-01-01T00%3A00%3A00.000Z', diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts index d6f472e18bc7b..7621a95083bc7 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts @@ -23,25 +23,14 @@ function isPdfJob(job: TaskPayloadPNG | TaskPayloadPDF): job is TaskPayloadPDF { return (job as TaskPayloadPDF).relativeUrls !== undefined; } -export function getFullUrls({ - config, - job, -}: { - config: ReportingConfig; - job: TaskPayloadPDF | TaskPayloadPNG; -}) { +export function getFullUrls(config: ReportingConfig, job: TaskPayloadPDF | TaskPayloadPNG) { const [basePath, protocol, hostname, port] = [ config.kbnConfig.get('server', 'basePath'), config.get('kibanaServer', 'protocol'), config.get('kibanaServer', 'hostname'), config.get('kibanaServer', 'port'), ] as string[]; - const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: basePath, - protocol, - hostname, - port, - }); + const getAbsoluteUrl = getAbsoluteUrlFactory({ basePath, protocol, hostname, port }); // PDF and PNG job params put in the url differently let relativeUrls: string[] = []; @@ -61,7 +50,6 @@ export function getFullUrls({ const urls = relativeUrls.map((relativeUrl) => { const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); const jobUrl = getAbsoluteUrl({ - basePath: job.basePath, path: parsedRelative.pathname, hash: parsedRelative.hash, search: parsedRelative.search, diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index e0d03eb4864ca..5fa313c8a2fb7 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -6,7 +6,24 @@ export { decryptJobHeaders } from './decrypt_job_headers'; export { getConditionalHeaders } from './get_conditional_headers'; -export { getCustomLogo } from './get_custom_logo'; export { getFullUrls } from './get_full_urls'; export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; + +export interface TimeRangeParams { + timezone: string; + min?: Date | string | number | null; + max?: Date | string | number | null; +} + +export interface ConditionalHeadersConditions { + protocol: string; + hostname: string; + port: number; + basePath: string; +} + +export interface ConditionalHeaders { + headers: Record; + conditions: ConditionalHeadersConditions; +} diff --git a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts index f40651603db8f..1833c2a7c62d7 100644 --- a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts @@ -24,20 +24,9 @@ test(`omits blocked headers`, async () => { trailer: 's are for trucks', }; - const filteredHeaders = await omitBlockedHeaders({ - job: { - title: 'cool-job-bro', - type: 'csv', - jobParams: { - savedObjectId: 'abc-123', - isImmediate: false, - savedObjectType: 'search', - }, - }, - decryptedHeaders: { - ...permittedHeaders, - ...blockedHeaders, - }, + const filteredHeaders = omitBlockedHeaders({ + ...permittedHeaders, + ...blockedHeaders, }); expect(filteredHeaders).toEqual(permittedHeaders); diff --git a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts index 946f033b4b481..09512ae703076 100644 --- a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts @@ -9,13 +9,7 @@ import { KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN, } from '../../../common/constants'; -export const omitBlockedHeaders = ({ - job, - decryptedHeaders, -}: { - job: TaskPayloadType; - decryptedHeaders: Record; -}) => { +export const omitBlockedHeaders = (decryptedHeaders: Record) => { const filteredHeaders: Record = omitBy( decryptedHeaders, (_value, header: string) => diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index be18bd7fff361..cb60b218818f0 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -6,10 +6,11 @@ import { cryptoFactory } from '../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../types'; -import { JobParamsDiscoverCsv } from './types'; +import { IndexPatternSavedObject, JobParamsCSV, TaskPayloadCSV } from './types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); @@ -18,13 +19,14 @@ export const createJobFnFactory: CreateJobFnFactory { - const decryptHeaders = async () => { - try { - if (typeof headers !== 'string') { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage', - { - defaultMessage: 'Job headers are missing', - } - ) - ); - } - return await crypto.decrypt(headers); - } catch (err) { - logger.error(err); - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage', - { - defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', - values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() }, - } - ) - ); // prettier-ignore - } - }; - - return KibanaRequest.from({ - headers: await decryptHeaders(), - // This is used by the spaces SavedObjectClientWrapper to determine the existing space. - // We use the basePath from the saved job, which we'll have post spaces being implemented; - // or we use the server base path, which uses the default space - path: '/', - route: { settings: {} }, - url: { href: '/' }, - app: {}, - raw: { req: { url: '/' } }, - } as Hapi.Request); -}; - export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); return async function runTask(jobId, job, cancellationToken) { @@ -67,16 +21,15 @@ export const runTaskFnFactory: RunTaskFnFactory callAsCurrentUser(endpoint, clientParams, options); - const savedObjectsClient = await reporting.getSavedObjectsClient(fakeRequest); - const uiSettingsClient = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( job, config, diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/get_ui_settings.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/get_ui_settings.ts index 915d5010a4885..1f3354debc305 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/get_ui_settings.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/get_ui_settings.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; import { IUiSettingsClient } from 'kibana/server'; +import { + UI_SETTINGS_CSV_QUOTE_VALUES, + UI_SETTINGS_CSV_SEPARATOR, +} from '../../../../common/constants'; import { ReportingConfig } from '../../../'; import { LevelLogger } from '../../../lib'; @@ -38,8 +42,8 @@ export const getUiSettings = async ( // Separator, QuoteValues const [separator, quoteValues] = await Promise.all([ - client.get('csv:separator'), - client.get('csv:quoteValues'), + client.get(UI_SETTINGS_CSV_SEPARATOR), + client.get(UI_SETTINGS_CSV_QUOTE_VALUES), ]); return { diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index 06aa2434afc3f..2f6df9cd67a75 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -6,11 +6,12 @@ import { i18n } from '@kbn/i18n'; import { IUiSettingsClient } from 'src/core/server'; -import { getFieldFormats } from '../../../services'; import { ReportingConfig } from '../../../'; import { CancellationToken } from '../../../../../../plugins/reporting/common'; import { CSV_BOM_CHARS } from '../../../../common/constants'; +import { byteSizeValueToNumber } from '../../../../common/schema_utils'; import { LevelLogger } from '../../../lib'; +import { getFieldFormats } from '../../../services'; import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../types'; import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; import { createEscapeValue } from './escape_value'; @@ -36,9 +37,7 @@ interface SearchRequest { } export interface GenerateCsvParams { - jobParams: { - browserTimezone: string; - }; + browserTimezone?: string; searchRequest: SearchRequest; indexPatternSavedObject: IndexPatternSavedObject; fields: string[]; @@ -56,15 +55,10 @@ export function createGenerateCsv(logger: LevelLogger) { callEndpoint: EndpointCaller, cancellationToken: CancellationToken ): Promise { - const settings = await getUiSettings( - job.jobParams?.browserTimezone, - uiSettingsClient, - config, - logger - ); + const settings = await getUiSettings(job.browserTimezone, uiSettingsClient, config, logger); const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues); const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; - const builder = new MaxSizeStringBuilder(settings.maxSizeBytes, bom); + const builder = new MaxSizeStringBuilder(byteSizeValueToNumber(settings.maxSizeBytes), bom); const { fields, metaFields, conflictedTypesFields } = job; const header = `${fields.map(escapeValue).join(settings.separator)}\n`; diff --git a/x-pack/plugins/reporting/server/export_types/csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/index.ts index b54844cdf1742..e66cfef18c6e2 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/index.ts @@ -17,12 +17,10 @@ import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; import { createJobFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsDiscoverCsv, TaskPayloadCSV } from './types'; +import { JobParamsCSV, TaskPayloadCSV } from './types'; export const getExportType = (): ExportTypeDefinition< - JobParamsDiscoverCsv, - CreateJobFn, - TaskPayloadCSV, + CreateJobFn, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/csv/lib/get_request.ts b/x-pack/plugins/reporting/server/export_types/csv/lib/get_request.ts deleted file mode 100644 index 09e6becc2baec..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv/lib/get_request.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Crypto } from '@elastic/node-crypto'; -import { i18n } from '@kbn/i18n'; -import Hapi from 'hapi'; -import { KibanaRequest } from '../../../../../../../src/core/server'; -import { LevelLogger } from '../../../lib'; - -export const getRequest = async ( - headers: string | undefined, - crypto: Crypto, - logger: LevelLogger -) => { - const decryptHeaders = async () => { - try { - if (typeof headers !== 'string') { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage', - { - defaultMessage: 'Job headers are missing', - } - ) - ); - } - return await crypto.decrypt(headers); - } catch (err) { - logger.error(err); - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage', - { - defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', - values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() }, - } - ) - ); // prettier-ignore - } - }; - - return KibanaRequest.from({ - headers: await decryptHeaders(), - // This is used by the spaces SavedObjectClientWrapper to determine the existing space. - // We use the basePath from the saved job, which we'll have post spaces being implemented; - // or we use the server base path, which uses the default space - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - } as Hapi.Request); -}; diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index f420d8b033170..78615a0e7b72c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -8,16 +8,6 @@ import { BaseParams, BasePayload } from '../../types'; export type RawValue = string | object | null | undefined; -interface DocValueField { - field: string; - format: string; -} - -interface SortOptions { - order: string; - unmapped_type: string; -} - export interface IndexPatternSavedObject { title: string; timeFieldName: string; @@ -28,23 +18,23 @@ export interface IndexPatternSavedObject { }; } -export interface JobParamsDiscoverCsv extends BaseParams { - indexPatternId: string; - title: string; +interface BaseParamsCSV { searchRequest: SearchRequest; fields: string[]; metaFields: string[]; conflictedTypesFields: string[]; } -export interface TaskPayloadCSV extends BasePayload { - basePath: string; - searchRequest: any; - fields: any; - indexPatternSavedObject: any; - metaFields: any; - conflictedTypesFields: any; -} +export type JobParamsCSV = BaseParamsCSV & + BaseParams & { + indexPatternId: string; + }; + +// CSV create job method converts indexPatternID to indexPatternSavedObject +export type TaskPayloadCSV = BaseParamsCSV & + BasePayload & { + indexPatternSavedObject: IndexPatternSavedObject; + }; export interface SearchRequest { index: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts index 1746792981a21..c780247dd61b3 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts @@ -6,57 +6,40 @@ import { notFound, notImplemented } from 'boom'; import { get } from 'lodash'; -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { RequestHandlerContext } from 'src/core/server'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { cryptoFactory } from '../../lib'; -import { CreateJobFnFactory, TimeRangeParams } from '../../types'; +import { CsvFromSavedObjectRequest } from '../../routes/generate_from_savedobject_immediate'; +import { CreateJobFnFactory } from '../../types'; import { JobParamsPanelCsv, + JobPayloadPanelCsv, SavedObject, SavedObjectReference, SavedObjectServiceError, - SavedSearchObjectAttributesJSON, - SearchPanel, VisObjectAttributesJSON, } from './types'; export type ImmediateCreateJobFn = ( jobParams: JobParamsPanelCsv, - headers: KibanaRequest['headers'], context: RequestHandlerContext, - req: KibanaRequest -) => Promise<{ - type: string; - title: string; - jobParams: JobParamsPanelCsv; -}>; - -interface VisData { - title: string; - visType: string; - panel: SearchPanel; -} + req: CsvFromSavedObjectRequest +) => Promise; export const createJobFnFactory: CreateJobFnFactory = function createJobFactoryFn( reporting, parentLogger ) { - const config = reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); - return async function createJob(jobParams, headers, context, req) { + return async function createJob(jobParams, context, req) { const { savedObjectType, savedObjectId } = jobParams; - const serializedEncryptedHeaders = await crypto.encrypt(headers); - const { panel, title, visType }: VisData = await Promise.resolve() + const panel = await Promise.resolve() .then(() => context.core.savedObjects.client.get(savedObjectType, savedObjectId)) .then(async (savedObject: SavedObject) => { const { attributes, references } = savedObject; - const { - kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON, - } = attributes as SavedSearchObjectAttributesJSON; - const { timerange } = req.body as { timerange: TimeRangeParams }; + const { kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON } = attributes; + const { timerange } = req.body; if (!kibanaSavedObjectMetaJSON) { throw new Error('Could not parse saved object data!'); @@ -85,7 +68,7 @@ export const createJobFnFactory: CreateJobFnFactory = func throw new Error('Could not find index pattern for the saved search!'); } - const sPanel = { + return { attributes: { ...attributes, kibanaSavedObjectMeta: { searchSource }, @@ -93,8 +76,6 @@ export const createJobFnFactory: CreateJobFnFactory = func indexPatternSavedObjectId: indexPatternMeta.id, timerange, }; - - return { panel: sPanel, title: attributes.title, visType: 'search' }; }) .catch((err: Error) => { const boomErr = (err as unknown) as { isBoom: boolean }; @@ -109,11 +90,6 @@ export const createJobFnFactory: CreateJobFnFactory = func throw new Error(`Unable to create a job from saved object data! Error: ${err}`); }); - return { - headers: serializedEncryptedHeaders, - jobParams: { ...jobParams, panel, visType }, - type: visType, - title, - }; + return { ...jobParams, panel }; }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts index 3a5deda176b8c..19348c0a678d7 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts @@ -7,16 +7,11 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { CancellationToken } from '../../../common'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { BasePayload, RunTaskFnFactory, TaskRunResult } from '../../types'; +import { TaskRunResult } from '../../lib/tasks'; +import { RunTaskFnFactory } from '../../types'; import { createGenerateCsv } from '../csv/generate_csv'; import { getGenerateCsvParams } from './lib/get_csv_job'; -import { JobParamsPanelCsv, SearchPanel } from './types'; - -/* - * The run function receives the full request which provides the un-encrypted - * headers, so encrypted headers are not part of these kind of job params - */ -type ImmediateJobParams = Omit, 'headers'>; +import { JobPayloadPanelCsv } from './types'; /* * ImmediateExecuteFn receives the job doc payload because the payload was @@ -24,7 +19,7 @@ type ImmediateJobParams = Omit, 'headers'>; */ export type ImmediateExecuteFn = ( jobId: null, - job: ImmediateJobParams, + job: JobPayloadPanelCsv, context: RequestHandlerContext, req: KibanaRequest ) => Promise; @@ -36,21 +31,16 @@ export const runTaskFnFactory: RunTaskFnFactory = function e const config = reporting.getConfig(); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - return async function runTask(jobId: string | null, jobPayload, context, req) { - // There will not be a jobID for "immediate" generation. - // jobID is only for "queued" jobs - // Use the jobID as a logging tag or "immediate" - const { jobParams } = jobPayload; + return async function runTask(jobId, jobPayload, context, req) { const jobLogger = logger.clone(['immediate']); const generateCsv = createGenerateCsv(jobLogger); - const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; + const { panel, visType } = jobPayload; jobLogger.debug(`Execute job generating [${visType}] csv`); const savedObjectsClient = context.core.savedObjects.client; - - const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const job = await getGenerateCsvParams(jobParams, panel, savedObjectsClient, uiConfig); + const uiSettingsClient = await reporting.getUiSettingsServiceFactory(savedObjectsClient); + const job = await getGenerateCsvParams(jobPayload, panel, savedObjectsClient, uiSettingsClient); const elasticsearch = reporting.getElasticsearchService(); const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); @@ -58,7 +48,7 @@ export const runTaskFnFactory: RunTaskFnFactory = function e const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( job, config, - uiConfig, + uiSettingsClient, callAsCurrentUser, new CancellationToken() // can not be cancelled ); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts index 4b4cfb3f062bf..abe9fbf3e3950 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts @@ -17,7 +17,6 @@ import { ExportTypeDefinition } from '../../types'; import { createJobFnFactory, ImmediateCreateJobFn } from './create_job'; import { ImmediateExecuteFn, runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsPanelCsv } from './types'; /* * These functions are exported to share with the API route handler that @@ -27,9 +26,7 @@ export { createJobFnFactory } from './create_job'; export { runTaskFnFactory } from './execute_job'; export const getExportType = (): ExportTypeDefinition< - JobParamsPanelCsv, ImmediateCreateJobFn, - JobParamsPanelCsv, ImmediateExecuteFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts index 9646d7eecd5b5..acf749584c6cd 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts @@ -13,7 +13,7 @@ describe('Get CSV Job', () => { let mockSavedObjectsClient: any; let mockUiSettingsClient: any; beforeEach(() => { - mockJobParams = { isImmediate: true, savedObjectType: 'search', savedObjectId: '234-ididid' }; + mockJobParams = { savedObjectType: 'search', savedObjectId: '234-ididid' }; mockSearchPanel = { indexPatternSavedObjectId: '123-indexId', attributes: { @@ -45,6 +45,7 @@ describe('Get CSV Job', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "browserTimezone": "PST", "conflictedTypesFields": Array [], "fields": Array [], "indexPatternSavedObject": Object { @@ -57,9 +58,6 @@ describe('Get CSV Job', () => { "timeFieldName": null, "title": null, }, - "jobParams": Object { - "browserTimezone": "PST", - }, "metaFields": Array [], "searchRequest": Object { "body": Object { @@ -99,6 +97,7 @@ describe('Get CSV Job', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "browserTimezone": "PST", "conflictedTypesFields": Array [], "fields": Array [], "indexPatternSavedObject": Object { @@ -111,9 +110,6 @@ describe('Get CSV Job', () => { "timeFieldName": null, "title": null, }, - "jobParams": Object { - "browserTimezone": "PST", - }, "metaFields": Array [], "searchRequest": Object { "body": Object { @@ -156,6 +152,7 @@ describe('Get CSV Job', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "browserTimezone": "Africa/Timbuktu", "conflictedTypesFields": Array [], "fields": Array [], "indexPatternSavedObject": Object { @@ -168,9 +165,6 @@ describe('Get CSV Job', () => { "timeFieldName": null, "title": null, }, - "jobParams": Object { - "browserTimezone": "Africa/Timbuktu", - }, "metaFields": Array [], "searchRequest": Object { "body": Object { @@ -212,6 +206,7 @@ describe('Get CSV Job', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "browserTimezone": "Africa/Timbuktu", "conflictedTypesFields": Array [], "fields": Array [ "@test_time", @@ -226,9 +221,6 @@ describe('Get CSV Job', () => { "timeFieldName": "@test_time", "title": "test search", }, - "jobParams": Object { - "browserTimezone": "Africa/Timbuktu", - }, "metaFields": Array [], "searchRequest": Object { "body": Object { @@ -286,6 +278,7 @@ describe('Get CSV Job', () => { ); expect(result).toMatchInlineSnapshot(` Object { + "browserTimezone": "Africa/Timbuktu", "conflictedTypesFields": Array [], "fields": Array [ "@test_time", @@ -300,9 +293,6 @@ describe('Get CSV Job', () => { "timeFieldName": "@test_time", "title": "test search", }, - "jobParams": Object { - "browserTimezone": "Africa/Timbuktu", - }, "metaFields": Array [], "searchRequest": Object { "body": Object { diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts index 0fc29c5b208d9..1fe64a25ebcaa 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts @@ -12,6 +12,8 @@ import { IIndexPattern, Query, } from '../../../../../../../src/plugins/data/server'; +import { TimeRangeParams } from '../../common'; +import { GenerateCsvParams } from '../../csv/generate_csv'; import { DocValueFields, IndexPatternField, @@ -23,7 +25,6 @@ import { } from '../types'; import { getDataSource } from './get_data_source'; import { getFilters } from './get_filters'; -import { GenerateCsvParams } from '../../csv/generate_csv'; export const getEsQueryConfig = async (config: IUiSettingsClient) => { const configs = await Promise.all([ @@ -49,11 +50,11 @@ export const getGenerateCsvParams = async ( savedObjectsClient: SavedObjectsClientContract, uiConfig: IUiSettingsClient ): Promise => { - let timerange; + let timerange: TimeRangeParams | null; if (jobParams.post?.timerange) { timerange = jobParams.post?.timerange; } else { - timerange = panel.timerange; + timerange = panel.timerange || null; } const { indexPatternSavedObjectId } = panel; const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes; @@ -136,7 +137,7 @@ export const getGenerateCsvParams = async ( }; return { - jobParams: { browserTimezone: timerange.timezone }, + browserTimezone: timerange?.timezone, indexPatternSavedObject, searchRequest, fields: includes, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts index 429b2c518cf14..75e979aa2ec01 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeRangeParams } from '../../../types'; +import { TimeRangeParams } from '../../common'; import { QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; import { getFilters } from './get_filters'; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts index a1b04cca0419d..8827a30d370d4 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts @@ -6,7 +6,7 @@ import { badRequest } from 'boom'; import moment from 'moment-timezone'; -import { TimeRangeParams } from '../../../types'; +import { TimeRangeParams } from '../../common'; import { Filter, QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; export function getFilters( diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts index 9c45d23b13a37..cca79747110d5 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobParamPostPayload, TimeRangeParams } from '../../types'; +import { TimeRangeParams } from '../common'; export interface FakeRequest { - headers: Record; + headers: Record; } -export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { +export interface JobParamsPanelCsvPost { + timerange?: TimeRangeParams; state?: any; } export interface SearchPanel { indexPatternSavedObjectId: string; attributes: SavedSearchObjectAttributes; - timerange: TimeRangeParams; + timerange?: TimeRangeParams; } export interface JobPayloadPanelCsv extends JobParamsPanelCsv { @@ -27,8 +28,7 @@ export interface JobPayloadPanelCsv extends JobParamsPanelCsv { export interface JobParamsPanelCsv { savedObjectType: string; savedObjectId: string; - isImmediate: boolean; - post?: JobParamsPostPayloadPanelCsv; + post?: JobParamsPanelCsvPost; visType?: string; } diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index 173a67ad18edf..eaaa11d461156 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -7,10 +7,11 @@ import { cryptoFactory } from '../../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPNG } from '../types'; +import { JobParamsPNG, TaskPayloadPNG } from '../types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); @@ -25,13 +26,13 @@ export const createJobFnFactory: CreateJobFnFactory>; - -export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFactoryFn( - reporting, - parentLogger -) { +export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); const logger = parentLogger.clone([PNG_JOB_TYPE, 'execute']); @@ -36,11 +34,11 @@ export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFac const generatePngObservable = await generatePngObservableFactory(reporting); const jobLogger = logger.clone([jobId]); const process$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), - map((decryptedHeaders) => omitBlockedHeaders({ job, decryptedHeaders })), - map((filteredHeaders) => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, logger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls(config, job); const hashUrl = urls[0]; if (apmGetAssets) apmGetAssets.end(); @@ -60,7 +58,6 @@ export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFac content_type: 'image/png', content: base64, size: (base64 && base64.length) || 0, - warnings, }; }), diff --git a/x-pack/plugins/reporting/server/export_types/png/index.ts b/x-pack/plugins/reporting/server/export_types/png/index.ts index 1cc6836572b7b..50e09a9984b2c 100644 --- a/x-pack/plugins/reporting/server/export_types/png/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/index.ts @@ -19,9 +19,7 @@ import { metadata } from './metadata'; import { JobParamsPNG, TaskPayloadPNG } from './types'; export const getExportType = (): ExportTypeDefinition< - JobParamsPNG, CreateJobFn, - TaskPayloadPNG, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts index 096d0bd428214..786936d43424c 100644 --- a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts @@ -11,7 +11,7 @@ import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; import { LayoutParams, PreserveLayout } from '../../../lib/layouts'; import { ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders } from '../../common'; export async function generatePngObservableFactory(reporting: ReportingCore) { const getScreenshots = await reporting.getScreenshotsObservable(); @@ -19,7 +19,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { return function generatePngObservable( logger: LevelLogger, url: string, - browserTimezone: string, + browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams ): Rx.Observable<{ base64: string | null; warnings: string[] }> { diff --git a/x-pack/plugins/reporting/server/export_types/png/types.d.ts b/x-pack/plugins/reporting/server/export_types/png/types.d.ts index a747f53861a99..1f99082c757c6 100644 --- a/x-pack/plugins/reporting/server/export_types/png/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/png/types.d.ts @@ -4,20 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BaseParams, BasePayload } from '../../../server/types'; import { LayoutParams } from '../../lib/layouts'; +import { BaseParams, BasePayload } from '../../types'; -// Job params: structure of incoming user request data -export interface JobParamsPNG extends BaseParams { - title: string; +interface BaseParamsPNG { + layout: LayoutParams; + forceNow?: string; relativeUrl: string; } +// Job params: structure of incoming user request data +export type JobParamsPNG = BaseParamsPNG & BaseParams; + // Job payload: structure of stored job data provided by create_job -export interface TaskPayloadPNG extends BasePayload { - basePath?: string; - browserTimezone: string; - forceNow?: string; - layout: LayoutParams; - relativeUrl: string; -} +export type TaskPayloadPNG = BaseParamsPNG & BasePayload; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts index 96e634337e6a9..07eed00401bac 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts @@ -7,10 +7,11 @@ import { cryptoFactory } from '../../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPDF } from '../types'; +import { JobParamsPDF, TaskPayloadPDF } from '../types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); @@ -25,10 +26,10 @@ export const createJobFnFactory: CreateJobFnFactory) => { const getBasePayload = (baseObj: any) => baseObj as TaskPayloadPDF; beforeEach(async () => { - const kbnConfig = { - 'server.basePath': '/sbp', - }; const reportingConfig = { + 'server.basePath': '/sbp', index: '.reports-test', encryptionKey: mockEncryptionKey, 'kibanaServer.hostname': 'localhost', 'kibanaServer.port': 5601, 'kibanaServer.protocol': 'http', }; - const mockReportingConfig = { - get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')], - kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, - }; + const mockSchema = createMockConfigSchema(reportingConfig); + const mockReportingConfig = createMockConfig(mockSchema); mockReporting = await createMockReportingCore(mockReportingConfig); @@ -79,7 +79,7 @@ test(`passes browserTimezone to generatePdf`, async () => { const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; await runTask( 'pdfJobId', @@ -98,7 +98,7 @@ test(`passes browserTimezone to generatePdf`, async () => { test(`returns content_type of application/pdf`, async () => { const logger = getMockLogger(); - const runTask = await runTaskFnFactory(mockReporting, logger); + const runTask = runTaskFnFactory(mockReporting, logger); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = await generatePdfObservableFactory(mockReporting); @@ -117,7 +117,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = await generatePdfObservableFactory(mockReporting); (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await runTask( 'pdfJobId', diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index 7d1bb109f508b..ea0d60a9fad12 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -8,23 +8,21 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; -import { RunTaskFn, RunTaskFnFactory, TaskRunResult } from '../../../types'; +import { TaskRunResult } from '../../../lib/tasks'; +import { RunTaskFn, RunTaskFnFactory } from '../../../types'; import { decryptJobHeaders, getConditionalHeaders, - getCustomLogo, getFullUrls, omitBlockedHeaders, } from '../../common'; import { generatePdfObservableFactory } from '../lib/generate_pdf'; +import { getCustomLogo } from '../lib/get_custom_logo'; import { TaskPayloadPDF } from '../types'; -type QueuedPdfExecutorFactory = RunTaskFnFactory>; - -export const runTaskFnFactory: QueuedPdfExecutorFactory = function executeJobFactoryFn( - reporting, - parentLogger -) { +export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); @@ -39,14 +37,12 @@ export const runTaskFnFactory: QueuedPdfExecutorFactory = function executeJobFac const jobLogger = logger.clone([jobId]); const process$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), - map((decryptedHeaders) => omitBlockedHeaders({ job, decryptedHeaders })), - map((filteredHeaders) => getConditionalHeaders({ config, job, filteredHeaders })), - mergeMap((conditionalHeaders) => - getCustomLogo({ reporting, config, job, conditionalHeaders }) - ), + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, logger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), + mergeMap((conditionalHeaders) => getCustomLogo(reporting, conditionalHeaders, job.spaceId)), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls(config, job); const { browserTimezone, layout, title } = job; if (apmGetAssets) apmGetAssets.end(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts index cf3ec9cdc8c2d..26704693ee489 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts @@ -19,9 +19,7 @@ import { metadata } from './metadata'; import { JobParamsPDF, TaskPayloadPDF } from './types'; export const getExportType = (): ExportTypeDefinition< - JobParamsPDF, CreateJobFn, - TaskPayloadPDF, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 17624c1bedb57..2cf5b69835d1f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -9,9 +9,9 @@ import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; -import { createLayout, LayoutInstance, LayoutParams } from '../../../lib/layouts'; +import { createLayout, LayoutParams } from '../../../lib/layouts'; import { ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders } from '../../common'; // @ts-ignore untyped module import { pdf } from './pdf'; import { getTracker } from './tracker'; @@ -35,7 +35,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { logger: LevelLogger, title: string, urls: string[], - browserTimezone: string, + browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams, logo?: string @@ -43,7 +43,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { const tracker = getTracker(); tracker.startLayout(); - const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; + const layout = createLayout(captureConfig, layoutParams); tracker.endLayout(); tracker.startScreenshots(); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts similarity index 56% rename from x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts rename to x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts index 8c02fdd69de8b..426770d719069 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReportingCore } from '../../core'; -import { createMockReportingCore } from '../../test_helpers'; -import { TaskPayloadPDF } from '../printable_pdf/types'; -import { getConditionalHeaders, getCustomLogo } from './'; - -const mockConfigGet = jest.fn().mockImplementation((key: string) => { - return 'localhost'; -}); -const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; - +import { ReportingConfig, ReportingCore } from '../../../'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../../test_helpers'; +import { getConditionalHeaders } from '../../common'; +import { getCustomLogo } from './get_custom_logo'; + +let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; + beforeEach(async () => { + mockConfig = createMockConfig(createMockConfigSchema()); mockReportingPlugin = await createMockReportingCore(mockConfig); }); @@ -36,18 +38,9 @@ test(`gets logo from uiSettings`, async () => { get: mockGet, }); - const conditionalHeaders = await getConditionalHeaders({ - job: {} as TaskPayloadPDF, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); + const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders); - const { logo } = await getCustomLogo({ - reporting: mockReportingPlugin, - config: mockConfig, - job: {} as TaskPayloadPDF, - conditionalHeaders, - }); + const { logo } = await getCustomLogo(mockReportingPlugin, conditionalHeaders); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); expect(logo).toBe('purple pony'); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts new file mode 100644 index 0000000000000..7bd1637db1379 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReportingCore } from '../../../'; +import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../../common/constants'; +import { ConditionalHeaders } from '../../common'; + +export const getCustomLogo = async ( + reporting: ReportingCore, + conditionalHeaders: ConditionalHeaders, + spaceId?: string +) => { + const fakeRequest = reporting.getFakeRequest({ headers: conditionalHeaders.headers }, spaceId); + const uiSettingsClient = await reporting.getUiSettingsClient(fakeRequest); + + const logo: string = await uiSettingsClient.get(UI_SETTINGS_CUSTOM_PDF_LOGO); + + // continue the pipeline + return { conditionalHeaders, logo }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js index 1042fd66abad7..8840fd524f3e4 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js @@ -104,7 +104,7 @@ class PdfMaker { table: { body: [[img]], }, - layout: 'simpleBorder', + layout: 'noBorder', }; contents.push(wrappedImg); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts index 3020cbb5f28b0..cef5c42856ff1 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts @@ -4,21 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BaseParams, BasePayload } from '../../../server/types'; -import { LayoutInstance, LayoutParams } from '../../lib/layouts'; +import { LayoutParams } from '../../lib/layouts'; +import { BaseParams, BasePayload } from '../../types'; -// Job params: structure of incoming user request data, after being parsed from RISON -export interface JobParamsPDF extends BaseParams { - title: string; +interface BaseParamsPDF { + layout: LayoutParams; + forceNow?: string; relativeUrls: string[]; - layout: LayoutInstance; } +// Job params: structure of incoming user request data, after being parsed from RISON +export type JobParamsPDF = BaseParamsPDF & BaseParams; + // Job payload: structure of stored job data provided by create_job -export interface TaskPayloadPDF extends BasePayload { - basePath?: string; - browserTimezone: string; - forceNow?: string; - layout: LayoutParams; - relativeUrls: string[]; -} +export type TaskPayloadPDF = BaseParamsPDF & BasePayload; diff --git a/x-pack/plugins/reporting/server/lib/check_license.ts b/x-pack/plugins/reporting/server/lib/check_license.ts index a764aa1f1eec6..1f8f66fe9b5ee 100644 --- a/x-pack/plugins/reporting/server/lib/check_license.ts +++ b/x-pack/plugins/reporting/server/lib/check_license.ts @@ -24,9 +24,7 @@ const messages = { }, }; -const makeManagementFeature = ( - exportTypes: Array> -) => { +const makeManagementFeature = (exportTypes: ExportTypeDefinition[]) => { return { id: 'management', checkLicense: (license?: ILicense) => { @@ -59,9 +57,7 @@ const makeManagementFeature = ( }; }; -const makeExportTypeFeature = ( - exportType: ExportTypeDefinition -) => { +const makeExportTypeFeature = (exportType: ExportTypeDefinition) => { return { id: exportType.id, checkLicense: (license?: ILicense) => { diff --git a/x-pack/plugins/reporting/server/lib/create_queue.ts b/x-pack/plugins/reporting/server/lib/create_queue.ts index 2da3d8bd47ccb..ded21d105f2f4 100644 --- a/x-pack/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/plugins/reporting/server/lib/create_queue.ts @@ -5,13 +5,13 @@ */ import { ReportingCore } from '../core'; -import { JobSource, TaskRunResult } from '../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; import { createTaggedLogger } from './esqueue/create_tagged_logger'; import { LevelLogger } from './level_logger'; -import { ReportingStore } from './store'; +import { ReportDocument, ReportingStore } from './store'; +import { TaskRunResult } from './tasks'; interface ESQueueWorker { on: (event: string, handler: any) => void; @@ -32,7 +32,7 @@ export interface ESQueueInstance { // GenericWorkerFn is a generic for ImmediateExecuteFn | ESQueueWorkerExecuteFn, type GenericWorkerFn = ( - jobSource: JobSource, + jobSource: ReportDocument, ...workerRestArgs: any[] ) => void | Promise; diff --git a/x-pack/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/plugins/reporting/server/lib/create_worker.test.ts index 85188c07eeb20..1fcd750849331 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.test.ts @@ -6,7 +6,12 @@ import * as sinon from 'sinon'; import { ReportingConfig, ReportingCore } from '../../server'; -import { createMockReportingCore } from '../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../test_helpers'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -14,16 +19,13 @@ import { Esqueue } from './esqueue'; import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; -const configGetStub = sinon.stub(); -configGetStub.withArgs('queue').returns({ - pollInterval: 3300, - pollIntervalErrorMultiplier: 10, -}); -configGetStub.withArgs('server', 'name').returns('test-server-123'); -configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +const logger = createMockLevelLogger(); +const reportingConfig = { + queue: { pollInterval: 3300, pollIntervalErrorMultiplier: 10 }, + server: { name: 'test-server-123', uuid: 'g9ymiujthvy6v8yrh7567g6fwzgzftzfr' }, +}; const executeJobFactoryStub = sinon.stub(); -const getMockLogger = sinon.stub(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ runTaskFnFactory: executeJobFactoryStub }] @@ -39,18 +41,18 @@ describe('Create Worker', () => { let client: ClientMock; beforeEach(async () => { - mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockReporting = await createMockReportingCore(mockConfig); mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - // @ts-ignore over-riding config manually - mockReporting.config = mockConfig; + client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - const createWorker = createWorkerFactory(mockReporting, getMockLogger()); + const createWorker = createWorkerFactory(mockReporting, logger); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -82,7 +84,7 @@ Object { { runTaskFnFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = createWorkerFactory(mockReporting, getMockLogger()); + const createWorker = createWorkerFactory(mockReporting, logger); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/plugins/reporting/server/lib/create_worker.ts b/x-pack/plugins/reporting/server/lib/create_worker.ts index dd5c560455274..7f03cefdb620e 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.ts @@ -6,12 +6,15 @@ import { CancellationToken } from '../../common'; import { PLUGIN_ID } from '../../common/constants'; +import { durationToNumber } from '../../common/schema_utils'; import { ReportingCore } from '../../server'; import { LevelLogger } from '../../server/lib'; -import { ExportTypeDefinition, JobSource, RunTaskFn } from '../../server/types'; +import { RunTaskFn } from '../../server/types'; import { ESQueueInstance } from './create_queue'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; +import { ReportDocument } from './store'; +import { ReportTaskParams } from './tasks'; export function createWorkerFactory(reporting: ReportingCore, logger: LevelLogger) { const config = reporting.getConfig(); @@ -22,18 +25,16 @@ export function createWorkerFactory(reporting: ReportingCore, log // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { // export type / execute job map - const jobExecutors: Map> = new Map(); + const jobExecutors: Map = new Map(); - for (const exportType of reporting.getExportTypesRegistry().getAll() as Array< - ExportTypeDefinition> - >) { + for (const exportType of reporting.getExportTypesRegistry().getAll()) { const jobExecutor = exportType.runTaskFnFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); } - const workerFn = ( - jobSource: JobSource, - jobParams: TaskPayloadType, + const workerFn = ( + jobSource: ReportDocument, + payload: ReportTaskParams['payload'], cancellationToken: CancellationToken ) => { const { @@ -51,13 +52,13 @@ export function createWorkerFactory(reporting: ReportingCore, log } // pass the work to the jobExecutor - return jobTypeExecutor(jobId, jobParams, cancellationToken); + return jobTypeExecutor(jobId, payload, cancellationToken); }; const workerOptions = { kibanaName, kibanaId, - interval: queueConfig.pollInterval, + interval: durationToNumber(queueConfig.pollInterval), intervalErrorMultiplier: queueConfig.pollIntervalErrorMultiplier, }; const worker = queue.registerWorker(PLUGIN_ID, workerFn, workerOptions); diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index 5acc6e38dddf9..305247e6f8637 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -6,7 +6,8 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { ReportingCore } from '../'; -import { BaseParams, CreateJobFn, ReportingUser } from '../types'; +import { durationToNumber } from '../../common/schema_utils'; +import { BaseParams, ReportingUser } from '../types'; import { LevelLogger } from './'; import { Report } from './store'; @@ -23,6 +24,13 @@ export function enqueueJobFactory( parentLogger: LevelLogger ): EnqueueJobFn { const logger = parentLogger.clone(['queue-job']); + const config = reporting.getConfig(); + const jobSettings = { + timeout: durationToNumber(config.get('queue', 'timeout')), + browser_type: config.get('capture', 'browser', 'type'), + max_attempts: config.get('capture', 'maxAttempts'), + priority: 10, // unused + }; return async function enqueueJob( exportTypeId: string, @@ -31,8 +39,6 @@ export function enqueueJobFactory( context: RequestHandlerContext, request: KibanaRequest ) { - type CreateJobFnType = CreateJobFn; - const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); if (exportType == null) { @@ -40,15 +46,24 @@ export function enqueueJobFactory( } const [createJob, { store }] = await Promise.all([ - exportType.createJobFnFactory(reporting, logger) as CreateJobFnType, + exportType.createJobFnFactory(reporting, logger), reporting.getPluginStartDeps(), ]); - // add encrytped headers - const payload = await createJob(jobParams, context, request); + const job = await createJob(jobParams, context, request); + const pendingReport = new Report({ + jobtype: exportType.jobType, + created_by: user ? user.username : false, + payload: job, + meta: { + objectType: jobParams.objectType, + layout: jobParams.layout?.id, + }, + ...jobSettings, + }); // store the pending report, puts it in the Reporting Management UI table - const report = await store.addReport(exportType.jobType, user, payload); + const report = await store.addReport(pendingReport); logger.info(`Scheduled ${exportType.name} report: ${report._id}`); diff --git a/x-pack/plugins/reporting/server/lib/export_types_registry.ts b/x-pack/plugins/reporting/server/lib/export_types_registry.ts index 1159221a9224e..e93cdba48a26a 100644 --- a/x-pack/plugins/reporting/server/lib/export_types_registry.ts +++ b/x-pack/plugins/reporting/server/lib/export_types_registry.ts @@ -9,21 +9,16 @@ import { getExportType as getTypeCsv } from '../export_types/csv'; import { getExportType as getTypeCsvFromSavedObject } from '../export_types/csv_from_savedobject'; import { getExportType as getTypePng } from '../export_types/png'; import { getExportType as getTypePrintablePdf } from '../export_types/printable_pdf'; -import { ExportTypeDefinition } from '../types'; +import { CreateJobFn, ExportTypeDefinition } from '../types'; -type GetCallbackFn = ( - item: ExportTypeDefinition -) => boolean; -// => ExportTypeDefinition +type GetCallbackFn = (item: ExportTypeDefinition) => boolean; export class ExportTypesRegistry { - private _map: Map> = new Map(); + private _map: Map = new Map(); constructor() {} - register( - item: ExportTypeDefinition - ): void { + register(item: ExportTypeDefinition): void { if (!isString(item.id)) { throw new Error(`'item' must have a String 'id' property `); } @@ -43,35 +38,21 @@ export class ExportTypesRegistry { return this._map.size; } - getById( - id: string - ): ExportTypeDefinition { + getById(id: string): ExportTypeDefinition { if (!this._map.has(id)) { throw new Error(`Unknown id ${id}`); } - return this._map.get(id) as ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType - >; + return this._map.get(id) as ExportTypeDefinition; } - get( - findType: GetCallbackFn - ): ExportTypeDefinition { + get(findType: GetCallbackFn): ExportTypeDefinition { let result; for (const value of this._map.values()) { if (!findType(value)) { continue; // try next value } - const foundResult: ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType - > = value; + const foundResult: ExportTypeDefinition = value; if (result) { throw new Error('Found multiple items matching predicate.'); @@ -88,13 +69,19 @@ export class ExportTypesRegistry { } } +// TODO: Define a 2nd ExportTypeRegistry instance for "immediate execute" report job types only. +// It should not require a `CreateJobFn` for its ExportTypeDefinitions, which only makes sense for async. +// Once that is done, the `any` types below can be removed. + +/* + * @return ExportTypeRegistry: the ExportTypeRegistry instance that should be + * used to register async export type definitions + */ export function getExportTypesRegistry(): ExportTypesRegistry { const registry = new ExportTypesRegistry(); - - /* this replaces the previously async method of registering export types, - * where this would run a directory scan and types would be registered via - * discovery */ - const getTypeFns: Array<() => ExportTypeDefinition> = [ + type CreateFnType = CreateJobFn; // can not specify params types because different type of params are not assignable to each other + type RunFnType = any; // can not specify because ImmediateExecuteFn is not assignable to RunTaskFn + const getTypeFns: Array<() => ExportTypeDefinition> = [ getTypeCsv, getTypeCsvFromSavedObject, getTypePng, diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts index 921d302387edf..585175aac82c5 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts @@ -6,12 +6,11 @@ import { CaptureConfig } from '../../types'; import { LayoutParams, LayoutTypes } from './'; -import { Layout } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { - if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { +export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams) { + if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts index 507b7614072ea..c091339a60582 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/index.ts @@ -53,7 +53,7 @@ export interface Size { export interface LayoutParams { id: string; - dimensions: Size; + dimensions?: Size; selectors?: LayoutSelectorDictionary; } @@ -64,4 +64,4 @@ interface LayoutSelectors { positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise; } -export type LayoutInstance = Layout & LayoutSelectors & Size; +export type LayoutInstance = Layout & LayoutSelectors & Partial; diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css index 12ac5b27c7a4a..35590df90fbb9 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.css @@ -36,7 +36,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts index e8d182dac0b1d..cecd761fbcf32 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts @@ -12,12 +12,13 @@ import { LayoutTypes, PageSizeParams, Size, + LayoutInstance, } from './'; // We use a zoom of two to bump up the resolution of the screenshot a bit. const ZOOM: number = 2; -export class PreserveLayout extends Layout { +export class PreserveLayout extends Layout implements LayoutInstance { public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); public readonly groupCount = 1; public readonly height: number; diff --git a/x-pack/plugins/reporting/server/lib/layouts/print.css b/x-pack/plugins/reporting/server/lib/layouts/print.css index 9b07e3c923138..3ff39974536d2 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print.css +++ b/x-pack/plugins/reporting/server/lib/layouts/print.css @@ -35,7 +35,7 @@ filter-bar, /* hide unusable controls */ discover-app .dscTimechart, discover-app .dscSidebar__container, -discover-app .kbnCollapsibleSidebar__collapseButton, +discover-app .dscCollapsibleSidebar__collapseButton, discover-app .discover-table-footer { display: none; } diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts index b055fae8a780d..33f16bc7865d5 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts @@ -9,10 +9,16 @@ import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { LevelLogger } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { getDefaultLayoutSelectors, LayoutSelectorDictionary, LayoutTypes, Size } from './'; +import { + getDefaultLayoutSelectors, + LayoutInstance, + LayoutSelectorDictionary, + LayoutTypes, + Size, +} from './'; import { Layout } from './layout'; -export class PrintLayout extends Layout { +export class PrintLayout extends Layout implements LayoutInstance { public readonly selectors: LayoutSelectorDictionary = { ...getDefaultLayoutSelectors(), screenshot: '[data-shared-item]', diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts index 49c690e8c024d..89cb4221c96b2 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts @@ -5,9 +5,10 @@ */ import { i18n } from '@kbn/i18n'; +import { durationToNumber } from '../../../common/schema_utils'; +import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { LevelLogger, startTrace } from '../'; import { LayoutInstance } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; @@ -31,9 +32,10 @@ export const getNumberOfItems = async ( // the dashboard is using the `itemsCountAttribute` attribute to let us // know how many items to expect since gridster incrementally adds panels // we have to use this hint to wait for all of them + const timeout = durationToNumber(captureConfig.timeouts.waitForElements); await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: captureConfig.timeouts.waitForElements }, + { timeout }, { context: CONTEXT_READMETADATA }, logger ); @@ -59,6 +61,7 @@ export const getNumberOfItems = async ( logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts index afd6364454835..5f7919df4e9fd 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts @@ -5,7 +5,7 @@ */ import { LevelLogger, startTrace } from '../'; -import { LayoutInstance } from '../../../common/types'; +import { LayoutInstance } from '../layouts'; import { HeadlessChromiumDriver } from '../../browsers'; import { CONTEXT_GETTIMERANGE } from './constants'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index c1d33cb519384..1b9722fb49458 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -6,7 +6,7 @@ import * as Rx from 'rxjs'; import { LevelLogger } from '../'; -import { ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from '../../export_types/common'; import { LayoutInstance } from '../layouts'; export { screenshotsObservableFactory } from './observable'; @@ -16,7 +16,7 @@ export interface ScreenshotObservableOpts { urls: string[]; conditionalHeaders: ConditionalHeaders; layout: LayoutInstance; - browserTimezone: string; + browserTimezone?: string; } export interface AttributesMap { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts index f893951815e9e..2fc711d4d6f07 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/inject_css.ts @@ -43,6 +43,7 @@ export const injectCustomCss = async ( logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.injectCss', { defaultMessage: `An error occurred when trying to update Kibana CSS for reporting. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 3749e4372bdab..798f926cd0a31 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -15,12 +15,17 @@ jest.mock('../../browsers/chromium/puppeteer', () => ({ }), })); +import moment from 'moment'; import * as Rx from 'rxjs'; -import { LevelLogger } from '../'; -import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { HeadlessChromiumDriver } from '../../browsers'; -import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../test_helpers'; -import { CaptureConfig, ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from '../../export_types/common'; +import { + createMockBrowserDriverFactory, + createMockConfig, + createMockConfigSchema, + createMockLayoutInstance, + createMockLevelLogger, +} from '../../test_helpers'; import { ElementsPositionAndAttribute } from './'; import * as contexts from './constants'; import { screenshotsObservableFactory } from './observable'; @@ -28,11 +33,22 @@ import { screenshotsObservableFactory } from './observable'; /* * Mocks */ -const mockLogger = jest.fn(loggingSystemMock.create); -const logger = new LevelLogger(mockLogger()); +const logger = createMockLevelLogger(); -const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; -const mockLayout = createMockLayoutInstance(mockConfig); +const reportingConfig = { + capture: { + loadDelay: moment.duration(2, 's'), + timeouts: { + openUrl: moment.duration(2, 'm'), + waitForElements: moment.duration(20, 's'), + renderComplete: moment.duration(10, 's'), + }, + }, +}; +const mockSchema = createMockConfigSchema(reportingConfig); +const mockConfig = createMockConfig(mockSchema); +const captureConfig = mockConfig.get('capture'); +const mockLayout = createMockLayoutInstance(captureConfig); /* * Tests @@ -45,7 +61,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -106,7 +122,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -205,7 +221,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -300,7 +316,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -333,7 +349,7 @@ describe('Screenshot Observable Pipeline', () => { mockLayout.getViewport = () => null; // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(captureConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index c21ef3b91fab3..e8b7f91764efd 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -5,9 +5,11 @@ */ import { i18n } from '@kbn/i18n'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig, ConditionalHeaders } from '../../types'; import { LevelLogger, startTrace } from '../'; +import { durationToNumber } from '../../../common/schema_utils'; +import { HeadlessChromiumDriver } from '../../browsers'; +import { ConditionalHeaders } from '../../export_types/common'; +import { CaptureConfig } from '../../types'; export const openUrl = async ( captureConfig: CaptureConfig, @@ -19,16 +21,14 @@ export const openUrl = async ( ): Promise => { const endTrace = startTrace('open_url', 'wait'); try { + const timeout = durationToNumber(captureConfig.timeouts.openUrl); await browser.open( url, - { - conditionalHeaders, - waitForSelector: pageLoadSelector, - timeout: captureConfig.timeouts.openUrl, - }, + { conditionalHeaders, waitForSelector: pageLoadSelector, timeout }, logger ); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index f36a7b6f73664..edd4f71b2adac 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; import { LevelLogger, startTrace } from '../'; @@ -67,7 +68,7 @@ export const waitForRenderComplete = async ( return Promise.all(renderedTasks).then(hackyWaitForVisualizations); }, - args: [layout.selectors.renderComplete, captureConfig.loadDelay], + args: [layout.selectors.renderComplete, durationToNumber(captureConfig.loadDelay)], }, { context: CONTEXT_WAITFORRENDER }, logger diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts index 779d00442522d..5f86a2b3bf00b 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts @@ -5,8 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { HeadlessChromiumDriver } from '../../browsers'; +import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; +import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -25,7 +26,7 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { export const waitForVisualizations = async ( captureConfig: CaptureConfig, browser: HeadlessChromiumDriver, - itemsCount: number, + toEqual: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { @@ -35,29 +36,26 @@ export const waitForVisualizations = async ( logger.debug( i18n.translate('xpack.reporting.screencapture.waitingForRenderedElements', { defaultMessage: `waiting for {itemsCount} rendered elements to be in the DOM`, - values: { itemsCount }, + values: { itemsCount: toEqual }, }) ); try { + const timeout = durationToNumber(captureConfig.timeouts.renderComplete); await browser.waitFor( - { - fn: getCompletedItemsCount, - args: [{ renderCompleteSelector }], - toEqual: itemsCount, - timeout: captureConfig.timeouts.renderComplete, - }, + { fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual, timeout }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger ); - logger.debug(`found ${itemsCount} rendered elements in the DOM`); + logger.debug(`found ${toEqual} rendered elements in the DOM`); } catch (err) { + logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, values: { - count: itemsCount, + count: toEqual, configKey: 'xpack.reporting.capture.timeouts.renderComplete', error: err, }, diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts index a88d36d3fdf9a..a48f266120323 100644 --- a/x-pack/plugins/reporting/server/lib/store/index.ts +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Report } from './report'; +export { Report, ReportDocument } from './report'; export { ReportingStore } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts index 71ce0b1e572f8..7b8b851f5bd72 100644 --- a/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts +++ b/x-pack/plugins/reporting/server/lib/store/index_timestamp.ts @@ -8,7 +8,6 @@ import moment, { unitOfTime } from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; -// TODO: This helper function can be removed by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr: string, separator = '-') { const startOf = intervalStr as unitOfTime.StartOf; if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index 9ac5d1f87c387..1e4a833c7cabe 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -14,7 +14,8 @@ describe('Class Report', () => { created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { headers: 'payload_test_field', objectType: 'testOt', title: 'cool report' }, + meta: { objectType: 'test' }, timeout: 30000, priority: 1, }); @@ -25,11 +26,10 @@ describe('Class Report', () => { attempts: 0, browser_type: 'browser_type_test_string', completed_at: undefined, - created_at: undefined, created_by: 'created_by_test_string', jobtype: 'test-report', max_attempts: 50, - meta: undefined, + meta: { objectType: 'test' }, payload: { headers: 'payload_test_field', objectType: 'testOt' }, priority: 1, started_at: undefined, @@ -38,12 +38,16 @@ describe('Class Report', () => { }, }); expect(report.toApiJSON()).toMatchObject({ + attempts: 0, browser_type: 'browser_type_test_string', created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', jobtype: 'test-report', max_attempts: 50, payload: { headers: 'payload_test_field', objectType: 'testOt' }, + meta: { objectType: 'test' }, priority: 1, + status: 'pending', timeout: 30000, }); @@ -57,7 +61,8 @@ describe('Class Report', () => { created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { headers: 'payload_test_field', objectType: 'testOt', title: 'hot report' }, + meta: { objectType: 'stange' }, timeout: 30000, priority: 1, }); @@ -70,51 +75,46 @@ describe('Class Report', () => { }; report.updateWithEsDoc(metadata); - expect(report.toEsDocsJSON()).toMatchInlineSnapshot(` - Object { - "_id": "12342p9o387549o2345", - "_index": ".reporting-test-update", - "_source": Object { - "attempts": 0, - "browser_type": "browser_type_test_string", - "completed_at": undefined, - "created_at": undefined, - "created_by": "created_by_test_string", - "jobtype": "test-report", - "max_attempts": 50, - "meta": undefined, - "payload": Object { - "headers": "payload_test_field", - "objectType": "testOt", - }, - "priority": 1, - "started_at": undefined, - "status": "pending", - "timeout": 30000, - }, - } - `); - expect(report.toApiJSON()).toMatchInlineSnapshot(` - Object { - "attempts": 0, - "browser_type": "browser_type_test_string", - "completed_at": undefined, - "created_at": undefined, - "created_by": "created_by_test_string", - "id": "12342p9o387549o2345", - "index": ".reporting-test-update", - "jobtype": "test-report", - "max_attempts": 50, - "meta": undefined, - "payload": Object { - "headers": "payload_test_field", - "objectType": "testOt", - }, - "priority": 1, - "started_at": undefined, - "status": "pending", - "timeout": 30000, - } - `); + expect(report.toEsDocsJSON()).toMatchObject({ + _id: '12342p9o387549o2345', + _index: '.reporting-test-update', + _source: { + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { objectType: 'testOt' }, + priority: 1, + started_at: undefined, + status: 'pending', + timeout: 30000, + }, + }); + expect(report.toApiJSON()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + id: '12342p9o387549o2345', + index: '.reporting-test-update', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + priority: 1, + started_at: undefined, + status: 'pending', + timeout: 30000, + }); + }); + + it('throws error if converted to task JSON before being synced with ES storage', () => { + const report = new Report({} as any); + expect(() => report.updateWithEsDoc(report)).toThrowErrorMatchingInlineSnapshot( + `"Report object from ES has missing fields!"` + ); }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 5c9b9ced7cce7..d82b90f4025ed 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -4,84 +4,96 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; // @ts-ignore no module definition import Puid from 'puid'; +import { JobStatus, ReportApiJSON } from '../../../common/types'; import { JobStatuses } from '../../../constants'; -import { LayoutInstance } from '../layouts'; +import { LayoutParams } from '../layouts'; +import { TaskRunResult } from '../tasks'; -/* - * The document created by Reporting to store in the .reporting index - */ -interface ReportingDocument { +interface ReportDocumentHead { _id: string; _index: string; _seq_no: unknown; _primary_term: unknown; +} + +/* + * The document created by Reporting to store in the .reporting index + */ +export interface ReportDocument extends ReportDocumentHead { + _source: ReportSource; +} + +export interface ReportSource { jobtype: string; + kibana_name: string; + kibana_id: string; created_by: string | false; payload: { headers: string; // encrypted headers + browserTimezone?: string; // may use timezone from advanced settings objectType: string; - layout?: LayoutInstance; + title: string; + layout?: LayoutParams; }; - meta: unknown; + meta: { objectType: string; layout?: string }; browser_type: string; max_attempts: number; timeout: number; - status: string; + status: JobStatus; attempts: number; - output?: unknown; + output: TaskRunResult | null; started_at?: string; completed_at?: string; - created_at?: string; + created_at: string; priority?: number; process_expiration?: string; } -/* - * The document created by Reporting to store as task parameters for Task - * Manager to reference the report in .reporting - */ const puid = new Puid(); -export class Report implements Partial { +export class Report implements Partial { public _index?: string; public _id: string; public _primary_term?: unknown; // set by ES public _seq_no: unknown; // set by ES - public readonly jobtype: string; - public readonly created_at?: string; - public readonly created_by?: string | false; - public readonly payload: { - headers: string; // encrypted headers - objectType: string; - layout?: LayoutInstance; - }; - public readonly meta: unknown; - public readonly max_attempts: number; - public readonly browser_type?: string; - - public readonly status: string; - public readonly attempts: number; - public readonly output?: unknown; - public readonly started_at?: string; - public readonly completed_at?: string; - public readonly process_expiration?: string; - public readonly priority?: number; - public readonly timeout?: number; + public readonly kibana_name: ReportSource['kibana_name']; + public readonly kibana_id: ReportSource['kibana_id']; + public readonly jobtype: ReportSource['jobtype']; + public readonly created_at: ReportSource['created_at']; + public readonly created_by: ReportSource['created_by']; + public readonly payload: ReportSource['payload']; + + public readonly meta: ReportSource['meta']; + public readonly max_attempts: ReportSource['max_attempts']; + public readonly browser_type?: ReportSource['browser_type']; + + public readonly status: ReportSource['status']; + public readonly attempts: ReportSource['attempts']; + public readonly output?: ReportSource['output']; + public readonly started_at?: ReportSource['started_at']; + public readonly completed_at?: ReportSource['completed_at']; + public readonly process_expiration?: ReportSource['process_expiration']; + public readonly priority?: ReportSource['priority']; + public readonly timeout?: ReportSource['timeout']; /* * Create an unsaved report + * Index string is required */ - constructor(opts: Partial) { + constructor(opts: Partial & Partial) { this._id = opts._id != null ? opts._id : puid.generate(); this._index = opts._index; this._primary_term = opts._primary_term; this._seq_no = opts._seq_no; this.payload = opts.payload!; + this.kibana_name = opts.kibana_name!; + this.kibana_id = opts.kibana_id!; this.jobtype = opts.jobtype!; this.max_attempts = opts.max_attempts!; this.attempts = opts.attempts || 0; @@ -89,9 +101,9 @@ export class Report implements Partial { this.process_expiration = opts.process_expiration; this.timeout = opts.timeout; - this.created_at = opts.created_at; - this.created_by = opts.created_by; - this.meta = opts.meta; + this.created_at = opts.created_at || moment.utc().toISOString(); + this.created_by = opts.created_by || false; + this.meta = opts.meta || { objectType: 'unknown' }; this.browser_type = opts.browser_type; this.priority = opts.priority; @@ -141,10 +153,12 @@ export class Report implements Partial { /* * Data structure for API responses */ - toApiJSON() { + toApiJSON(): ReportApiJSON { return { id: this._id, - index: this._index, + index: this._index!, + kibana_name: this.kibana_name, + kibana_id: this.kibana_id, jobtype: this.jobtype, created_at: this.created_at, created_by: this.created_by, diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index b87466ca289cf..931eae8b246c4 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -7,15 +7,15 @@ import sinon from 'sinon'; import { ElasticsearchServiceSetup } from 'src/core/server'; import { ReportingConfig, ReportingCore } from '../..'; -import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers'; +import { + createMockConfig, + createMockConfigSchema, + createMockLevelLogger, + createMockReportingCore, +} from '../../test_helpers'; import { Report } from './report'; import { ReportingStore } from './store'; -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); - describe('ReportingStore', () => { const mockLogger = createMockLevelLogger(); let mockConfig: ReportingConfig; @@ -25,10 +25,12 @@ describe('ReportingStore', () => { const mockElasticsearch = { legacy: { client: { callAsInternalUser: callClusterStub } } }; beforeEach(async () => { - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('index').returns('.reporting-test'); - mockConfigGet.withArgs('queue', 'indexInterval').returns('week'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { + index: '.reporting-test', + queue: { indexInterval: 'week' }, + }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockCore = await createMockReportingCore(mockConfig); callClusterStub.reset(); @@ -46,48 +48,47 @@ describe('ReportingStore', () => { describe('addReport', () => { it('returns Report object', async () => { const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_1', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'username1' }, reportPayload) - ).resolves.toMatchObject({ + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + status: 'pending', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, completed_at: undefined, created_by: 'username1', jobtype: 'unknowntype', - max_attempts: undefined, payload: {}, - priority: 10, - started_at: undefined, + meta: {}, status: 'pending', - timeout: undefined, }); }); it('throws if options has invalid indexInterval', async () => { - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('index').returns('.reporting-test'); - mockConfigGet.withArgs('queue', 'indexInterval').returns('centurially'); - mockConfig = getMockConfig(mockConfigGet); + const reportingConfig = { + index: '.reporting-test', + queue: { indexInterval: 'centurially' }, + }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockConfig = createMockConfig(mockSchema); mockCore = await createMockReportingCore(mockConfig); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_2', - objectType: 'testOt', - }; - expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: Invalid index interval: centurially]`); + const mockReport = new Report({ + _index: '.reporting-errortest', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[TypeError: this.client.callAsInternalUser is not a function]` + ); }); it('handles error creating the index', async () => { @@ -96,15 +97,15 @@ describe('ReportingStore', () => { callClusterStub.withArgs('indices.create').rejects(new Error('horrible error')); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_3', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: horrible error]`); + const mockReport = new Report({ + _index: '.reporting-errortest', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[Error: horrible error]` + ); }); /* Creating the index will fail, if there were multiple jobs staged in @@ -119,15 +120,15 @@ describe('ReportingStore', () => { callClusterStub.withArgs('indices.create').rejects(new Error('devastating error')); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_4', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: devastating error]`); + const mockReport = new Report({ + _index: '.reporting-mock', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[Error: devastating error]` + ); }); it('skips creating the index if already exists', async () => { @@ -138,28 +139,20 @@ describe('ReportingStore', () => { .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_5', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).resolves.toMatchObject({ + const mockReport = new Report({ + created_by: 'user1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, - completed_at: undefined, created_by: 'user1', jobtype: 'unknowntype', - max_attempts: undefined, payload: {}, - priority: 10, - started_at: undefined, status: 'pending', - timeout: undefined, }); }); @@ -171,26 +164,24 @@ describe('ReportingStore', () => { .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_test_headers', - objectType: 'testOt', - }; - await expect(store.addReport(reportType, false, reportPayload)).resolves.toMatchObject({ + const mockReport = new Report({ + _index: '.reporting-unsecured', + attempts: 0, + created_by: false, + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'pending', + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, - completed_at: undefined, created_by: false, jobtype: 'unknowntype', - max_attempts: undefined, + meta: {}, payload: {}, - priority: 10, - started_at: undefined, status: 'pending', - timeout: undefined, }); }); }); @@ -205,8 +196,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'ABC', }, timeout: 30000, priority: 1, @@ -244,8 +237,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'BCD', }, timeout: 30000, priority: 1, @@ -283,8 +278,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'CDE', }, timeout: 30000, priority: 1, @@ -322,8 +319,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'utc', }, timeout: 30000, priority: 1, diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index b1309cbdeb94d..c20a9e991b4bc 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -7,16 +7,9 @@ import { ElasticsearchServiceSetup } from 'src/core/server'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; -import { BaseParams, BaseParamsEncryptedFields, ReportingUser } from '../../types'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; import { Report } from './report'; -interface JobSettings { - timeout: number; - browser_type: string; - max_attempts: number; - priority: number; -} const checkReportIsEditable = (report: Report) => { if (!report._id || !report._index) { @@ -33,7 +26,6 @@ const checkReportIsEditable = (report: Report) => { export class ReportingStore { private readonly indexPrefix: string; private readonly indexInterval: string; - private readonly jobSettings: JobSettings; private client: ElasticsearchServiceSetup['legacy']['client']; private logger: LevelLogger; @@ -44,13 +36,6 @@ export class ReportingStore { this.client = elasticsearch.legacy.client; this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); - this.jobSettings = { - timeout: config.get('queue', 'timeout'), - browser_type: config.get('capture', 'browser', 'type'), - max_attempts: config.get('capture', 'maxAttempts'), - priority: 10, // unused - }; - this.logger = logger; } @@ -99,36 +84,17 @@ export class ReportingStore { * Called from addReport, which handles any errors */ private async indexReport(report: Report) { - const params = report.payload; - - // Queing is handled by TM. These queueing-based fields for reference in Report Info panel - const infoFields = { - timeout: report.timeout, - process_expiration: new Date(0), // use epoch so the job query works - created_at: new Date(), - attempts: 0, - max_attempts: report.max_attempts, - status: statuses.JOB_STATUS_PENDING, - browser_type: report.browser_type, - }; - - const indexParams = { + const doc = { index: report._index, id: report._id, body: { - ...infoFields, - jobtype: report.jobtype, - meta: { - // We are copying these values out of payload because these fields are indexed and can be aggregated on - // for tracking stats, while payload contents are not. - objectType: params.objectType, - layout: params.layout ? params.layout.id : 'none', - }, - payload: report.payload, - created_by: report.created_by, + ...report.toEsDocsJSON()._source, + process_expiration: new Date(0), // use epoch so the job query works + attempts: 0, + status: statuses.JOB_STATUS_PENDING, }, }; - return await this.client.callAsInternalUser('index', indexParams); + return await this.client.callAsInternalUser('index', doc); } /* @@ -138,23 +104,15 @@ export class ReportingStore { return await this.client.callAsInternalUser('indices.refresh', { index }); } - public async addReport( - type: string, - user: ReportingUser, - payload: BaseParams & BaseParamsEncryptedFields - ): Promise { - const timestamp = indexTimestamp(this.indexInterval); - const index = `${this.indexPrefix}-${timestamp}`; + public async addReport(report: Report): Promise { + let index = report._index; + if (!index) { + const timestamp = indexTimestamp(this.indexInterval); + index = `${this.indexPrefix}-${timestamp}`; + report._index = index; + } await this.createIndex(index); - const report = new Report({ - _index: index, - payload, - jobtype: type, - created_by: user ? user.username : false, - ...this.jobSettings, - }); - try { const doc = await this.indexReport(report); report.updateWithEsDoc(doc); @@ -164,7 +122,7 @@ export class ReportingStore { return report; } catch (err) { - this.logger.error(`Error in addReport!`); + this.logger.error(`Error in adding a report!`); this.logger.error(err); throw err; } @@ -218,7 +176,7 @@ export class ReportingStore { public async setReportCompleted(report: Report, stats: Partial): Promise { try { - const { output } = stats as { output: any }; + const { output } = stats; const status = output && output.warnings && output.warnings.length > 0 ? statuses.JOB_STATUS_WARNINGS diff --git a/x-pack/plugins/reporting/server/lib/tasks/index.ts b/x-pack/plugins/reporting/server/lib/tasks/index.ts new file mode 100644 index 0000000000000..0dd9945985bfb --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/tasks/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BasePayload } from '../../types'; +import { ReportSource } from '../store/report'; + +/* + * The document created by Reporting to store as task parameters for Task + * Manager to reference the report in .reporting + */ +export interface ReportTaskParams { + id: string; + index?: string; // For ad-hoc, which as an existing "pending" record + payload: JobPayloadType; + created_at: ReportSource['created_at']; + created_by: ReportSource['created_by']; + jobtype: ReportSource['jobtype']; + attempts: ReportSource['attempts']; + meta: ReportSource['meta']; +} + +export interface TaskRunResult { + content_type: string | null; + content: string | null; + csv_contains_formulas?: boolean; + size: number; + max_size_reached?: boolean; + warnings?: string[]; +} diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index d323a281c06ff..3f2f472ab0623 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -32,8 +32,8 @@ describe('Reporting Plugin', () => { beforeEach(async () => { configSchema = createMockConfigSchema(); initContext = coreMock.createPluginInitializerContext(configSchema); - coreSetup = await coreMock.createSetup(configSchema); - coreStart = await coreMock.createStart(); + coreSetup = coreMock.createSetup(configSchema); + coreStart = coreMock.createStart(); pluginSetup = ({ licensing: {}, features: featuresPluginMock.createSetup(), diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index adb89abe20280..6a93a35bfcc84 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -34,7 +34,7 @@ export class ReportingPlugin constructor(context: PluginInitializerContext) { this.logger = new LevelLogger(context.logger.get()); this.initializerContext = context; - this.reportingCore = new ReportingCore(); + this.reportingCore = new ReportingCore(this.logger); } public setup(core: CoreSetup, plugins: ReportingSetupDeps) { @@ -70,11 +70,11 @@ export class ReportingPlugin }); const { elasticsearch, http } = core; - const { features, licensing, security } = plugins; + const { features, licensing, security, spaces } = plugins; const { initializerContext: initContext, reportingCore } = this; const router = http.createRouter(); - const basePath = http.basePath.get; + const basePath = http.basePath; reportingCore.pluginSetup({ features, @@ -83,6 +83,7 @@ export class ReportingPlugin basePath, router, security, + spaces, }); registerReportingUsageCollector(reportingCore, plugins); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts index f92fbfc7013cf..71ca0661a42a9 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.test.ts @@ -33,7 +33,15 @@ describe('POST /diagnose/browser', () => { const mockedCreateInterface: any = createInterface; const config = { - get: jest.fn().mockImplementation(() => ({})), + get: jest.fn().mockImplementation((...keys) => { + const key = keys.join('.'); + switch (key) { + case 'queue.timeout': + return 120000; + case 'capture.browser.chromium.proxy': + return { enabled: false }; + } + }), kbnConfig: { get: jest.fn() }, }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts index 24b85220defb4..dc4b30ffcfa7c 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts @@ -9,8 +9,8 @@ import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { browserStartLogs } from '../../browsers/chromium/driver_factory/start_logs'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; const logsToHelpMap = { 'error while loading shared libraries': i18n.translate( @@ -54,25 +54,30 @@ export const registerDiagnoseBrowser = (reporting: ReportingCore, logger: Logger validate: {}, }, userHandler(async (user, context, req, res) => { - const logs = await browserStartLogs(reporting, logger).toPromise(); - const knownIssues = Object.keys(logsToHelpMap) as Array; + try { + const logs = await browserStartLogs(reporting, logger).toPromise(); + const knownIssues = Object.keys(logsToHelpMap) as Array; - const boundSuccessfully = logs.includes(`DevTools listening on`); - const help = knownIssues.reduce((helpTexts: string[], knownIssue) => { - const helpText = logsToHelpMap[knownIssue]; - if (logs.includes(knownIssue)) { - helpTexts.push(helpText); - } - return helpTexts; - }, []); + const boundSuccessfully = logs.includes(`DevTools listening on`); + const help = knownIssues.reduce((helpTexts: string[], knownIssue) => { + const helpText = logsToHelpMap[knownIssue]; + if (logs.includes(knownIssue)) { + helpTexts.push(helpText); + } + return helpTexts; + }, []); - const response: DiagnosticResponse = { - success: boundSuccessfully && !help.length, - help, - logs, - }; + const response: DiagnosticResponse = { + success: boundSuccessfully && !help.length, + help, + logs, + }; - return res.ok({ body: response }); + return res.ok({ body: response }); + } catch (err) { + logger.error(err); + return res.custom({ statusCode: 500 }); + } }) ); }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts index 624397246656d..a112d04f38c7b 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.test.ts @@ -35,7 +35,15 @@ describe('POST /diagnose/config', () => { } as unknown) as any; config = { - get: jest.fn(), + get: jest.fn().mockImplementation((...keys) => { + const key = keys.join('.'); + switch (key) { + case 'queue.timeout': + return 120000; + case 'csv.maxSizeBytes': + return 1024; + } + }), kbnConfig: { get: jest.fn() }, }; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts index 198ba63e2614d..70428779366b3 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts @@ -4,18 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ByteSizeValue } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import numeral from '@elastic/numeral'; import { defaults, get } from 'lodash'; import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; +const numberToByteSizeValue = (value: number | ByteSizeValue) => { + if (typeof value === 'number') { + return new ByteSizeValue(value); + } + + return value; +}; + export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) => { const setupDeps = reporting.getPluginSetupDeps(); const userHandler = authorizedUserPreRoutingFactory(reporting); @@ -42,12 +50,10 @@ export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) 'http.max_content_length', '100mb' ); - const elasticSearchMaxContentBytes = numeral().unformat( - elasticSearchMaxContent.toUpperCase() - ); - const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); + const elasticSearchMaxContentBytes = ByteSizeValue.parse(elasticSearchMaxContent); + const kibanaMaxContentBytes = numberToByteSizeValue(config.get('csv', 'maxSizeBytes')); - if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { + if (kibanaMaxContentBytes.isGreaterThan(elasticSearchMaxContentBytes)) { const maxContentSizeWarning = i18n.translate( 'xpack.reporting.diagnostic.configSizeMismatch', { @@ -55,8 +61,8 @@ export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) `xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) is higher than ElasticSearch's {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}). ` + `Please set {ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} in Kibana.`, values: { - kibanaMaxContentBytes, - elasticSearchMaxContentBytes, + kibanaMaxContentBytes: kibanaMaxContentBytes.getValueInBytes(), + elasticSearchMaxContentBytes: elasticSearchMaxContentBytes.getValueInBytes(), KIBANA_MAX_SIZE_BYTES_PATH, ES_MAX_SIZE_BYTES_PATH, }, diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts index 895dee32614f1..84df91ea31b62 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts @@ -15,3 +15,9 @@ export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logge registerDiagnoseConfig(reporting, logger); registerDiagnoseScreenshot(reporting, logger); }; + +export interface DiagnosticResponse { + help: string[]; + success: boolean; + logs: string; +} diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts index ec4ab0446ae5f..287da0d2ed5ec 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -33,7 +33,11 @@ describe('POST /diagnose/screenshot', () => { }; const config = { - get: jest.fn(), + get: jest.fn().mockImplementation((...keys) => { + if (keys.join('.') === 'queue.timeout') { + return 120000; + } + }), kbnConfig: { get: jest.fn() }, }; const mockLogger = createMockLevelLogger(); diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 979283f9f037c..6ea6e22c5d7f9 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -11,8 +11,8 @@ import { omitBlockedHeaders } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Logger) => { const setupDeps = reporting.getPluginSetupDeps(); @@ -35,19 +35,8 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log config.get('kibanaServer', 'port'), ] as string[]; - const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: basePath, - protocol, - hostname, - port, - }); - - const hashUrl = getAbsoluteUrl({ - basePath, - path: '/', - hash: '', - search: '', - }); + const getAbsoluteUrl = getAbsoluteUrlFactory({ basePath, protocol, hostname, port }); + const hashUrl = getAbsoluteUrl({ path: '/', hash: '', search: '' }); // Hack the layout to make the base/login page work const layout = { @@ -65,10 +54,7 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log }; const headers = { - headers: omitBlockedHeaders({ - job: null, - decryptedHeaders, - }), + headers: omitBlockedHeaders(decryptedHeaders), conditions: { hostname, port: +port, diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 517f1dadc0ac1..400fbb16f54dc 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -10,17 +10,20 @@ import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { createJobFnFactory } from '../export_types/csv_from_savedobject/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/execute_job'; -import { JobParamsPostPayloadPanelCsv } from '../export_types/csv_from_savedobject/types'; +import { + JobParamsPanelCsv, + JobParamsPanelCsvPost, +} from '../export_types/csv_from_savedobject/types'; import { LevelLogger as Logger } from '../lib'; -import { TaskRunResult } from '../types'; +import { TaskRunResult } from '../lib/tasks'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; export type CsvFromSavedObjectRequest = KibanaRequest< - { savedObjectType: string; savedObjectId: string }, + JobParamsPanelCsv, unknown, - JobParamsPostPayloadPanelCsv + JobParamsPanelCsvPost >; /* @@ -66,27 +69,22 @@ export function registerGenerateCsvFromSavedObjectImmediate( }, userHandler(async (user, context, req: CsvFromSavedObjectRequest, res) => { const logger = parentLogger.clone(['savedobject-csv']); - const jobParams = getJobParamsFromRequest(req, { isImmediate: true }); + const jobParams = getJobParamsFromRequest(req); const createJob = createJobFnFactory(reporting, logger); const runTaskFn = runTaskFnFactory(reporting, logger); try { // FIXME: no create job for immediate download - const jobDocPayload = await createJob(jobParams, req.headers, context, req); + const payload = await createJob(jobParams, context, req); const { content_type: jobOutputContentType, content: jobOutputContent, size: jobOutputSize, - }: TaskRunResult = await runTaskFn(null, jobDocPayload, context, req); + }: TaskRunResult = await runTaskFn(null, payload, context, req); logger.info(`Job output size: ${jobOutputSize} bytes`); - /* - * ESQueue worker function defaults `content` to null, even if the - * runTask returned undefined. - * - * This converts null to undefined so the value can be sent to h.response() - */ + // convert null to undefined so the value can be sent to h.response() if (jobOutputContent === null) { logger.warn('CSV Job Execution created empty content result'); } diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index dd905223a81d5..867af75c8de27 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -74,8 +74,8 @@ describe('POST /api/reporting/generate', () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', validLicenses: ['basic', 'gold'], - createJobFnFactory: () => () => ({ jobParamsTest: { test1: 'yes' } }), - runTaskFnFactory: () => () => ({ runParamsTest: { test2: 'yes' } }), + createJobFnFactory: () => async () => ({ createJobTest: { test1: 'yes' } } as any), + runTaskFnFactory: () => async () => ({ runParamsTest: { test2: 'yes' } } as any), }); core.getExportTypesRegistry = () => mockExportTypesRegistry; }); @@ -163,9 +163,21 @@ describe('POST /api/reporting/generate', () => { .then(({ body }) => { expect(body).toMatchObject({ job: { - id: expect.any(String), + attempts: 0, + created_by: 'Tom Riddle', + id: 'foo', + index: 'foo-index', + jobtype: 'printable_pdf', + payload: { + createJobTest: { + test1: 'yes', + }, + }, + priority: 10, + status: 'pending', + timeout: 10000, }, - path: expect.any(String), + path: 'undefined/api/reporting/jobs/download/foo', }); }); }); diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index 11ad4cc9d4eb8..22edd4002dbcf 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -15,3 +15,10 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerJobInfoRoutes(reporting); registerDiagnosticRoutes(reporting, logger); } + +export interface ReportingRequestPre { + management: { + jobTypes: string[]; + }; + user: string; +} diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 2957bc76f4682..fc1cfd00493c3 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -10,9 +10,8 @@ import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '..'; import { ReportingInternalSetup } from '../core'; -import { LevelLogger } from '../lib'; import { ExportTypesRegistry } from '../lib/export_types_registry'; -import { createMockReportingCore } from '../test_helpers'; +import { createMockConfig, createMockConfigSchema, createMockReportingCore } from '../test_helpers'; import { ExportTypeDefinition } from '../types'; import { registerJobInfoRoutes } from './jobs'; @@ -25,12 +24,7 @@ describe('GET /api/reporting/jobs/download', () => { let exportTypesRegistry: ExportTypesRegistry; let core: ReportingCore; - const config = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; - const mockLogger = ({ - error: jest.fn(), - debug: jest.fn(), - } as unknown) as jest.Mocked; - + const config = createMockConfig(createMockConfigSchema()); const getHits = (...sources: any) => { return { hits: { @@ -74,20 +68,18 @@ describe('GET /api/reporting/jobs/download', () => { jobType: 'unencodedJobType', jobContentExtension: 'csv', validLicenses: ['basic', 'gold'], - } as ExportTypeDefinition); + } as ExportTypeDefinition); exportTypesRegistry.register({ id: 'base64Encoded', jobType: 'base64EncodedJobType', jobContentEncoding: 'base64', jobContentExtension: 'pdf', validLicenses: ['basic', 'gold'], - } as ExportTypeDefinition); + } as ExportTypeDefinition); core.getExportTypesRegistry = () => exportTypesRegistry; }); afterEach(async () => { - mockLogger.debug.mockReset(); - mockLogger.error.mockReset(); await server.stop(); }); diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index db62c0cc403fc..43e73c137fb13 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -128,7 +128,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { } return res.ok({ - body: jobOutput, + body: jobOutput || {}, headers: { 'content-type': 'application/json', }, diff --git a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts index 50780a577af02..932ebfdd22bbc 100644 --- a/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts @@ -4,24 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, RequestHandlerContext, KibanaResponseFactory } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory, RequestHandlerContext } from 'kibana/server'; import { coreMock, httpServerMock } from 'src/core/server/mocks'; import { ReportingCore } from '../../'; -import { createMockReportingCore } from '../../test_helpers'; -import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { ReportingInternalSetup } from '../../core'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../test_helpers'; +import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; let mockCore: ReportingCore; -const kbnConfig = { - 'server.basePath': '/sbp', -}; -const reportingConfig = { - 'roles.allow': ['reporting_user'], -}; -const mockReportingConfig = { - get: (...keys: string[]) => (reportingConfig as any)[keys.join('.')] || 'whoah!', - kbnConfig: { get: (...keys: string[]) => (kbnConfig as any)[keys.join('.')] }, -}; +const mockConfig: any = { 'server.basePath': '/sbp', 'roles.allow': ['reporting_user'] }; +const mockReportingConfigSchema = createMockConfigSchema(mockConfig); +const mockReportingConfig = createMockConfig(mockReportingConfigSchema); const getMockContext = () => (({ diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index 84a98d6d1f1d7..b154978d041f4 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -9,9 +9,9 @@ import contentDisposition from 'content-disposition'; import { get } from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ExportTypesRegistry, statuses } from '../../lib'; -import { ExportTypeDefinition, JobSource, TaskRunResult } from '../../types'; - -type ExportTypeType = ExportTypeDefinition; +import { ReportDocument } from '../../lib/store'; +import { TaskRunResult } from '../../lib/tasks'; +import { ExportTypeDefinition } from '../../types'; interface ErrorFromPayload { message: string; @@ -27,10 +27,10 @@ interface Payload { const DEFAULT_TITLE = 'report'; -const getTitle = (exportType: ExportTypeType, title?: string): string => +const getTitle = (exportType: ExportTypeDefinition, title?: string): string => `${title || DEFAULT_TITLE}.${exportType.jobContentExtension}`; -const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeType) => { +const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefinition) => { const metaDataHeaders: Record = {}; if (exportType.jobType === CSV_JOB_TYPE) { @@ -45,7 +45,10 @@ const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeType) }; export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { - function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { + function encodeContent( + content: string | null, + exportType: ExportTypeDefinition + ): Buffer | string { switch (exportType.jobContentEncoding) { case 'base64': return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string @@ -55,7 +58,9 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist } function getCompleted(output: TaskRunResult, jobType: string, title: string): Payload { - const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); + const exportType = exportTypesRegistry.get( + (item: ExportTypeDefinition) => item.jobType === jobType + ); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -92,16 +97,18 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } - return function getDocumentPayload(doc: JobSource): Payload { + return function getDocumentPayload(doc: ReportDocument): Payload { const { status, jobtype: jobType, payload: { title } = { title: '' } } = doc._source; const { output } = doc._source; - if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { - return getCompleted(output, jobType, title); - } + if (output) { + if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { + return getCompleted(output, jobType, title); + } - if (status === statuses.JOB_STATUS_FAILED) { - return getFailure(output); + if (status === statuses.JOB_STATUS_FAILED) { + return getFailure(output); + } } // send a 503 indicating that the report isn't completed yet diff --git a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts index bfa15a4022a4d..e685339c966ed 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -7,17 +7,13 @@ import { JobParamsPanelCsv } from '../../export_types/csv_from_savedobject/types'; import { CsvFromSavedObjectRequest } from '../generate_from_savedobject_immediate'; -export function getJobParamsFromRequest( - request: CsvFromSavedObjectRequest, - opts: { isImmediate: boolean } -): JobParamsPanelCsv { +export function getJobParamsFromRequest(request: CsvFromSavedObjectRequest): JobParamsPanelCsv { const { savedObjectType, savedObjectId } = request.params; const { timerange, state } = request.body; const post = timerange || state ? { timerange, state } : undefined; return { - isImmediate: opts.isImmediate, savedObjectType, savedObjectId, post, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index b01c880abe820..d1270215b4821 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -8,7 +8,8 @@ import { i18n } from '@kbn/i18n'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { get } from 'lodash'; import { ReportingCore } from '../../'; -import { JobSource, ReportingUser } from '../../types'; +import { ReportDocument } from '../../lib/store'; +import { ReportingUser } from '../../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -130,7 +131,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { }); }, - get(user: ReportingUser, id: string, opts: GetOpts = {}): Promise | void> { + get(user: ReportingUser, id: string, opts: GetOpts = {}): Promise { if (!id) return Promise.resolve(); const username = getUsername(user); diff --git a/x-pack/plugins/reporting/server/routes/types.d.ts b/x-pack/plugins/reporting/server/routes/types.d.ts index 5c34d466197fe..b3f9225c3dce5 100644 --- a/x-pack/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/plugins/reporting/server/routes/types.d.ts @@ -18,11 +18,11 @@ export type HandlerFunction = ( export type HandlerErrorFunction = (res: KibanaResponseFactory, err: Error) => any; -export interface QueuedJobPayload { +export interface QueuedJobPayload { error?: boolean; source: { job: { - payload: BasePayload; + payload: BasePayload; }; }; } diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts index f2785bce10964..d6996d2caf1bc 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_browserdriverfactory.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; import { Page } from 'puppeteer'; import * as Rx from 'rxjs'; import { chromium, HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../browsers'; @@ -15,6 +16,7 @@ import { CaptureConfig } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; waitForSelector: jest.Mock, any[]>; + waitFor: jest.Mock, any[]>; screenshot: jest.Mock, any[]>; open: jest.Mock, any[]>; getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock; @@ -86,6 +88,7 @@ const getCreatePage = (driver: HeadlessChromiumDriver) => const defaultOpts: CreateMockBrowserDriverFactoryOpts = { evaluate: mockBrowserEvaluate, waitForSelector: mockWaitForSelector, + waitFor: jest.fn(), screenshot: mockScreenshot, open: jest.fn(), getCreatePage, @@ -96,7 +99,11 @@ export const createMockBrowserDriverFactory = async ( opts: Partial = {} ): Promise => { const captureConfig: CaptureConfig = { - timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, + timeouts: { + openUrl: moment.duration(60, 's'), + waitForElements: moment.duration(30, 's'), + renderComplete: moment.duration(30, 's'), + }, browser: { type: 'chromium', chromium: { @@ -108,18 +115,14 @@ export const createMockBrowserDriverFactory = async ( }, networkPolicy: { enabled: true, rules: [] }, viewport: { width: 800, height: 600 }, - loadDelay: 2000, + loadDelay: moment.duration(2, 's'), zoom: 2, maxAttempts: 1, }; const binaryPath = '/usr/local/share/common/secure/super_awesome_binary'; - const mockBrowserDriverFactory = await chromium.createDriverFactory( - binaryPath, - captureConfig, - logger - ); - const mockPage = {} as Page; + const mockBrowserDriverFactory = chromium.createDriverFactory(binaryPath, captureConfig, logger); + const mockPage = ({ setViewport: () => {} } as unknown) as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, networkPolicy: captureConfig.networkPolicy, @@ -127,6 +130,7 @@ export const createMockBrowserDriverFactory = async ( // mock the driver methods as either default mocks or passed-in mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore + mockBrowserDriver.waitFor = opts.waitFor ? opts.waitFor : defaultOpts.waitFor; mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate; mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot; mockBrowserDriver.open = opts.open ? opts.open : defaultOpts.open; diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 559726e0b8a99..72772f9f7b755 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -9,14 +9,16 @@ jest.mock('../usage'); jest.mock('../browsers'); jest.mock('../lib/create_queue'); +import _ from 'lodash'; import * as Rx from 'rxjs'; -import { featuresPluginMock } from '../../../features/server/mocks'; import { ReportingConfig, ReportingCore } from '../'; +import { featuresPluginMock } from '../../../features/server/mocks'; import { chromium, HeadlessChromiumDriverFactory, initializeBrowserDriverFactory, } from '../browsers'; +import { ReportingConfigType } from '../config'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; import { ReportingStore } from '../lib'; import { ReportingStartDeps } from '../types'; @@ -35,18 +37,19 @@ const createMockPluginSetup = ( return { features: featuresPluginMock.createSetup(), elasticsearch: setupMock.elasticsearch || { legacy: { client: {} } }, - basePath: setupMock.basePath || '/all-about-that-basepath', + basePath: { set: jest.fn() }, router: setupMock.router, security: setupMock.security, licensing: { license$: Rx.of({ isAvailable: true, isActive: true, type: 'basic' }) } as any, }; }; +const logger = createMockLevelLogger(); + const createMockPluginStart = ( mockReportingCore: ReportingCore, startMock?: any ): ReportingInternalStart => { - const logger = createMockLevelLogger(); const store = new ReportingStore(mockReportingCore, logger); return { browserDriverFactory: startMock.browserDriverFactory, @@ -57,12 +60,58 @@ const createMockPluginStart = ( }; }; -export const createMockConfigSchema = (overrides?: any) => ({ - index: '.reporting', - kibanaServer: { hostname: 'localhost', port: '80' }, - capture: { browser: { chromium: { disableSandbox: true } } }, - ...overrides, -}); +interface ReportingConfigTestType { + index: string; + encryptionKey: string; + queue: Partial; + kibanaServer: Partial; + csv: Partial; + capture: any; + server?: any; +} + +export const createMockConfigSchema = ( + overrides: Partial = {} +): ReportingConfigTestType => { + // deeply merge the defaults and the provided partial schema + return { + index: '.reporting', + encryptionKey: 'cool-encryption-key-where-did-you-find-it', + ...overrides, + kibanaServer: { + hostname: 'localhost', + port: 80, + ...overrides.kibanaServer, + }, + capture: { + browser: { + chromium: { + disableSandbox: true, + }, + }, + ...overrides.capture, + }, + queue: { + timeout: 120000, + ...overrides.queue, + }, + csv: { + ...overrides.csv, + }, + }; +}; + +export const createMockConfig = ( + reportingConfig: Partial +): ReportingConfig => { + const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { + return _.get(reportingConfig, keys.join('.')); + }); + return { + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, + }; +}; export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, @@ -86,7 +135,7 @@ export const createMockReportingCore = async ( } config = config || {}; - const core = new ReportingCore(); + const core = new ReportingCore(logger); core.pluginSetup(setupDepsMock); core.setConfig(config); diff --git a/x-pack/plugins/reporting/server/test_helpers/index.ts b/x-pack/plugins/reporting/server/test_helpers/index.ts index 2d5ef9fdd768d..96357dc915eef 100644 --- a/x-pack/plugins/reporting/server/test_helpers/index.ts +++ b/x-pack/plugins/reporting/server/test_helpers/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createMockServer } from './create_mock_server'; -export { createMockReportingCore, createMockConfigSchema } from './create_mock_reportingplugin'; export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory'; export { createMockLayoutInstance } from './create_mock_layoutinstance'; export { createMockLevelLogger } from './create_mock_levellogger'; +export { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from './create_mock_reportingplugin'; +export { createMockServer } from './create_mock_server'; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index c67a95c2de754..eb046a3eab075 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -8,84 +8,16 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { DataPluginStart } from 'src/plugins/data/server/plugin'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { SpacesPluginSetup } from '../../spaces/server'; import { CancellationToken } from '../../../plugins/reporting/common'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../security/server'; -import { JobStatus } from '../common/types'; import { ReportingConfigType } from './config'; import { ReportingCore } from './core'; import { LevelLogger } from './lib'; -import { LayoutInstance } from './lib/layouts'; - -/* - * Routing types - */ - -export interface ReportingRequestPre { - management: { - jobTypes: string[]; - }; - user: string; -} - -// generate a report with unparsed jobParams -export interface GenerateExportTypePayload { - jobParams: string; -} - -export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPayload; - -export interface TimeRangeParams { - timezone: string; - min?: Date | string | number | null; - max?: Date | string | number | null; -} - -// the "raw" data coming from the client, unencrypted -export interface JobParamPostPayload { - timerange?: TimeRangeParams; -} - -// the pre-processed, encrypted data ready for storage -export interface BasePayload { - headers: string; // serialized encrypted headers - jobParams: JobParamsType; - title: string; - type: string; -} - -export interface JobSource { - _id: string; - _index: string; - _source: { - jobtype: string; - output: TaskRunResult; - payload: BasePayload; - status: JobStatus; - }; -} - -export interface TaskRunResult { - content_type: string | null; - content: string | null; - csv_contains_formulas?: boolean; - size: number; - max_size_reached?: boolean; - warnings?: string[]; -} - -interface ConditionalHeadersConditions { - protocol: string; - hostname: string; - port: number; - basePath: string; -} - -export interface ConditionalHeaders { - headers: Record; - conditions: ConditionalHeadersConditions; -} +import { LayoutParams } from './lib/layouts'; +import { ReportTaskParams, TaskRunResult } from './lib/tasks'; /* * Plugin Contract @@ -95,6 +27,7 @@ export interface ReportingSetupDeps { licensing: LicensingPluginSetup; features: FeaturesPluginSetup; security?: SecurityPluginSetup; + spaces?: SpacesPluginSetup; usageCollection?: UsageCollectionSetup; } @@ -115,25 +48,29 @@ export type CaptureConfig = ReportingConfigType['capture']; export type ScrollConfig = ReportingConfigType['csv']['scroll']; export interface BaseParams { - browserTimezone: string; - layout?: LayoutInstance; // for screenshot type reports + browserTimezone?: string; // browserTimezone is optional: it is not in old POST URLs that were generated prior to being added to this interface + layout?: LayoutParams; objectType: string; + title: string; } -export interface BaseParamsEncryptedFields extends BaseParams { - basePath?: string; // for screenshot type reports - headers: string; // encrypted headers +// base params decorated with encrypted headers that come into runJob functions +export interface BasePayload extends BaseParams { + headers: string; + spaceId?: string; } -export type CreateJobFn = ( +// default fn type for CreateJobFnFactory +export type CreateJobFn = ( jobParams: JobParamsType, context: RequestHandlerContext, - request: KibanaRequest -) => Promise; + request: KibanaRequest +) => Promise; -export type RunTaskFn = ( +// default fn type for RunTaskFnFactory +export type RunTaskFn = ( jobId: string, - job: TaskPayloadType, + payload: ReportTaskParams['payload'], cancellationToken: CancellationToken ) => Promise; @@ -147,12 +84,7 @@ export type RunTaskFnFactory = ( logger: LevelLogger ) => RunTaskFnType; -export interface ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType -> { +export interface ExportTypeDefinition { id: string; name: string; jobType: string; @@ -162,9 +94,3 @@ export interface ExportTypeDefinition< runTaskFnFactory: RunTaskFnFactory; validLicenses: string[]; } - -export interface DiagnosticResponse { - help: string[]; - success: boolean; - logs: string; -} diff --git a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts index 213bea3bc3eec..1211d4c2cf1c3 100644 --- a/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,7 +5,7 @@ */ import { get } from 'lodash'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { LegacyAPICaller } from 'kibana/server'; import { ReportingConfig } from '../'; import { ExportTypesRegistry } from '../lib/export_types_registry'; import { GetLicense } from './'; @@ -118,7 +118,7 @@ async function handleResponse(response: SearchResponse): Promise { const reportingIndex = config.get('index'); diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts index ed2abef2542de..fc2dce441c621 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.test.ts @@ -8,8 +8,8 @@ import * as Rx from 'rxjs'; import sinon from 'sinon'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ReportingConfig, ReportingCore } from '../'; -import { createMockReportingCore } from '../test_helpers'; import { getExportTypesRegistry } from '../lib/export_types_registry'; +import { createMockConfig, createMockConfigSchema, createMockReportingCore } from '../test_helpers'; import { ReportingSetupDeps } from '../types'; import { FeaturesAvailability } from './'; import { @@ -54,17 +54,13 @@ function getPluginsMock( } as unknown) as ReportingSetupDeps & { usageCollection: UsageCollectionSetup }; } -const getMockReportingConfig = () => ({ - get: () => {}, - kbnConfig: { get: () => '' }, -}); const getResponseMock = (base = {}) => base; describe('license checks', () => { let mockConfig: ReportingConfig; let mockCore: ReportingCore; beforeAll(async () => { - mockConfig = getMockReportingConfig(); + mockConfig = createMockConfig(createMockConfigSchema()); mockCore = await createMockReportingCore(mockConfig); }); @@ -189,7 +185,7 @@ describe('data modeling', () => { let mockConfig: ReportingConfig; let mockCore: ReportingCore; beforeAll(async () => { - mockConfig = getMockReportingConfig(); + mockConfig = createMockConfig(createMockConfigSchema()); mockCore = await createMockReportingCore(mockConfig); }); test('with normal looking usage data', async () => { @@ -455,7 +451,7 @@ describe('data modeling', () => { describe('Ready for collection observable', () => { test('converts observable to promise', async () => { - const mockConfig = getMockReportingConfig(); + const mockConfig = createMockConfig(createMockConfigSchema()); const mockReporting = await createMockReportingCore(mockConfig); const usageCollection = getMockUsageCollection(); diff --git a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts index 100d09a2da7e4..8f26579726ff1 100644 --- a/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -5,7 +5,7 @@ */ import { first, map } from 'rxjs/operators'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { LegacyAPICaller } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ReportingCore } from '../'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -36,7 +36,7 @@ export function getReportingUsageCollector( ) { return usageCollection.makeUsageCollector({ type: 'reporting', - fetch: (callCluster: CallCluster) => { + fetch: (callCluster: LegacyAPICaller) => { const config = reporting.getConfig(); return getReportingUsage(config, getLicense, callCluster, exportTypesRegistry); }, diff --git a/x-pack/plugins/rollup/server/collectors/register.ts b/x-pack/plugins/rollup/server/collectors/register.ts index aa06d3f696d00..daacc065629a4 100644 --- a/x-pack/plugins/rollup/server/collectors/register.ts +++ b/x-pack/plugins/rollup/server/collectors/register.ts @@ -6,7 +6,7 @@ import { get } from 'lodash'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; +import { LegacyAPICaller } from 'kibana/server'; interface IdToFlagMap { [key: string]: boolean; @@ -27,7 +27,7 @@ function createIdToFlagMap(ids: string[]) { }, {} as any); } -async function fetchRollupIndexPatterns(kibanaIndex: string, callCluster: CallCluster) { +async function fetchRollupIndexPatterns(kibanaIndex: string, callCluster: LegacyAPICaller) { const searchParams = { size: ES_MAX_RESULT_WINDOW_DEFAULT_VALUE, index: kibanaIndex, @@ -56,7 +56,7 @@ async function fetchRollupIndexPatterns(kibanaIndex: string, callCluster: CallCl async function fetchRollupSavedSearches( kibanaIndex: string, - callCluster: CallCluster, + callCluster: LegacyAPICaller, rollupIndexPatternToFlagMap: IdToFlagMap ) { const searchParams = { @@ -104,7 +104,7 @@ async function fetchRollupSavedSearches( async function fetchRollupVisualizations( kibanaIndex: string, - callCluster: CallCluster, + callCluster: LegacyAPICaller, rollupIndexPatternToFlagMap: IdToFlagMap, rollupSavedSearchesToFlagMap: IdToFlagMap ) { @@ -211,7 +211,7 @@ export function registerRollupUsageCollector( total: { type: 'long' }, }, }, - fetch: async (callCluster: CallCluster) => { + fetch: async (callCluster: LegacyAPICaller) => { const rollupIndexPatterns = await fetchRollupIndexPatterns(kibanaIndex, callCluster); const rollupIndexPatternToFlagMap = createIdToFlagMap(rollupIndexPatterns); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js index d466ebd69737e..8672a8b8f6849 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.test.js @@ -6,21 +6,16 @@ import { registerRollupSearchStrategy } from './register_rollup_search_strategy'; describe('Register Rollup Search Strategy', () => { - let routeDependencies; let addSearchStrategy; + let getRollupService; beforeEach(() => { - routeDependencies = { - router: jest.fn().mockName('router'), - elasticsearchService: jest.fn().mockName('elasticsearchService'), - elasticsearch: jest.fn().mockName('elasticsearch'), - }; - addSearchStrategy = jest.fn().mockName('addSearchStrategy'); + getRollupService = jest.fn().mockName('getRollupService'); }); test('should run initialization', () => { - registerRollupSearchStrategy(routeDependencies, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); expect(addSearchStrategy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts index 333863979ba95..22dafbb71d802 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/register_rollup_search_strategy.ts @@ -4,27 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'src/core/server'; import { - AbstractSearchRequest, DefaultSearchCapabilities, AbstractSearchStrategy, + ReqFacade, } from '../../../../../../src/plugins/vis_type_timeseries/server'; -import { CallWithRequestFactoryShim } from '../../types'; import { getRollupSearchStrategy } from './rollup_search_strategy'; -import { getRollupSearchRequest } from './rollup_search_request'; import { getRollupSearchCapabilities } from './rollup_search_capabilities'; export const registerRollupSearchStrategy = ( - callWithRequestFactory: CallWithRequestFactoryShim, - addSearchStrategy: (searchStrategy: any) => void + addSearchStrategy: (searchStrategy: any) => void, + getRollupService: (reg: ReqFacade) => Promise ) => { - const RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); const RollupSearchCapabilities = getRollupSearchCapabilities(DefaultSearchCapabilities); const RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, RollupSearchCapabilities, - callWithRequestFactory + getRollupService ); addSearchStrategy(new RollupSearchStrategy()); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js deleted file mode 100644 index 2ea0612140946..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.test.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { getRollupSearchRequest } from './rollup_search_request'; - -class AbstractSearchRequest { - indexPattern = 'indexPattern'; - callWithRequest = jest.fn(({ body }) => Promise.resolve(body)); -} - -describe('Rollup search request', () => { - let RollupSearchRequest; - - beforeEach(() => { - RollupSearchRequest = getRollupSearchRequest(AbstractSearchRequest); - }); - - test('should create instance of RollupSearchRequest', () => { - const rollupSearchRequest = new RollupSearchRequest(); - - expect(rollupSearchRequest).toBeInstanceOf(AbstractSearchRequest); - expect(rollupSearchRequest.search).toBeDefined(); - expect(rollupSearchRequest.callWithRequest).toBeDefined(); - }); - - test('should send one request for single search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [{ body: 'body', index: 'index' }]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(1); - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledWith('rollup.search', { - body: 'body', - index: 'index', - rest_total_hits_as_int: true, - }); - }); - - test('should send multiple request for multi search', async () => { - const rollupSearchRequest = new RollupSearchRequest(); - const searches = [ - { body: 'body', index: 'index' }, - { body: 'body1', index: 'index' }, - ]; - - await rollupSearchRequest.search(searches); - - expect(rollupSearchRequest.callWithRequest).toHaveBeenCalledTimes(2); - }); -}); diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts deleted file mode 100644 index 7e12d5286f34c..0000000000000 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_request.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -const SEARCH_METHOD = 'rollup.search'; - -interface Search { - index: string; - body: { - [key: string]: any; - }; -} - -export const getRollupSearchRequest = (AbstractSearchRequest: any) => - class RollupSearchRequest extends AbstractSearchRequest { - async search(searches: Search[]) { - const requests = searches.map(({ body, index }) => - this.callWithRequest(SEARCH_METHOD, { - body, - index, - rest_total_hits_as_int: true, - }) - ); - - return await Promise.all(requests); - } - }; diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js index 63f4628e36bfe..f3da7ed3fdd17 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.test.js @@ -7,13 +7,32 @@ import { getRollupSearchStrategy } from './rollup_search_strategy'; describe('Rollup Search Strategy', () => { let RollupSearchStrategy; - let RollupSearchRequest; let RollupSearchCapabilities; let callWithRequest; let rollupResolvedData; - const server = 'server'; - const request = 'request'; + const request = { + requestContext: { + core: { + elasticsearch: { + client: { + asCurrentUser: { + rollup: { + getRollupIndexCaps: jest.fn().mockImplementation(() => rollupResolvedData), + }, + }, + }, + }, + }, + }, + }; + const getRollupService = jest.fn().mockImplementation(() => { + return { + callAsCurrentUser: async () => { + return rollupResolvedData; + }, + }; + }); const indexPattern = 'indexPattern'; beforeEach(() => { @@ -33,19 +52,17 @@ describe('Rollup Search Strategy', () => { } } - RollupSearchRequest = jest.fn(); RollupSearchCapabilities = jest.fn(() => 'capabilities'); - callWithRequest = jest.fn().mockImplementation(() => rollupResolvedData); RollupSearchStrategy = getRollupSearchStrategy( AbstractSearchStrategy, - RollupSearchRequest, - RollupSearchCapabilities + RollupSearchCapabilities, + getRollupService ); }); test('should create instance of RollupSearchRequest', () => { - const rollupSearchStrategy = new RollupSearchStrategy(server); + const rollupSearchStrategy = new RollupSearchStrategy(); expect(rollupSearchStrategy.name).toBe('rollup'); }); @@ -55,7 +72,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); rollupSearchStrategy.getRollupData = jest.fn(() => ({ [rollupIndex]: { rollup_jobs: [ @@ -104,7 +121,7 @@ describe('Rollup Search Strategy', () => { let rollupSearchStrategy; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); }); test('should return rollup data', async () => { @@ -112,10 +129,7 @@ describe('Rollup Search Strategy', () => { const rollupData = await rollupSearchStrategy.getRollupData(request, indexPattern); - expect(callWithRequest).toHaveBeenCalledWith('rollup.rollupIndexCapabilities', { - indexPattern, - }); - expect(rollupSearchStrategy.getCallWithRequestInstance).toHaveBeenCalledWith(request); + expect(getRollupService).toHaveBeenCalled(); expect(rollupData).toBe('data'); }); @@ -135,7 +149,7 @@ describe('Rollup Search Strategy', () => { const rollupIndex = 'rollupIndex'; beforeEach(() => { - rollupSearchStrategy = new RollupSearchStrategy(server); + rollupSearchStrategy = new RollupSearchStrategy(); fieldsCapabilities = { [rollupIndex]: { aggs: { diff --git a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts index 885836780f1a9..e7794caf8697b 100644 --- a/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts +++ b/x-pack/plugins/rollup/server/lib/search_strategies/rollup_search_strategy.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { keyBy, isString } from 'lodash'; -import { KibanaRequest } from 'src/core/server'; - -import { CallWithRequestFactoryShim } from '../../types'; +import { ILegacyScopedClusterClient } from 'src/core/server'; +import { ReqFacade } from '../../../../../../src/plugins/vis_type_timeseries/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/server'; import { mergeCapabilitiesWithFields } from '../merge_capabilities_with_fields'; import { getCapabilitiesForRollupIndices } from '../map_capabilities'; -const ROLLUP_INDEX_CAPABILITIES_METHOD = 'rollup.rollupIndexCapabilities'; - -const getRollupIndices = (rollupData: { [key: string]: any[] }) => Object.keys(rollupData); +const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); const isIndexPatternValid = (indexPattern: string) => @@ -20,28 +18,40 @@ const isIndexPatternValid = (indexPattern: string) => export const getRollupSearchStrategy = ( AbstractSearchStrategy: any, - RollupSearchRequest: any, RollupSearchCapabilities: any, - callWithRequestFactory: CallWithRequestFactoryShim + getRollupService: (reg: ReqFacade) => Promise ) => class RollupSearchStrategy extends AbstractSearchStrategy { name = 'rollup'; constructor() { - // TODO: When vis_type_timeseries and AbstractSearchStrategy are migrated to the NP, it - // shouldn't require elasticsearchService to be injected, and we can remove this null argument. - super(null, callWithRequestFactory, RollupSearchRequest); + super(ENHANCED_ES_SEARCH_STRATEGY, 'rollup', { rest_total_hits_as_int: true }); } - getRollupData(req: KibanaRequest, indexPattern: string) { - const callWithRequest = this.getCallWithRequestInstance(req); + async search(req: ReqFacade, bodies: any[], options = {}) { + const rollupService = await getRollupService(req); + const requests: any[] = []; + bodies.forEach((body) => { + requests.push( + rollupService.callAsCurrentUser('rollup.search', { + ...body, + rest_total_hits_as_int: true, + }) + ); + }); + return Promise.all(requests); + } - return callWithRequest(ROLLUP_INDEX_CAPABILITIES_METHOD, { - indexPattern, - }).catch(() => Promise.resolve({})); + async getRollupData(req: ReqFacade, indexPattern: string) { + const rollupService = await getRollupService(req); + return rollupService + .callAsCurrentUser('rollup.rollupIndexCapabilities', { + indexPattern, + }) + .catch(() => Promise.resolve({})); } - async checkForViability(req: KibanaRequest, indexPattern: string) { + async checkForViability(req: ReqFacade, indexPattern: string) { let isViable = false; let capabilities = null; @@ -66,7 +76,7 @@ export const getRollupSearchStrategy = ( } async getFieldsForWildcard( - req: KibanaRequest, + req: ReqFacade, indexPattern: string, { fieldsCapabilities, diff --git a/x-pack/plugins/rollup/server/plugin.ts b/x-pack/plugins/rollup/server/plugin.ts index 8b3a6355f950d..fe193150fc1ca 100644 --- a/x-pack/plugins/rollup/server/plugin.ts +++ b/x-pack/plugins/rollup/server/plugin.ts @@ -17,17 +17,16 @@ import { ILegacyCustomClusterClient, Plugin, Logger, - KibanaRequest, PluginInitializerContext, ILegacyScopedClusterClient, - LegacyAPICaller, SharedGlobalConfig, } from 'src/core/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; +import { ReqFacade } from '../../../../src/plugins/vis_type_timeseries/server'; import { PLUGIN, CONFIG_ROLLUPS } from '../common'; -import { Dependencies, CallWithRequestFactoryShim } from './types'; +import { Dependencies } from './types'; import { registerApiRoutes } from './routes'; import { License } from './services'; import { registerRollupUsageCollector } from './collectors'; @@ -132,19 +131,12 @@ export class RollupPlugin implements Plugin { }); if (visTypeTimeseries) { - // TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. - const callWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest - ): LegacyAPICaller => { - return async (...args: Parameters) => { - this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); - return await this.rollupEsClient.asScoped(request).callAsCurrentUser(...args); - }; + const getRollupService = async (request: ReqFacade) => { + this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); + return this.rollupEsClient.asScoped(request); }; - const { addSearchStrategy } = visTypeTimeseries; - registerRollupSearchStrategy(callWithRequestFactoryShim, addSearchStrategy); + registerRollupSearchStrategy(addSearchStrategy, getRollupService); } if (usageCollection) { diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts index 290d2df050099..b167806cf8d5d 100644 --- a/x-pack/plugins/rollup/server/types.ts +++ b/x-pack/plugins/rollup/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, LegacyAPICaller, KibanaRequest } from 'src/core/server'; +import { IRouter } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; @@ -39,9 +39,3 @@ export interface RouteDependencies { IndexPatternsFetcher: typeof IndexPatternsFetcher; }; } - -// TODO: When vis_type_timeseries is fully migrated to the NP, it shouldn't require this shim. -export type CallWithRequestFactoryShim = ( - elasticsearchServiceShim: CallWithRequestFactoryShim, - request: KibanaRequest -) => LegacyAPICaller; diff --git a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx index 3141f5bedc8f9..7e7d74155b2d9 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx @@ -56,6 +56,12 @@ export const Editor = memo(({ licenseEnabled, initialValue, onEditorReady }: Pro setTextArea(licenseEnabled ? containerRef.current!.querySelector('textarea') : null); onEditorReady(createEditorShim(editorInstanceRef.current)); + + return () => { + if (editorInstanceRef.current) { + editorInstanceRef.current.destroy(); + } + }; }, [initialValue, onEditorReady, licenseEnabled]); return ( diff --git a/x-pack/plugins/searchprofiler/server/plugin.ts b/x-pack/plugins/searchprofiler/server/plugin.ts index 0dfb65aa6f857..032593d5e3b31 100644 --- a/x-pack/plugins/searchprofiler/server/plugin.ts +++ b/x-pack/plugins/searchprofiler/server/plugin.ts @@ -20,10 +20,9 @@ export class SearchProfilerServerPlugin implements Plugin { this.licenseStatus = { valid: false }; } - async setup({ http }: CoreSetup, { licensing, elasticsearch }: AppServerPluginDependencies) { + async setup({ http }: CoreSetup, { licensing }: AppServerPluginDependencies) { const router = http.createRouter(); profileRoute.register({ - elasticsearch, router, getLicenseStatus: () => this.licenseStatus, log: this.log, diff --git a/x-pack/plugins/searchprofiler/server/types.ts b/x-pack/plugins/searchprofiler/server/types.ts index 7aa0032afba13..84733b0ccfd95 100644 --- a/x-pack/plugins/searchprofiler/server/types.ts +++ b/x-pack/plugins/searchprofiler/server/types.ts @@ -5,18 +5,15 @@ */ import { IRouter, Logger } from 'kibana/server'; -import { ElasticsearchPlugin } from '../../../../src/legacy/core_plugins/elasticsearch'; import { LicensingPluginSetup } from '../../licensing/server'; import { LicenseStatus } from '../common'; export interface AppServerPluginDependencies { licensing: LicensingPluginSetup; - elasticsearch: ElasticsearchPlugin; } export interface RouteDependencies { getLicenseStatus: () => LicenseStatus; - elasticsearch: ElasticsearchPlugin; router: IRouter; log: Logger; } diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts index 2b78355787ff2..1bab51e70a494 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -21,6 +21,7 @@ export const createFeature = ( icon: 'discoverApp', navLinkId: 'discover', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: [], privileges: privileges === null diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index bf791b37087bd..7dff2912e6aa3 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -32,6 +32,7 @@ const buildFeatures = () => { name: 'Feature 1', icon: 'addDataApp', app: ['feature1App'], + category: { id: 'foo', label: 'foo' }, privileges: { all: { app: ['feature1App'], @@ -56,6 +57,7 @@ const buildFeatures = () => { name: 'Feature 2', icon: 'addDataApp', app: ['feature2App'], + category: { id: 'foo', label: 'foo' }, privileges: { all: { app: ['feature2App'], diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index 7ecf32ee45b85..77b6da2a00487 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -18,6 +18,7 @@ const buildProps = (customProps: any = {}) => { id: 'feature1', name: 'Feature 1', app: ['app'], + category: { id: 'foo', label: 'foo' }, icon: 'spacesApp', privileges: { all: { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index bc60613345910..0242fddc957c9 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -28,6 +28,7 @@ const features = [ id: 'normal', name: 'normal feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { all: [], read: [] }, @@ -43,6 +44,7 @@ const features = [ id: 'normal_with_sub', name: 'normal feature with sub features', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { all: [], read: [] }, @@ -96,6 +98,7 @@ const features = [ id: 'bothPrivilegesExcludedFromBase', name: 'bothPrivilegesExcludedFromBase', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { excludeFromBasePrivileges: true, @@ -113,6 +116,7 @@ const features = [ id: 'allPrivilegeExcludedFromBase', name: 'allPrivilegeExcludedFromBase', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { excludeFromBasePrivileges: true, diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 98faae6edab2c..ea24560c8ddc9 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -80,6 +80,7 @@ describe('usingPrivileges', () => { id: 'fooFeature', name: 'Foo KibanaFeature', app: ['fooApp', 'foo'], + category: { id: 'foo', label: 'foo' }, navLinkId: 'foo', privileges: null, }), @@ -168,6 +169,7 @@ describe('usingPrivileges', () => { id: 'fooFeature', name: 'Foo KibanaFeature', app: ['foo'], + category: { id: 'foo', label: 'foo' }, navLinkId: 'foo', privileges: null, }), @@ -322,6 +324,7 @@ describe('usingPrivileges', () => { name: 'Foo KibanaFeature', navLinkId: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }), new KibanaFeature({ @@ -329,6 +332,7 @@ describe('usingPrivileges', () => { name: 'Bar KibanaFeature', navLinkId: 'bar', app: ['bar'], + category: { id: 'foo', label: 'foo' }, privileges: null, }), ], @@ -469,6 +473,7 @@ describe('usingPrivileges', () => { name: 'Foo KibanaFeature', navLinkId: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }), new KibanaFeature({ @@ -476,6 +481,7 @@ describe('usingPrivileges', () => { name: 'Bar KibanaFeature', navLinkId: 'bar', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }), ], @@ -552,6 +558,7 @@ describe('all', () => { id: 'fooFeature', name: 'Foo KibanaFeature', app: ['foo'], + category: { id: 'foo', label: 'foo' }, navLinkId: 'foo', privileges: null, }), diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index dc261e2eec982..5f19c911fd5d3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -33,6 +33,7 @@ describe(`feature_privilege_builder`, () => { id: 'my-feature', name: 'my-feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: privilege, read: privilege, @@ -64,6 +65,7 @@ describe(`feature_privilege_builder`, () => { id: 'my-feature', name: 'my-feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: privilege, read: privilege, @@ -101,6 +103,7 @@ describe(`feature_privilege_builder`, () => { id: 'my-feature', name: 'my-feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: privilege, read: privilege, @@ -148,6 +151,7 @@ describe(`feature_privilege_builder`, () => { id: 'my-feature', name: 'my-feature', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: privilege, read: privilege, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts index 033040fd2f14b..bdf2c87f40f0b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -14,6 +14,7 @@ describe('featurePrivilegeIterator', () => { name: 'foo', privileges: null, app: [], + category: { id: 'foo', label: 'foo' }, }); const actualPrivileges = Array.from( @@ -29,6 +30,7 @@ describe('featurePrivilegeIterator', () => { const feature = new KibanaFeature({ id: 'foo', name: 'foo', + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -120,6 +122,7 @@ describe('featurePrivilegeIterator', () => { const feature = new KibanaFeature({ id: 'foo', name: 'foo', + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -194,6 +197,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -317,6 +321,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -440,6 +445,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -567,6 +573,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -690,6 +697,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], @@ -815,6 +823,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -923,6 +932,7 @@ describe('featurePrivilegeIterator', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { api: ['all-api', 'read-api'], diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index dd8ac44386dbd..6f721c91fbd67 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -21,6 +21,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], + category: { id: 'foo', label: 'foo' }, catalogue: ['catalogue-1', 'catalogue-2'], management: { foo: ['management-1', 'management-2'], @@ -66,6 +67,7 @@ describe('features', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -165,6 +167,7 @@ describe('features', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }), ]; @@ -207,6 +210,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], management: { foo: ['ignore-me-1', 'ignore-me-2'], @@ -327,6 +331,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], management: { foo: ['ignore-me-1', 'ignore-me-2'], @@ -409,6 +414,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], management: { foo: ['ignore-me-1', 'ignore-me-2'], @@ -467,6 +473,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], management: { foo: ['ignore-me-1', 'ignore-me-2'], @@ -532,6 +539,7 @@ describe('features', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: [], + category: { id: 'foo', label: 'foo' }, catalogue: ['ignore-me-1', 'ignore-me-2'], management: { foo: ['ignore-me-1', 'ignore-me-2'], @@ -602,6 +610,7 @@ describe('reserved', () => { icon: 'arrowDown', navLinkId: 'kibana:foo', app: ['app-1', 'app-2'], + category: { id: 'foo', label: 'foo' }, catalogue: ['catalogue-1', 'catalogue-2'], management: { foo: ['management-1', 'management-2'], @@ -644,6 +653,7 @@ describe('reserved', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { privileges: [ @@ -708,6 +718,7 @@ describe('reserved', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -749,6 +760,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -876,6 +888,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -1075,6 +1088,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, excludeFromBasePrivileges: true, privileges: { all: { @@ -1216,6 +1230,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -1379,6 +1394,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, excludeFromBasePrivileges: true, privileges: { all: { @@ -1508,6 +1524,7 @@ describe('subFeatures', () => { name: 'Foo KibanaFeature', icon: 'arrowDown', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 8e6d72670c8d9..d449eb29d53d8 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -12,6 +12,7 @@ it('allows features to be defined without privileges', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, }); @@ -23,6 +24,7 @@ it('allows features with reserved privileges to be defined', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -49,6 +51,7 @@ it('allows features with sub-features to be defined', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -112,6 +115,7 @@ it('does not allow features with sub-features which have id conflicts with the m id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -162,6 +166,7 @@ it('does not allow features with sub-features which have id conflicts with the p id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { @@ -212,6 +217,7 @@ it('does not allow features with sub-features which have id conflicts each other id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { savedObject: { diff --git a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts index d91a4d4151316..0c7d12f67f4b9 100644 --- a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts @@ -13,6 +13,7 @@ it('allows features to be defined without privileges', () => { name: 'foo', app: [], privileges: null, + category: { id: 'foo', label: 'foo' }, }); validateReservedPrivileges([feature]); @@ -23,6 +24,7 @@ it('allows features with a single reserved privilege to be defined', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -49,6 +51,7 @@ it('allows multiple features with reserved privileges to be defined', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -71,6 +74,7 @@ it('allows multiple features with reserved privileges to be defined', () => { id: 'foo2', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -97,6 +101,7 @@ it('prevents a feature from specifying the same reserved privilege id', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -135,6 +140,7 @@ it('prevents features from sharing a reserved privilege id', () => { id: 'foo', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', @@ -157,6 +163,7 @@ it('prevents features from sharing a reserved privilege id', () => { id: 'foo2', name: 'foo', app: [], + category: { id: 'foo', label: 'foo' }, privileges: null, reserved: { description: 'foo', diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index 6e9b88f30479f..811ea080b4316 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -87,6 +87,7 @@ const putRoleTest = ( id: 'feature_1', name: 'feature 1', app: [], + category: { id: 'foo', label: 'foo' }, privileges: { all: { ui: [], diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 7ada34ff5ccac..86d1b68ba761e 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -609,22 +609,83 @@ describe('#find', () => { await expectGeneralError(client.find, { type: type1 }); }); - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + test(`returns empty result when unauthorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); - await expectForbiddenError(client.find, { options }); - }); + const result = await client.find(options); - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); - await expectForbiddenError(client.find, { options }); + expect(clientOpts.baseClient.find).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'find', + [type1], + options.namespaces, + [{ spaceId: 'some-ns', privilege: 'mock-saved_object:foo/find' }], + { options } + ); + expect(result).toEqual({ page: 1, per_page: 20, total: 0, saved_objects: [] }); }); - test(`returns result of baseClient.find when authorized`, async () => { + test(`returns result of baseClient.find when fully authorized`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); const result = await expectSuccess(client.find, { options }); + expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ + ...options, + typeToNamespacesMap: undefined, + }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`returns result of baseClient.find when partially authorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username: USERNAME, + privileges: { + kibana: [ + { resource: 'some-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, + { resource: 'some-ns', privilege: 'mock-saved_object:bar/find', authorized: true }, + { resource: 'some-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, + { resource: 'some-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + { resource: 'another-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, + { resource: 'another-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, + { resource: 'another-ns', privilege: 'mock-saved_object:baz/find', authorized: true }, + { resource: 'another-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:foo/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + ], + }, + }); + + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); + + const options = Object.freeze({ + type: ['foo', 'bar', 'baz', 'qux'], + namespaces: ['some-ns', 'another-ns', 'forbidden-ns'], + }); + const result = await client.find(options); + // 'expect(clientOpts.baseClient.find).toHaveBeenCalledWith' resulted in false negatives, resorting to manually comparing mock call args + expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ + ...options, + typeToNamespacesMap: new Map([ + ['foo', ['some-ns', 'another-ns']], + ['bar', ['some-ns']], + ['baz', ['another-ns']], + // qux is not authorized, so there is no entry for it + // forbidden-ns is completely forbidden, so there are no entries with this namespace + ]), + type: '', + namespaces: [], + }); expect(result).toEqual(apiCallReturnValue); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 16e52c69f274f..f5de8f4b226f3 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -16,6 +16,7 @@ import { SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsUtils, } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; @@ -39,8 +40,19 @@ interface SavedObjectsNamespaces { saved_objects: SavedObjectNamespaces[]; } -function uniq(arr: T[]): T[] { - return Array.from(new Set(arr)); +interface EnsureAuthorizedOptions { + args?: Record; + auditAction?: string; + requireFullAuthorization?: boolean; +} + +interface EnsureAuthorizedResult { + status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; + typeMap: Map; +} +interface EnsureAuthorizedTypeResult { + authorizedSpaces: string[]; + isGloballyAuthorized?: boolean; } export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { @@ -72,7 +84,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); + const args = { type, attributes, options }; + await this.ensureAuthorized(type, 'create', options.namespace, { args }); const savedObject = await this.baseClient.create(type, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -82,9 +95,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} ) { - const types = this.getUniqueObjectTypes(objects); const args = { objects, options }; - await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts'); + const types = this.getUniqueObjectTypes(objects); + await this.ensureAuthorized(types, 'bulk_create', options.namespace, { + args, + auditAction: 'checkConflicts', + }); const response = await this.baseClient.checkConflicts(objects, options); return response; @@ -94,11 +110,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array>, options: SavedObjectsBaseOptions = {} ) { + const args = { objects, options }; await this.ensureAuthorized( this.getUniqueObjectTypes(objects), 'bulk_create', options.namespace, - { objects, options } + { args } ); const response = await this.baseClient.bulkCreate(objects, options); @@ -106,7 +123,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options }); + const args = { type, id, options }; + await this.ensureAuthorized(type, 'delete', options.namespace, { args }); return await this.baseClient.delete(type, id, options); } @@ -121,9 +139,29 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra `_find across namespaces is not permitted when the Spaces plugin is disabled.` ); } - await this.ensureAuthorized(options.type, 'find', options.namespaces, { options }); + const args = { options }; + const { status, typeMap } = await this.ensureAuthorized( + options.type, + 'find', + options.namespaces, + { args, requireFullAuthorization: false } + ); + + if (status === 'unauthorized') { + // return empty response + return SavedObjectsUtils.createEmptyFindResponse(options); + } - const response = await this.baseClient.find(options); + const typeToNamespacesMap = Array.from(typeMap).reduce>( + (acc, [type, { authorizedSpaces, isGloballyAuthorized }]) => + isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces), + new Map() + ); + const response = await this.baseClient.find({ + ...options, + typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation + ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined + }); return await this.redactSavedObjectsNamespaces(response); } @@ -131,9 +169,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { + const args = { objects, options }; await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, { - objects, - options, + args, }); const response = await this.baseClient.bulkGet(objects, options); @@ -141,7 +179,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args }); const savedObject = await this.baseClient.get(type, id, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -154,7 +193,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsUpdateOptions = {} ) { const args = { type, id, attributes, options }; - await this.ensureAuthorized(type, 'update', options.namespace, args); + await this.ensureAuthorized(type, 'update', options.namespace, { args }); const savedObject = await this.baseClient.update(type, id, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -169,13 +208,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const args = { type, id, namespaces, options }; const { namespace } = options; // To share an object, the user must have the "create" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'create', namespaces, args, 'addToNamespacesCreate'); + await this.ensureAuthorized(type, 'create', namespaces, { + args, + auditAction: 'addToNamespacesCreate', + }); // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will // result in a 404 error. - await this.ensureAuthorized(type, 'update', namespace, args, 'addToNamespacesUpdate'); + await this.ensureAuthorized(type, 'update', namespace, { + args, + auditAction: 'addToNamespacesUpdate', + }); const result = await this.baseClient.addToNamespaces(type, id, namespaces, options); return await this.redactSavedObjectNamespaces(result); @@ -189,7 +234,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { type, id, namespaces, options }; // To un-share an object, the user must have the "delete" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'delete', namespaces, args, 'deleteFromNamespaces'); + await this.ensureAuthorized(type, 'delete', namespaces, { + args, + auditAction: 'deleteFromNamespaces', + }); const result = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); return await this.redactSavedObjectNamespaces(result); @@ -205,9 +253,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra .filter(({ namespace }) => namespace !== undefined) .map(({ namespace }) => namespace!); const namespaces = [options?.namespace, ...objectNamespaces]; + const args = { objects, options }; await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { - objects, - options, + args, }); const response = await this.baseClient.bulkUpdate(objects, options); @@ -228,11 +276,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async ensureAuthorized( typeOrTypes: string | string[], action: string, - namespaceOrNamespaces?: string | Array, - args?: Record, - auditAction: string = action, - requiresAll = true - ) { + namespaceOrNamespaces: undefined | string | Array, + options: EnsureAuthorizedOptions = {} + ): Promise { + const { args, auditAction = action, requireFullAuthorization = true } = options; const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actionsToTypesMap = new Map( types.map((type) => [this.actions.savedObject.get(type, action), type]) @@ -245,27 +292,60 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined) ).sort() as string[]; - const isAuthorized = - (requiresAll && hasAllRequested) || - (!requiresAll && privileges.kibana.some(({ authorized }) => authorized)); - if (isAuthorized) { - this.auditLogger.savedObjectsAuthorizationSuccess( + const missingPrivileges = this.getMissingPrivileges(privileges); + const typeMap = privileges.kibana.reduce>( + (acc, { resource, privilege, authorized }) => { + if (!authorized) { + return acc; + } + const type = actionsToTypesMap.get(privilege)!; // always defined + const value = acc.get(type) ?? { authorizedSpaces: [] }; + if (resource === undefined) { + return acc.set(type, { ...value, isGloballyAuthorized: true }); + } + const authorizedSpaces = value.authorizedSpaces.concat(resource); + return acc.set(type, { ...value, authorizedSpaces }); + }, + new Map() + ); + + const logAuthorizationFailure = () => { + this.auditLogger.savedObjectsAuthorizationFailure( username, auditAction, types, spaceIds, + missingPrivileges, args ); - } else { - const missingPrivileges = this.getMissingPrivileges(privileges); - this.auditLogger.savedObjectsAuthorizationFailure( + }; + const logAuthorizationSuccess = (typeArray: string[], spaceIdArray: string[]) => { + this.auditLogger.savedObjectsAuthorizationSuccess( username, auditAction, - types, - spaceIds, - missingPrivileges, + typeArray, + spaceIdArray, args ); + }; + + if (hasAllRequested) { + logAuthorizationSuccess(types, spaceIds); + return { typeMap, status: 'fully_authorized' }; + } else if (!requireFullAuthorization) { + const isPartiallyAuthorized = privileges.kibana.some(({ authorized }) => authorized); + if (isPartiallyAuthorized) { + for (const [type, { isGloballyAuthorized, authorizedSpaces }] of typeMap.entries()) { + // generate an individual audit record for each authorized type + logAuthorizationSuccess([type], isGloballyAuthorized ? spaceIds : authorizedSpaces); + } + return { typeMap, status: 'partially_authorized' }; + } else { + logAuthorizationFailure(); + return { typeMap, status: 'unauthorized' }; + } + } else { + logAuthorizationFailure(); const targetTypes = uniq( missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() ).join(','); @@ -303,19 +383,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { - const comparator = (a: string, b: string) => { - const _a = a.toLowerCase(); - const _b = b.toLowerCase(); - if (_a === '?') { - return 1; - } else if (_a < _b) { - return -1; - } else if (_a > _b) { - return 1; - } - return 0; - }; - return spaceIds.map((spaceId) => (privilegeMap[spaceId] ? spaceId : '?')).sort(comparator); + return spaceIds.map((x) => (privilegeMap[x] ? x : '?')).sort(namespaceComparator); } private async redactSavedObjectNamespaces( @@ -362,3 +430,25 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }; } } + +/** + * Returns all unique elements of an array. + */ +function uniq(arr: T[]): T[] { + return Array.from(new Set(arr)); +} + +/** + * Utility function to sort potentially redacted namespaces. + * Sorts in a case-insensitive manner, and ensures that redacted namespaces ('?') always show up at the end of the array. + */ +function namespaceComparator(a: string, b: string) { + const A = a.toUpperCase(); + const B = b.toUpperCase(); + if (A === '?' && B !== '?') { + return 1; + } else if (A !== '?' && B === '?') { + return -1; + } + return A > B ? 1 : A < B ? -1 : 0; +} diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index f002e13a07cf1..5fbba84467ecf 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -299,6 +299,7 @@ export const type = t.keyof({ query: null, saved_query: null, threshold: null, + threat_match: null, }); export type Type = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts index b666b95ea1e97..777256ff961f9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.mock.ts @@ -48,3 +48,104 @@ export const getAddPrepackagedRulesSchemaDecodedMock = (): AddPrepackagedRulesSc exceptions_list: [], rule_id: 'rule-1', }); + +export const getAddPrepackagedThreatMatchRulesSchemaMock = (): AddPrepackagedRulesSchema => ({ + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + version: 1, + threat_query: '*:*', + threat_index: 'list-index', + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); + +export const getAddPrepackagedThreatMatchRulesSchemaDecodedMock = (): AddPrepackagedRulesSchemaDecoded => ({ + author: [], + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + severity_mapping: [], + type: 'threat_match', + risk_score: 55, + risk_score_mapping: [], + language: 'kuery', + references: [], + actions: [], + enabled: false, + false_positives: [], + from: 'now-6m', + interval: '5m', + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + to: 'now', + threat: [], + throttle: null, + version: 1, + exceptions_list: [], + rule_id: 'rule-1', + threat_query: '*:*', + threat_index: 'list-index', + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index 9b90cf9fdf782..69538f025d95d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -45,6 +45,12 @@ import { RiskScoreMapping, SeverityMapping, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, +} from '../types/threat_mapping'; import { DefaultStringArray, @@ -116,6 +122,10 @@ export const addPrepackagedRulesSchema = t.intersection([ references: DefaultStringArray, // defaults to empty array of strings if not set during decode note, // defaults to "undefined" if not set during decode exceptions_list: DefaultListArray, // defaults to empty array if not set during decode + threat_filters, // defaults to "undefined" if not set during decode + threat_mapping, // defaults to "undefined" if not set during decode + threat_query, // defaults to "undefined" if not set during decode + threat_index, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index 137b40eb648ba..8c916e4f013b4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -17,6 +17,8 @@ import { left } from 'fp-ts/lib/Either'; import { getAddPrepackagedRulesSchemaMock, getAddPrepackagedRulesSchemaDecodedMock, + getAddPrepackagedThreatMatchRulesSchemaMock, + getAddPrepackagedThreatMatchRulesSchemaDecodedMock, } from './add_prepackaged_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; @@ -1597,4 +1599,16 @@ describe('add prepackaged rules schema', () => { expect(message.schema).toEqual(expected); }); }); + + describe('threat_mapping', () => { + test('You can set a threat query, index, mapping, filters on a pre-packaged rule', () => { + const payload = getAddPrepackagedThreatMatchRulesSchemaMock(); + const decoded = addPrepackagedRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getAddPrepackagedThreatMatchRulesSchemaDecodedMock(); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts index f1e87bdb11e75..32299be500b45 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.mock.ts @@ -55,3 +55,103 @@ export const getCreateRulesSchemaDecodedMock = (): CreateRulesSchemaDecoded => ( exceptions_list: [], rule_id: 'rule-1', }); + +export const getCreateThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): CreateRulesSchema => ({ + description: 'Detecting root and admin users', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: ruleId, + threat_query: '*:*', + threat_index: 'list-index', + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); + +export const getCreateThreatMatchRulesSchemaDecodedMock = (): CreateRulesSchemaDecoded => ({ + author: [], + severity_mapping: [], + risk_score_mapping: [], + description: 'Detecting root and admin users', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'threat_match', + risk_score: 55, + language: 'kuery', + references: [], + actions: [], + enabled: true, + false_positives: [], + from: 'now-6m', + interval: '5m', + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + to: 'now', + threat: [], + throttle: null, + version: 1, + exceptions_list: [], + rule_id: 'rule-1', + threat_query: '*:*', + threat_index: 'list-index', + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts index 56bc68a275ee4..19517017743f1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts @@ -16,6 +16,8 @@ import { left } from 'fp-ts/lib/Either'; import { getCreateRulesSchemaMock, getCreateRulesSchemaDecodedMock, + getCreateThreatMatchRulesSchemaMock, + getCreateThreatMatchRulesSchemaDecodedMock, } from './create_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; @@ -1661,4 +1663,16 @@ describe('create rules schema', () => { expect(message.schema).toEqual(expected); }); }); + + describe('threat_mapping', () => { + test('You can set a threat query, index, mapping, filters when creating a rule', () => { + const payload = getCreateThreatMatchRulesSchemaMock(); + const decoded = createRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getCreateThreatMatchRulesSchemaDecodedMock(); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts index 7b6b98383cc33..c024ba1c48f8d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts @@ -46,6 +46,12 @@ import { RiskScoreMapping, SeverityMapping, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, +} from '../types/threat_mapping'; import { DefaultStringArray, @@ -112,6 +118,10 @@ export const createRulesSchema = t.intersection([ note, // defaults to "undefined" if not set during decode version: DefaultVersionNumber, // defaults to 1 if not set during decode exceptions_list: DefaultListArray, // defaults to empty array if not set during decode + threat_mapping, // defaults to "undefined" if not set during decode + threat_query, // defaults to "undefined" if not set during decode + threat_filters, // defaults to "undefined" if not set during decode + threat_index, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts index 43f0901912271..75ad92578318c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getCreateRulesSchemaMock } from './create_rules_schema.mock'; +import { + getCreateRulesSchemaMock, + getCreateThreatMatchRulesSchemaMock, +} from './create_rules_schema.mock'; import { CreateRulesSchema } from './create_rules_schema'; import { createRuleValidateTypeDependents } from './create_rules_type_dependents'; @@ -87,4 +90,39 @@ describe('create_rules_type_dependents', () => { const errors = createRuleValidateTypeDependents(schema); expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); }); + + test('threat_index, threat_query, and threat_mapping are required when type is "threat_match" and validates with it', () => { + const schema: CreateRulesSchema = { + ...getCreateRulesSchemaMock(), + type: 'threat_match', + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual([ + 'when "type" is "threat_match", "threat_index" is required', + 'when "type" is "threat_match", "threat_query" is required', + 'when "type" is "threat_match", "threat_mapping" is required', + ]); + }); + + test('validates with threat_index, threat_query, and threat_mapping when type is "threat_match"', () => { + const schema = getCreateThreatMatchRulesSchemaMock(); + const { threat_filters: threatFilters, ...noThreatFilters } = schema; + const errors = createRuleValidateTypeDependents(noThreatFilters); + expect(errors).toEqual([]); + }); + + test('does NOT validate when threat_mapping is an empty array', () => { + const schema: CreateRulesSchema = { + ...getCreateThreatMatchRulesSchemaMock(), + threat_mapping: [], + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual(['threat_mapping" must have at least one element']); + }); + + test('validates with threat_index, threat_query, threat_mapping, and an optional threat_filters, when type is "threat_match"', () => { + const schema = getCreateThreatMatchRulesSchemaMock(); + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual([]); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts index 91b14fa9b999c..c2a41005ebf4d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts @@ -5,7 +5,7 @@ */ import { isMlRule } from '../../../machine_learning/helpers'; -import { isThresholdRule } from '../../utils'; +import { isThreatMatchRule, isThresholdRule } from '../../utils'; import { CreateRulesSchema } from './create_rules_schema'; export const validateAnomalyThreshold = (rule: CreateRulesSchema): string[] => { @@ -107,6 +107,24 @@ export const validateThreshold = (rule: CreateRulesSchema): string[] => { return []; }; +export const validateThreatMapping = (rule: CreateRulesSchema): string[] => { + let errors: string[] = []; + if (isThreatMatchRule(rule.type)) { + if (!rule.threat_mapping) { + errors = ['when "type" is "threat_match", "threat_mapping" is required', ...errors]; + } else if (rule.threat_mapping.length === 0) { + errors = ['threat_mapping" must have at least one element', ...errors]; + } + if (!rule.threat_query) { + errors = ['when "type" is "threat_match", "threat_query" is required', ...errors]; + } + if (!rule.threat_index) { + errors = ['when "type" is "threat_match", "threat_index" is required', ...errors]; + } + } + return errors; +}; + export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): string[] => { return [ ...validateAnomalyThreshold(schema), @@ -117,5 +135,6 @@ export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): str ...validateTimelineId(schema), ...validateTimelineTitle(schema), ...validateThreshold(schema), + ...validateThreatMapping(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts index e3b4196c90c6c..160dbb92b74cd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.mock.ts @@ -76,3 +76,94 @@ export const ruleIdsToNdJsonString = (ruleIds: string[]) => { const rules = ruleIds.map((ruleId) => getImportRulesSchemaMock(ruleId)); return rulesToNdJsonString(rules); }; + +export const getImportThreatMatchRulesSchemaMock = (ruleId = 'rule-1'): ImportRulesSchema => ({ + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: ruleId, + threat_index: 'index-123', + threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], + threat_query: '*:*', + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); + +export const getImportThreatMatchRulesSchemaDecodedMock = (): ImportRulesSchemaDecoded => ({ + author: [], + description: 'some description', + name: 'Query with a rule id', + query: 'user.name: root or user.name: admin', + severity: 'high', + severity_mapping: [], + type: 'threat_match', + risk_score: 55, + risk_score_mapping: [], + language: 'kuery', + references: [], + actions: [], + enabled: true, + false_positives: [], + from: 'now-6m', + interval: '5m', + max_signals: DEFAULT_MAX_SIGNALS, + tags: [], + to: 'now', + threat: [], + throttle: null, + version: 1, + exceptions_list: [], + rule_id: 'rule-1', + immutable: false, + threat_query: '*:*', + threat_index: 'index-123', + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index 0515bee0052d7..bd25a63e153dd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -20,6 +20,8 @@ import { import { getImportRulesSchemaMock, getImportRulesSchemaDecodedMock, + getImportThreatMatchRulesSchemaMock, + getImportThreatMatchRulesSchemaDecodedMock, } from './import_rules_schema.mock'; import { DEFAULT_MAX_SIGNALS } from '../../../constants'; import { getListArrayMock } from '../types/lists.mock'; @@ -1792,4 +1794,16 @@ describe('import rules schema', () => { expect(message.schema).toEqual(expected); }); }); + + describe('threat_mapping', () => { + test('You can set a threat query, index, mapping, filters on an imported rule', () => { + const payload = getImportThreatMatchRulesSchemaMock(); + const decoded = importRulesSchema.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getImportThreatMatchRulesSchemaDecodedMock(); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 698716fea696e..b63d70783b7b5 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -52,6 +52,12 @@ import { RiskScoreMapping, SeverityMapping, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, +} from '../types/threat_mapping'; import { DefaultStringArray, @@ -135,6 +141,10 @@ export const importRulesSchema = t.intersection([ 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 + threat_filters, // defaults to "undefined" if not set during decode + threat_mapping, // defaults to "undefined" if not set during decode + threat_query, // defaults to "undefined" if not set during decode + threat_index, // defaults to "undefined" if not set during decode }) ), ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index ed9fb8930ea1b..a462b297d37f8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -82,3 +82,31 @@ export const getRulesMlSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSch machine_learning_job_id: 'some_machine_learning_job_id', }; }; + +export const getThreatMatchingSchemaMock = (anchorDate: string = ANCHOR_DATE): RulesSchema => { + return { + ...getRulesSchemaMock(anchorDate), + type: 'threat_match', + threat_index: 'index-123', + threat_mapping: [{ entries: [{ field: 'host.name', type: 'mapping', value: 'host.name' }] }], + threat_query: '*:*', + threat_filters: [ + { + bool: { + must: [ + { + query_string: { + query: 'host.name: linux', + analyze_wildcard: true, + time_zone: 'Zulu', + }, + }, + ], + filter: [], + should: [], + must_not: [], + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts index 36fc063761840..3a47d4af6ac14 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.test.ts @@ -17,11 +17,16 @@ import { addQueryFields, addTimelineTitle, addMlFields, + addThreatMatchFields, } from './rules_schema'; import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; import { TypeAndTimelineOnly } from './type_timeline_only_schema'; -import { getRulesSchemaMock, getRulesMlSchemaMock } from './rules_schema.mocks'; +import { + getRulesSchemaMock, + getRulesMlSchemaMock, + getThreatMatchingSchemaMock, +} from './rules_schema.mocks'; import { ListArray } from '../types/lists'; export const ANCHOR_DATE = '2020-02-20T03:57:54.037Z'; @@ -593,6 +598,36 @@ describe('rules_schema', () => { expect(getPaths(left(message.errors))).toEqual(['invalid keys "query,language"']); expect(message.schema).toEqual({}); }); + + test('it validates a threat_match response', () => { + const payload = getThreatMatchingSchemaMock(); + + const dependents = getDependents(payload); + const decoded = dependents.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + const expected = getThreatMatchingSchemaMock(); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(expected); + }); + + test('it rejects a response with threat_match properties but type of "query"', () => { + const payload: RulesSchema = { + ...getThreatMatchingSchemaMock(), + type: 'query', + }; + + const dependents = getDependents(payload); + const decoded = dependents.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'invalid keys "threat_index,threat_mapping,[{"entries":[{"field":"host.name","type":"mapping","value":"host.name"}]}],threat_query,threat_filters,[{"bool":{"must":[{"query_string":{"query":"host.name: linux","analyze_wildcard":true,"time_zone":"Zulu"}}],"filter":[],"should":[],"must_not":[]}}]"', + ]); + expect(message.schema).toEqual({}); + }); }); describe('addSavedId', () => { @@ -647,6 +682,11 @@ describe('rules_schema', () => { const fields = addQueryFields({ type: 'saved_query' }); expect(fields.length).toEqual(2); }); + + test('should return two fields for a rule of type "threat_match"', () => { + const fields = addQueryFields({ type: 'threat_match' }); + expect(fields.length).toEqual(2); + }); }); describe('addMlFields', () => { @@ -704,4 +744,17 @@ describe('rules_schema', () => { expect(message.schema).toEqual({ ...payload, exceptions_list: [] }); }); }); + + describe('addThreatMatchFields', () => { + test('should return empty array if type is not "threat_match"', () => { + const fields = addThreatMatchFields({ type: 'query' }); + const expected: t.Mixed[] = []; + expect(fields).toEqual(expected); + }); + + test('should return 5 fields for a rule of type "threat_match"', () => { + const fields = addThreatMatchFields({ type: 'threat_match' }); + expect(fields.length).toEqual(5); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index c26a7efb0c288..1c2254f9f8f09 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -60,6 +60,13 @@ import { rule_name_override, timestamp_override, } from '../common/schemas'; +import { + threat_index, + threat_query, + threat_filters, + threat_mapping, +} from '../types/threat_mapping'; + import { DefaultListArray } from '../types/lists_default_array'; import { DefaultStringArray, @@ -114,7 +121,7 @@ export const dependentRulesSchema = t.partial({ language, query, - // when type = saved_query, saved_is is required + // when type = saved_query, saved_id is required saved_id, // These two are required together or not at all. @@ -127,6 +134,12 @@ export const dependentRulesSchema = t.partial({ // Threshold fields threshold, + + // Threat Match fields + threat_filters, + threat_index, + threat_query, + threat_mapping, }); /** @@ -206,7 +219,9 @@ export const addTimelineTitle = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mi }; export const addQueryFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { - if (['eql', 'query', 'saved_query', 'threshold'].includes(typeAndTimelineOnly.type)) { + if ( + ['eql', 'query', 'saved_query', 'threshold', 'threat_match'].includes(typeAndTimelineOnly.type) + ) { return [ t.exact(t.type({ query: dependentRulesSchema.props.query })), t.exact(t.type({ language: dependentRulesSchema.props.language })), @@ -240,6 +255,20 @@ export const addThresholdFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t. } }; +export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { + if (typeAndTimelineOnly.type === 'threat_match') { + return [ + t.exact(t.type({ threat_query: dependentRulesSchema.props.threat_query })), + t.exact(t.type({ threat_index: dependentRulesSchema.props.threat_index })), + t.exact(t.type({ threat_mapping: dependentRulesSchema.props.threat_mapping })), + t.exact(t.partial({ threat_filters: dependentRulesSchema.props.threat_filters })), + t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })), + ]; + } else { + return []; + } +}; + export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed => { const dependents: t.Mixed[] = [ t.exact(requiredRulesSchema), @@ -249,6 +278,7 @@ export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed ...addQueryFields(typeAndTimelineOnly), ...addMlFields(typeAndTimelineOnly), ...addThresholdFields(typeAndTimelineOnly), + ...addThreatMatchFields(typeAndTimelineOnly), ]; if (dependents.length > 1) { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts new file mode 100644 index 0000000000000..63d593ea84e67 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ThreatMapping, + threatMappingEntries, + ThreatMappingEntries, + threat_mapping, +} from './threat_mapping'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { foldLeftRight, getPaths } from '../../../test_utils'; +import { exactCheck } from '../../../exact_check'; + +describe('threat_mapping', () => { + describe('threatMappingEntries', () => { + test('it should validate an entry', () => { + const payload: ThreatMappingEntries = [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, + ]; + const decoded = threatMappingEntries.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 validate an extra entry item', () => { + const payload: ThreatMappingEntries & Array<{ extra: string }> = [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + extra: 'blah', + }, + ]; + const decoded = threatMappingEntries.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extra"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a non string', () => { + const payload = ([ + { + field: 5, + type: 'mapping', + value: 'field.one', + }, + ] as unknown) as ThreatMappingEntries[]; + const decoded = threatMappingEntries.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "5" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a wrong type', () => { + const payload = ([ + { + field: 'field.one', + type: 'invalid', + value: 'field.one', + }, + ] as unknown) as ThreatMappingEntries[]; + const decoded = threatMappingEntries.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('threat_mapping', () => { + test('it should validate a threat mapping', () => { + const payload: ThreatMapping = [ + { + entries: [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, + ], + }, + ]; + const decoded = threat_mapping.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 validate an extra key', () => { + const payload: ThreatMapping & Array<{ extra: string }> = [ + { + entries: [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + }, + ], + extra: 'invalid', + }, + ]; + + const decoded = threat_mapping.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extra"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an extra inner entry', () => { + const payload: ThreatMapping & Array<{ entries: Array<{ extra: string }> }> = [ + { + entries: [ + { + field: 'field.one', + type: 'mapping', + value: 'field.one', + extra: 'blah', + }, + ], + }, + ]; + + const decoded = threat_mapping.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "extra"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an extra inner entry with the wrong data type', () => { + const payload = ([ + { + entries: [ + { + field: 5, + type: 'mapping', + value: 'field.one', + }, + ], + }, + ] as unknown) as ThreatMapping; + + const decoded = threat_mapping.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "entries,field"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts new file mode 100644 index 0000000000000..f2b4754c2d113 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import * as t from 'io-ts'; +import { NonEmptyString } from './non_empty_string'; + +export const threat_query = t.string; +export type ThreatQuery = t.TypeOf; +export const threatQueryOrUndefined = t.union([threat_query, t.undefined]); +export type ThreatQueryOrUndefined = t.TypeOf; + +export const threat_filters = t.array(t.unknown); // Filters are not easily type-able yet +export type ThreatFilters = t.TypeOf; +export const threatFiltersOrUndefined = t.union([threat_filters, t.undefined]); +export type ThreatFiltersOrUndefined = t.TypeOf; + +export const threatMappingEntries = t.array( + t.exact( + t.type({ + field: NonEmptyString, + type: t.keyof({ mapping: null }), + value: NonEmptyString, + }) + ) +); +export type ThreatMappingEntries = t.TypeOf; + +export const threat_mapping = t.array( + t.exact( + t.type({ + entries: threatMappingEntries, + }) + ) +); +export type ThreatMapping = t.TypeOf; + +export const threatMappingOrUndefined = t.union([threat_mapping, t.undefined]); +export type ThreatMappingOrUndefined = t.TypeOf; + +export const threat_index = t.string; +export const threatIndexOrUndefined = t.union([threat_index, t.undefined]); +export type ThreatIndexOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts index 99680ffe41d44..ea50acc9b46be 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hasLargeValueList, hasNestedEntry } from './utils'; +import { hasLargeValueList, hasNestedEntry, isThreatMatchRule } from './utils'; import { EntriesArray } from '../shared_imports'; describe('#hasLargeValueList', () => { @@ -102,4 +102,14 @@ describe('#hasNestedEntry', () => { expect(hasLists).toBeFalsy(); }); + + describe('isThreatMatchRule', () => { + test('it returns true if a threat match rule', () => { + expect(isThreatMatchRule('threat_match')).toEqual(true); + }); + + test('it returns false if not a threat match rule', () => { + expect(isThreatMatchRule('query')).toEqual(false); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 170d28cb5a725..f76417099bb17 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -17,6 +17,7 @@ export const hasNestedEntry = (entries: EntriesArray): boolean => { return found.length > 0; }; -export const isEqlRule = (ruleType: Type | undefined) => ruleType === 'eql'; -export const isThresholdRule = (ruleType: Type | undefined) => ruleType === 'threshold'; -export const isQueryRule = (ruleType: Type | undefined) => ruleType === 'query'; +export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === 'eql'; +export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold'; +export const isQueryRule = (ruleType: Type | undefined): boolean => ruleType === 'query'; +export const isThreatMatchRule = (ruleType: Type): boolean => ruleType === 'threat_match'; diff --git a/x-pack/plugins/security_solution/common/ecs/agent/index.ts b/x-pack/plugins/security_solution/common/ecs/agent/index.ts new file mode 100644 index 0000000000000..6f29a2020c944 --- /dev/null +++ b/x-pack/plugins/security_solution/common/ecs/agent/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AgentEcs { + type?: string[]; +} diff --git a/x-pack/plugins/security_solution/common/ecs/endgame/index.ts b/x-pack/plugins/security_solution/common/ecs/endgame/index.ts index f435db4f47810..d2fc5d61527a5 100644 --- a/x-pack/plugins/security_solution/common/ecs/endgame/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/endgame/index.ts @@ -5,29 +5,17 @@ */ export interface EndgameEcs { - exit_code?: number; - - file_name?: string; - - file_path?: string; - - logon_type?: number; - - parent_process_name?: string; - - pid?: number; - - process_name?: string; - - subject_domain_name?: string; - - subject_logon_id?: string; - - subject_user_name?: string; - - target_domain_name?: string; - - target_logon_id?: string; - - target_user_name?: string; + exit_code?: number[]; + file_name?: string[]; + file_path?: string[]; + logon_type?: number[]; + parent_process_name?: string[]; + pid?: number[]; + process_name?: string[]; + subject_domain_name?: string[]; + subject_logon_id?: string[]; + subject_user_name?: string[]; + target_domain_name?: string[]; + target_logon_id?: string[]; + target_user_name?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/index.ts b/x-pack/plugins/security_solution/common/ecs/index.ts index e31d42b02f80b..b8190463f5da5 100644 --- a/x-pack/plugins/security_solution/common/ecs/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/index.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentEcs } from './agent'; import { AuditdEcs } from './auditd'; import { DestinationEcs } from './destination'; import { DnsEcs } from './dns'; import { EndgameEcs } from './endgame'; import { EventEcs } from './event'; +import { FileEcs } from './file'; import { GeoEcs } from './geo'; import { HostEcs } from './host'; import { NetworkEcs } from './network'; @@ -28,6 +30,7 @@ import { SystemEcs } from './system'; export interface Ecs { _id: string; _index?: string; + agent?: AgentEcs; auditd?: AuditdEcs; destination?: DestinationEcs; dns?: DnsEcs; @@ -49,6 +52,6 @@ export interface Ecs { user?: UserEcs; winlog?: WinlogEcs; process?: ProcessEcs; - file?: File; + file?: FileEcs; system?: SystemEcs; } diff --git a/x-pack/plugins/security_solution/common/ecs/process/index.ts b/x-pack/plugins/security_solution/common/ecs/process/index.ts index 0584d95c8059d..451f1455f55d4 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -5,35 +5,25 @@ */ export interface ProcessEcs { + entity_id?: string[]; hash?: ProcessHashData; - pid?: number[]; - name?: string[]; - ppid?: number[]; - args?: string[]; - executable?: string[]; - title?: string[]; - thread?: Thread; - working_directory?: string[]; } export interface ProcessHashData { md5?: string[]; - sha1?: string[]; - sha256?: string[]; } export interface Thread { id?: number[]; - start?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/rule/index.ts b/x-pack/plugins/security_solution/common/ecs/rule/index.ts index c1ef1ee17ca0c..47316c7791e4b 100644 --- a/x-pack/plugins/security_solution/common/ecs/rule/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/rule/index.ts @@ -6,64 +6,38 @@ export interface RuleEcs { id?: string[]; - rule_id?: string[]; - false_positives: string[]; - saved_id?: string[]; - timeline_id?: string[]; - timeline_title?: string[]; - max_signals?: number[]; - risk_score?: string[]; - output_index?: string[]; - description?: string[]; - from?: string[]; - immutable?: boolean[]; - index?: string[]; - interval?: string[]; - language?: string[]; - query?: string[]; - references?: string[]; - severity?: string[]; - tags?: string[]; - threat?: unknown; - + threshold?: { + field: string; + value: number; + }; type?: string[]; - size?: string[]; - to?: string[]; - enabled?: boolean[]; - filters?: unknown; - created_at?: string[]; - updated_at?: string[]; - created_by?: string[]; - updated_by?: string[]; - version?: string[]; - note?: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/signal/index.ts b/x-pack/plugins/security_solution/common/ecs/signal/index.ts index 66e35e26af341..55a889f3a5dd1 100644 --- a/x-pack/plugins/security_solution/common/ecs/signal/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/signal/index.ts @@ -8,6 +8,6 @@ import { RuleEcs } from '../rule'; export interface SignalEcs { rule?: RuleEcs; - original_time?: string[]; + status?: string[]; } diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index a6018837fa4fe..74ccf9105ba6b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -8,11 +8,13 @@ export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; export const metadataIndexPattern = 'metrics-endpoint.metadata-*'; export const metadataCurrentIndexPattern = 'metrics-endpoint.metadata_current-*'; +export const metadataTransformPrefix = 'metrics-endpoint.metadata-current-default'; export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const TRUSTED_APPS_SUPPORTED_OS_TYPES: readonly string[] = ['macos', 'windows', 'linux']; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 07208214a641a..9634659b1a5dd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -3,20 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - LegacyEndpointEvent, - ResolverEvent, - SafeResolverEvent, - SafeLegacyEndpointEvent, -} from '../types'; +import { LegacyEndpointEvent, ResolverEvent, SafeResolverEvent, ECSField } from '../types'; import { firstNonNullValue, hasValue, values } from './ecs_safety_helpers'; +/** + * Legacy events will define the `endgame` object. This is used to narrow a ResolverEvent. + */ +interface LegacyEvent { + endgame?: object; +} + /* - * Determine if a `ResolverEvent` is the legacy variety. Can be used to narrow `ResolverEvent` to `LegacyEndpointEvent`. + * Determine if a higher level event type is the legacy variety. Can be used to narrow an event type. + * T optionally defines an `endgame` object field used for determining the type of event. If T doesn't contain the + * `endgame` field it will serve as the narrowed type. */ -export function isLegacyEventSafeVersion( - event: SafeResolverEvent -): event is SafeLegacyEndpointEvent { +export function isLegacyEventSafeVersion( + event: LegacyEvent | {} +): event is T { return 'endgame' in event && event.endgame !== undefined; } @@ -27,7 +31,30 @@ export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEven return (event as LegacyEndpointEvent).endgame !== undefined; } -export function isProcessRunning(event: SafeResolverEvent): boolean { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ProcessRunningFields = Partial< + | { + endgame: object; + event: Partial<{ + type: ECSField; + action: ECSField; + }>; + } + | { + event: Partial<{ + type: ECSField; + }>; + } +>; + +/** + * Checks if an event describes a process as running (whether it was started, already running, or changed) + * + * @param event a document to check for running fields + */ +export function isProcessRunning(event: ProcessRunningFields): boolean { if (isLegacyEventSafeVersion(event)) { return ( hasValue(event.event?.type, 'process_start') || @@ -43,7 +70,18 @@ export function isProcessRunning(event: SafeResolverEvent): boolean { ); } -export function timestampSafeVersion(event: SafeResolverEvent): undefined | number { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type TimestampFields = Pick; + +/** + * Extracts the first non null value from the `@timestamp` field in the document. Returns undefined if the field doesn't + * exist in the document. + * + * @param event a document from ES + */ +export function timestampSafeVersion(event: TimestampFields): undefined | number { return firstNonNullValue(event?.['@timestamp']); } @@ -51,7 +89,7 @@ export function timestampSafeVersion(event: SafeResolverEvent): undefined | numb * The `@timestamp` for the event, as a `Date` object. * If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`. */ -export function timestampAsDateSafeVersion(event: SafeResolverEvent): Date | undefined { +export function timestampAsDateSafeVersion(event: TimestampFields): Date | undefined { const value = timestampSafeVersion(event); if (value === undefined) { return undefined; @@ -93,9 +131,30 @@ export function eventId(event: ResolverEvent): number | undefined | string { return event.event.id; } -export function eventSequence(event: SafeResolverEvent): number | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EventSequenceFields = Partial< + | { + endgame: Partial<{ + serial_event_id: ECSField; + }>; + } + | { + event: Partial<{ + sequence: ECSField; + }>; + } +>; + +/** + * Extract the first non null event sequence value from a document. Returns undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function eventSequence(event: EventSequenceFields): number | undefined { if (isLegacyEventSafeVersion(event)) { - return firstNonNullValue(event.endgame.serial_event_id); + return firstNonNullValue(event.endgame?.serial_event_id); } return firstNonNullValue(event.event?.sequence); } @@ -113,7 +172,29 @@ export function entityId(event: ResolverEvent): string { return event.process.entity_id; } -export function entityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EntityIDFields = Partial< + | { + endgame: Partial<{ + unique_pid: ECSField; + }>; + } + | { + process: Partial<{ + entity_id: ECSField; + }>; + } +>; + +/** + * Extract the first non null value from either the `entity_id` or `unique_pid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function entityIDSafeVersion(event: EntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { return event.endgame?.unique_pid === undefined ? undefined @@ -130,14 +211,59 @@ export function parentEntityId(event: ResolverEvent): string | undefined { return event.process.parent?.entity_id; } -export function parentEntityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ParentEntityIDFields = Partial< + | { + endgame: Partial<{ + unique_ppid: ECSField; + }>; + } + | { + process: Partial<{ + parent: Partial<{ + entity_id: ECSField; + }>; + }>; + } +>; + +/** + * Extract the first non null value from either the `parent.entity_id` or `unique_ppid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function parentEntityIDSafeVersion(event: ParentEntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { - return String(firstNonNullValue(event.endgame.unique_ppid)); + return String(firstNonNullValue(event.endgame?.unique_ppid)); } return firstNonNullValue(event.process?.parent?.entity_id); } -export function ancestryArray(event: SafeResolverEvent): string[] | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type AncestryArrayFields = Partial< + | { + endgame: object; + } + | { + process: Partial<{ + Ext: Partial<{ + ancestry: ECSField; + }>; + }>; + } +>; + +/** + * Extracts all ancestry array from a document if it exists. + * + * @param event an ES document + */ +export function ancestryArray(event: AncestryArrayFields): string[] | undefined { if (isLegacyEventSafeVersion(event)) { return undefined; } @@ -146,7 +272,17 @@ export function ancestryArray(event: SafeResolverEvent): string[] | undefined { return values(event.process?.Ext?.ancestry); } -export function getAncestryAsArray(event: SafeResolverEvent | undefined): string[] { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type GetAncestryArrayFields = AncestryArrayFields & ParentEntityIDFields; + +/** + * Returns an array of strings representing the ancestry for a process. + * + * @param event an ES document + */ +export function getAncestryAsArray(event: GetAncestryArrayFields | undefined): string[] { if (!event) { return []; } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index b0c769216732d..c0fbebf73ed8a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -76,7 +76,7 @@ describe('When invoking Trusted Apps Schema', () => { os: 'windows', entries: [ { - field: 'path', + field: 'process.path.text', type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', @@ -111,14 +111,6 @@ describe('When invoking Trusted Apps Schema', () => { expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); }); - it('should validate `description` to be non-empty if defined', () => { - const bodyMsg = { - ...getCreateTrustedAppItem(), - description: '', - }; - expect(() => body.validate(bodyMsg)).toThrow(); - }); - it('should validate `os` to to only accept known values', () => { const bodyMsg = { ...getCreateTrustedAppItem(), @@ -202,7 +194,7 @@ describe('When invoking Trusted Apps Schema', () => { }; expect(() => body.validate(bodyMsg2)).toThrow(); - ['hash', 'path'].forEach((field) => { + ['process.hash.*', 'process.path.text'].forEach((field) => { const bodyMsg3 = { ...getCreateTrustedAppItem(), entries: [ @@ -217,9 +209,55 @@ describe('When invoking Trusted Apps Schema', () => { }); }); - it.todo('should validate `entry.type` is limited to known values'); + it('should validate `entry.type` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'match', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); + + it('should validate `entry.operator` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); - it.todo('should validate `entry.operator` is limited to known values'); + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'included', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); it('should validate `entry.value` required', () => { const { value, ...entry } = getTrustedAppItemEntryItem(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 7c0de84b637c9..3b3bec4a47804 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -22,11 +22,14 @@ export const GetTrustedAppsRequestSchema = { export const PostTrustedAppCreateRequestSchema = { body: schema.object({ name: schema.string({ minLength: 1 }), - description: schema.maybe(schema.string({ minLength: 1 })), + description: schema.maybe(schema.string({ minLength: 0, defaultValue: '' })), os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), entries: schema.arrayOf( schema.object({ - field: schema.oneOf([schema.literal('hash'), schema.literal('path')]), + field: schema.oneOf([ + schema.literal('process.hash.*'), + schema.literal('process.path.text'), + ]), type: schema.literal('match'), operator: schema.literal('included'), value: schema.string({ minLength: 1 }), diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index cc40225ec1a10..6afec75903477 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -297,6 +297,8 @@ export interface HostResultList { request_page_size: number; /* the page index requested */ request_page_index: number; + /* the version of the query strategy */ + query_strategy_version: MetadataQueryStrategyVersions; } /** @@ -504,9 +506,16 @@ export enum HostStatus { UNENROLLING = 'unenrolling', } +export enum MetadataQueryStrategyVersions { + VERSION_1 = 'v1', + VERSION_2 = 'v2', +} + export type HostInfo = Immutable<{ metadata: HostMetadata; host_status: HostStatus; + /* the version of the query strategy */ + query_strategy_version: MetadataQueryStrategyVersions; }>; export type HostMetadataDetails = Immutable<{ diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 7aeb6c6024b99..93e3305078f8d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -12,6 +12,7 @@ import { /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; + export interface GetTrustedListAppsResponse { per_page: number; page: number; @@ -21,21 +22,22 @@ export interface GetTrustedListAppsResponse { /** API Request body for creating a new Trusted App entry */ export type PostTrustedAppCreateRequest = TypeOf; + export interface PostTrustedAppCreateResponse { data: TrustedApp; } -interface MacosLinuxConditionEntry { - field: 'hash' | 'path'; +export interface MacosLinuxConditionEntry { + field: 'process.hash.*' | 'process.path.text'; type: 'match'; operator: 'included'; value: string; } -type WindowsConditionEntry = +export type WindowsConditionEntry = | MacosLinuxConditionEntry | (Omit & { - field: 'signer'; + field: 'process.code_signature'; }); /** Type for a new Trusted App Entry */ diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index b55226b08b800..48437e12f75a5 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -113,3 +113,13 @@ export interface GenericBuckets { } export type StringOrNumber = string | number; + +export interface TimerangeFilter { + range: { + [timestamp: string]: { + gte: string; + lte: string; + format: string; + }; + }; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts index 63a57c20a8593..a39638e48892d 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts @@ -9,6 +9,7 @@ export * from './authentications'; export * from './common'; export * from './details'; export * from './first_last_seen'; +export * from './kpi'; export * from './overview'; export * from './uncommon_processes'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/authentications/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/authentications/index.ts new file mode 100644 index 0000000000000..cbf1f32c3b5fa --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/authentications/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../../common'; +import { RequestBasicOptions } from '../../..'; +import { HostsKpiHistogramData } from '../common'; + +export interface HostsKpiAuthenticationsHistogramCount { + doc_count: number; +} + +export type HostsKpiAuthenticationsRequestOptions = RequestBasicOptions; + +export interface HostsKpiAuthenticationsStrategyResponse extends IEsSearchResponse { + authenticationsSuccess: Maybe; + authenticationsSuccessHistogram: Maybe; + authenticationsFailure: Maybe; + authenticationsFailureHistogram: Maybe; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/common/index.ts new file mode 100644 index 0000000000000..52e65bb995796 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/common/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Maybe } from '../../../../common'; + +export interface HostsKpiHistogramData { + x?: Maybe; + y?: Maybe; +} + +export interface HostsKpiHistogram { + key_as_string: string; + key: number; + doc_count: number; + count: T; +} + +export interface HostsKpiGeneralHistogramCount { + value: number; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/hosts/index.ts new file mode 100644 index 0000000000000..8e8bd97c9b60b --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/hosts/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../../common'; +import { RequestBasicOptions } from '../../..'; +import { HostsKpiHistogramData } from '../common'; + +export type HostsKpiHostsRequestOptions = RequestBasicOptions; + +export interface HostsKpiHostsStrategyResponse extends IEsSearchResponse { + hosts: Maybe; + hostsHistogram: Maybe; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts new file mode 100644 index 0000000000000..dc34f619e0362 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './authentications'; +export * from './common'; +export * from './hosts'; +export * from './unique_ips'; + +import { HostsKpiAuthenticationsStrategyResponse } from './authentications'; +import { HostsKpiHostsStrategyResponse } from './hosts'; +import { HostsKpiUniqueIpsStrategyResponse } from './unique_ips'; + +export enum HostsKpiQueries { + kpiAuthentications = 'hostsKpiAuthentications', + kpiHosts = 'hostsKpiHosts', + kpiUniqueIps = 'hostsKpiUniqueIps', +} + +export type HostsKpiStrategyResponse = + | Omit + | Omit + | Omit; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/unique_ips/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/unique_ips/index.ts new file mode 100644 index 0000000000000..18a603725f401 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/unique_ips/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../../common'; +import { RequestBasicOptions } from '../../..'; +import { HostsKpiHistogramData } from '../common'; + +export type HostsKpiUniqueIpsRequestOptions = RequestBasicOptions; + +export interface HostsKpiUniqueIpsStrategyResponse extends IEsSearchResponse { + uniqueSourceIps: Maybe; + uniqueSourceIpsHistogram: Maybe; + uniqueDestinationIps: Maybe; + uniqueDestinationIpsHistogram: Maybe; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 95f3cd4fd7da7..cfcf613b662bc 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -20,6 +20,13 @@ import { HostsStrategyResponse, HostUncommonProcessesStrategyResponse, HostUncommonProcessesRequestOptions, + HostsKpiQueries, + HostsKpiAuthenticationsStrategyResponse, + HostsKpiAuthenticationsRequestOptions, + HostsKpiHostsStrategyResponse, + HostsKpiHostsRequestOptions, + HostsKpiUniqueIpsStrategyResponse, + HostsKpiUniqueIpsRequestOptions, } from './hosts'; import { NetworkQueries, @@ -70,6 +77,7 @@ export * from './network'; export type FactoryQueryTypes = | HostsQueries + | HostsKpiQueries | NetworkQueries | NetworkKpiQueries | typeof MatrixHistogramQuery; @@ -106,6 +114,12 @@ export type StrategyResponseType = T extends HostsQ ? HostFirstLastSeenStrategyResponse : T extends HostsQueries.uncommonProcesses ? HostUncommonProcessesStrategyResponse + : T extends HostsKpiQueries.kpiAuthentications + ? HostsKpiAuthenticationsStrategyResponse + : T extends HostsKpiQueries.kpiHosts + ? HostsKpiHostsStrategyResponse + : T extends HostsKpiQueries.kpiUniqueIps + ? HostsKpiUniqueIpsStrategyResponse : T extends NetworkQueries.details ? NetworkDetailsStrategyResponse : T extends NetworkQueries.dns @@ -148,6 +162,12 @@ export type StrategyRequestType = T extends HostsQu ? HostFirstLastSeenRequestOptions : T extends HostsQueries.uncommonProcesses ? HostUncommonProcessesRequestOptions + : T extends HostsKpiQueries.kpiAuthentications + ? HostsKpiAuthenticationsRequestOptions + : T extends HostsKpiQueries.kpiHosts + ? HostsKpiHostsRequestOptions + : T extends HostsKpiQueries.kpiUniqueIps + ? HostsKpiUniqueIpsRequestOptions : T extends NetworkQueries.details ? NetworkDetailsRequestOptions : T extends NetworkQueries.dns diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts new file mode 100644 index 0000000000000..0503a9c327467 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Ecs } from '../../../../ecs'; +import { CursorType, Inspect, Maybe } from '../../../common'; +import { TimelineRequestOptionsPaginated } from '../..'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} + +export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { + edges: TimelineEdges[]; + totalCount: number; + pageInfo: { + activePage: number; + totalPages: number; + }; + inspect?: Maybe; +} + +export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsPaginated { + fields: string[]; + fieldRequested: string[]; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts new file mode 100644 index 0000000000000..200f400ef6816 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/common/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Ecs } from '../../../../ecs'; +import { CursorType, Maybe } from '../../../common'; + +export interface TimelineEdges { + node: TimelineItem; + cursor: CursorType; +} + +export interface TimelineItem { + _id: string; + _index?: Maybe; + data: TimelineNonEcsData[]; + ecs: Ecs; +} + +export interface TimelineNonEcsData { + field: string; + value?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts similarity index 50% rename from x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts rename to x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index e5e1c41f4731a..6f9192be40150 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -4,25 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; -import { Inspect, Maybe } from '../../common'; -import { TimelineRequestOptionsPaginated } from '..'; +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestOptionsPaginated } from '../..'; -export interface DetailItem { +export interface TimelineEventsDetailsItem { field: string; values?: Maybe; // eslint-disable-next-line @typescript-eslint/no-explicit-any originalValue?: Maybe; } -export interface TimelineDetailsStrategyResponse extends IEsSearchResponse { - data?: Maybe; +export interface TimelineEventsDetailsStrategyResponse extends IEsSearchResponse { + data?: Maybe; inspect?: Maybe; } -export interface TimelineDetailsRequestOptions extends Partial { +export interface TimelineEventsDetailsRequestOptions + extends Partial { defaultIndex: string[]; - executeQuery: boolean; indexName: string; eventId: string; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts new file mode 100644 index 0000000000000..6bb9461995974 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './all'; +export * from './details'; +export * from './last_event_time'; + +export enum TimelineEventsQueries { + all = 'eventsAll', + details = 'eventsDetails', + lastEventTime = 'eventsLastEventTime', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts new file mode 100644 index 0000000000000..10750503fc807 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/last_event_time/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { Inspect, Maybe } from '../../../common'; +import { TimelineRequestBasicOptions } from '../..'; + +export enum LastEventIndexKey { + hostDetails = 'hostDetails', + hosts = 'hosts', + ipDetails = 'ipDetails', + network = 'network', +} + +export interface LastTimeDetails { + hostName?: Maybe; + ip?: Maybe; +} + +export interface TimelineEventsLastEventTimeStrategyResponse extends IEsSearchResponse { + lastSeen: Maybe; + inspect?: Maybe; +} + +export interface TimelineEventsLastEventTimeRequestOptions + extends Omit { + indexKey: LastEventIndexKey; + details: LastTimeDetails; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts index a7bf61c102cd4..773ee60855886 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/index.ts @@ -3,45 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; -import { Ecs } from '../../ecs'; import { - CursorType, - Maybe, - TimerangeInput, - DocValueFields, - PaginationInput, - PaginationInputPaginated, - SortField, -} from '../common'; -import { TimelineDetailsRequestOptions, TimelineDetailsStrategyResponse } from './details'; - -export * from './details'; + TimelineEventsQueries, + TimelineEventsAllRequestOptions, + TimelineEventsAllStrategyResponse, + TimelineEventsDetailsRequestOptions, + TimelineEventsDetailsStrategyResponse, + TimelineEventsLastEventTimeRequestOptions, + TimelineEventsLastEventTimeStrategyResponse, +} from './events'; +import { DocValueFields, TimerangeInput, SortField } from '../common'; -export enum TimelineQueries { - details = 'details', -} +export * from './events'; -export type TimelineFactoryQueryTypes = TimelineQueries; - -export interface TimelineEdges { - node: TimelineItem; - cursor: CursorType; -} - -export interface TimelineItem { - _id: string; - _index?: Maybe; - data: TimelineNonEcsData[]; - ecs: Ecs; -} - -export interface TimelineNonEcsData { - field: string; - value?: Maybe; -} +export type TimelineFactoryQueryTypes = TimelineEventsQueries; export interface TimelineRequestBasicOptions extends IEsSearchRequest { timerange: TimerangeInput; @@ -51,20 +28,39 @@ export interface TimelineRequestBasicOptions extends IEsSearchRequest { factoryQueryType?: TimelineFactoryQueryTypes; } -export interface TimelineRequestOptions extends TimelineRequestBasicOptions { - pagination: PaginationInput; - sortField?: SortField; +export interface TimelineRequestOptions extends TimelineRequestBasicOptions { + pagination: { + activePage: number; + querySize: number; + }; + sort: SortField; } -export interface TimelineRequestOptionsPaginated extends TimelineRequestBasicOptions { - pagination: PaginationInputPaginated; - sortField?: SortField; +export interface TimelineRequestOptionsPaginated + extends TimelineRequestBasicOptions { + pagination: { + activePage: number; + querySize: number; + }; + sort: SortField; } export type TimelineStrategyResponseType< T extends TimelineFactoryQueryTypes -> = T extends TimelineQueries.details ? TimelineDetailsStrategyResponse : never; +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllStrategyResponse + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsStrategyResponse + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeStrategyResponse + : never; export type TimelineStrategyRequestType< T extends TimelineFactoryQueryTypes -> = T extends TimelineQueries.details ? TimelineDetailsRequestOptions : never; +> = T extends TimelineEventsQueries.all + ? TimelineEventsAllRequestOptions + : T extends TimelineEventsQueries.details + ? TimelineEventsDetailsRequestOptions + : T extends TimelineEventsQueries.lastEventTime + ? TimelineEventsLastEventTimeRequestOptions + : never; diff --git a/x-pack/plugins/security_solution/cypress/.eslintrc.json b/x-pack/plugins/security_solution/cypress/.eslintrc.json index 96a5a52f13e6c..a738652e2d27b 100644 --- a/x-pack/plugins/security_solution/cypress/.eslintrc.json +++ b/x-pack/plugins/security_solution/cypress/.eslintrc.json @@ -2,5 +2,8 @@ "plugins": ["cypress"], "env": { "cypress/globals": true + }, + "rules": { + "import/no-extraneous-dependencies": "off" } } diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts index 3de57b085a9c6..fdfa042e8fcc9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts.spec.ts @@ -30,7 +30,8 @@ import { loginAndWaitForPage } from '../tasks/login'; import { DETECTIONS_URL } from '../urls/navigation'; -describe('Alerts', () => { +// FLAKY: https://github.com/elastic/kibana/issues/77957 +describe.skip('Alerts', () => { context('Closing alerts', () => { beforeEach(() => { esArchiverLoad('alerts'); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index 6194d6892d799..a45b1fd18a4b6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -24,17 +24,16 @@ import { ALL_CASES_TAGS_COUNT, } from '../screens/all_cases'; import { - ACTION, CASE_DETAILS_DESCRIPTION, CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION, + CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, + CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, REPORTER, - USER, } from '../screens/case_details'; import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens/timeline'; @@ -84,8 +83,8 @@ describe('Cases', () => { const expectedTags = case1.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'open'); - cy.get(CASE_DETAILS_USER_ACTION).eq(USER).should('have.text', case1.reporter); - cy.get(CASE_DETAILS_USER_ACTION).eq(ACTION).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${case1.description} ${case1.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts index 6438a738580b7..e09d62d2a87d1 100644 --- a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts @@ -28,7 +28,7 @@ import { resetFields, } from '../tasks/fields_browser'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { openTimelineFieldsBrowser, populateTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -48,7 +48,7 @@ describe('Fields Browser', () => { context('Fields Browser rendering', () => { before(() => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); openTimelineFieldsBrowser(); }); @@ -111,7 +111,7 @@ describe('Fields Browser', () => { context('Editing the timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); openTimelineFieldsBrowser(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts index 53ddff501db82..c19e51c3ada40 100644 --- a/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/inspect.spec.ts @@ -12,7 +12,7 @@ import { import { closesModal, openStatsAndTables } from '../tasks/inspect'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { executeTimelineKQL, openTimelineInspectButton, @@ -58,7 +58,7 @@ describe('Inspect', () => { it('inspects the timeline', () => { const hostExistsQuery = 'host.name: *'; loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL(hostExistsQuery); openTimelineSettings(); openTimelineInspectButton(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts index 6af4d174b9583..3862a89a7d833 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -11,7 +11,7 @@ import { addNewCase, selectCase, } from '../tasks/timeline'; -import { DESCRIPTION_INPUT } from '../screens/create_new_case'; +import { DESCRIPTION_INPUT, ADD_COMMENT_INPUT } from '../screens/create_new_case'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case'; @@ -34,7 +34,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -46,7 +46,7 @@ describe('attach timeline to case', () => { cy.location('origin').then((origin) => { cy.get(DESCRIPTION_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); @@ -66,9 +66,9 @@ describe('attach timeline to case', () => { selectCase(TIMELINE_CASE_ID); cy.location('origin').then((origin) => { - cy.get(DESCRIPTION_INPUT).should( + cy.get(ADD_COMMENT_INPUT).should( 'have.text', - `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))` + `[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:%27${caseTimeline.id}%27,isOpen:!t))` ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts new file mode 100644 index 0000000000000..9f61d11b7ac0f --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_creation.spec.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { timeline } from '../objects/timeline'; + +import { + FAVORITE_TIMELINE, + LOCKED_ICON, + NOTES, + NOTES_BUTTON, + NOTES_COUNT, + NOTES_TEXT_AREA, + PIN_EVENT, + TIMELINE_DESCRIPTION, + // TIMELINE_FILTER, + TIMELINE_QUERY, + TIMELINE_TITLE, +} from '../screens/timeline'; +import { + TIMELINES_DESCRIPTION, + TIMELINES_PINNED_EVENT_COUNT, + TIMELINES_NOTES_COUNT, + TIMELINES_FAVORITE, +} from '../screens/timelines'; + +import { loginAndWaitForPage } from '../tasks/login'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { + addDescriptionToTimeline, + addFilter, + addNameToTimeline, + addNotesToTimeline, + closeNotes, + closeTimeline, + createNewTimeline, + markAsFavorite, + openTimelineFromSettings, + pinFirstEvent, + populateTimeline, + waitForTimelineChanges, +} from '../tasks/timeline'; +import { openTimeline } from '../tasks/timelines'; + +import { OVERVIEW_URL } from '../urls/navigation'; + +describe('Timelines', () => { + before(() => { + cy.server(); + cy.route('PATCH', '**/api/timeline').as('timeline'); + }); + + it('Creates a timeline', async () => { + loginAndWaitForPage(OVERVIEW_URL); + openTimelineUsingToggle(); + populateTimeline(); + addFilter(timeline.filter); + pinFirstEvent(); + + cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); + cy.get(LOCKED_ICON).should('be.visible'); + + addNameToTimeline(timeline.title); + + const response = await cy.wait('@timeline').promisify(); + const timelineId = JSON.parse(response.xhr.responseText).data.persistTimeline.timeline + .savedObjectId; + + addDescriptionToTimeline(timeline.description); + addNotesToTimeline(timeline.notes); + closeNotes(); + markAsFavorite(); + waitForTimelineChanges(); + createNewTimeline(); + closeTimeline(); + openTimelineFromSettings(); + + cy.contains(timeline.title).should('exist'); + cy.get(TIMELINES_DESCRIPTION).first().should('have.text', timeline.description); + cy.get(TIMELINES_PINNED_EVENT_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_NOTES_COUNT).first().should('have.text', '1'); + cy.get(TIMELINES_FAVORITE).first().should('exist'); + + openTimeline(timelineId); + + cy.get(FAVORITE_TIMELINE).should('exist'); + cy.get(TIMELINE_TITLE).should('have.attr', 'value', timeline.title); + cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', timeline.description); + cy.get(TIMELINE_QUERY).should('have.text', timeline.query); + // Comments this assertion until we agreed what to do with the filters. + // cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); + cy.get(NOTES_COUNT).should('have.text', '1'); + cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).should('have.attr', 'placeholder', 'Add a Note'); + cy.get(NOTES).should('have.text', timeline.notes); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index df0a26f3649c0..f62db083172a4 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -19,7 +19,7 @@ import { } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -31,7 +31,7 @@ describe('timeline data providers', () => { }); beforeEach(() => { - openTimeline(); + openTimelineUsingToggle(); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts deleted file mode 100644 index 549cd134a04a4..0000000000000 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_events.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PIN_EVENT } from '../screens/timeline'; - -import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; -import { pinFirstEvent, populateTimeline, unpinFirstEvent } from '../tasks/timeline'; - -import { HOSTS_URL } from '../urls/navigation'; - -describe('timeline events', () => { - before(() => { - loginAndWaitForPage(HOSTS_URL); - openTimeline(); - populateTimeline(); - }); - - after(() => { - unpinFirstEvent(); - }); - - it('pins the first event to the timeline', () => { - cy.server(); - cy.route('POST', '**/api/solutions/security/graphql').as('persistTimeline'); - - pinFirstEvent(); - - cy.wait('@persistTimeline', { timeout: 10000 }).then((response) => { - cy.wrap(response.status).should('eql', 200); - cy.wrap(response.xhr.responseText).should('include', 'persistPinnedEventOnTimeline'); - }); - - cy.get(PIN_EVENT).should('have.attr', 'aria-label', 'Pinned event'); - }); -}); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts index 87639f41d4109..9b3434b5521d4 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts @@ -8,7 +8,7 @@ import { TIMELINE_FLYOUT_HEADER, TIMELINE_NOT_READY_TO_DROP_BUTTON } from '../sc import { dragFirstHostToTimeline, waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline, openTimelineIfClosed } from '../tasks/security_main'; +import { openTimelineUsingToggle, openTimelineIfClosed } from '../tasks/security_main'; import { createNewTimeline } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -25,7 +25,7 @@ describe('timeline flyout button', () => { }); it('toggles open the timeline', () => { - openTimeline(); + openTimelineUsingToggle(); cy.get(TIMELINE_FLYOUT_HEADER).should('have.css', 'visibility', 'visible'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts index a2e2a72a17946..814fcee2b0c5f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_search_or_filter.spec.ts @@ -7,7 +7,7 @@ import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { executeTimelineKQL } from '../tasks/timeline'; import { HOSTS_URL } from '../urls/navigation'; @@ -19,7 +19,7 @@ describe('timeline search or filter KQL bar', () => { it('executes a KQL query', () => { const hostExistsQuery = 'host.name: *'; - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts index 12e6f3db9b61e..e4f303fb89fda 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts @@ -12,7 +12,7 @@ import { } from '../screens/timeline'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { checkIdToggleField, createNewTimeline, @@ -30,7 +30,7 @@ describe('toggle column in timeline', () => { }); beforeEach(() => { - openTimeline(); + openTimelineUsingToggle(); populateTimeline(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts index d8f96aaf5e563..103bbaad8f303 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines_export.spec.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { exportTimeline, waitForTimelinesPanelToBeLoaded } from '../tasks/timeline'; +import { exportTimeline, waitForTimelinesPanelToBeLoaded } from '../tasks/timelines'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; diff --git a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts index 6d605e1d577a9..6c1d73492f30a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/url_state.spec.ts @@ -30,7 +30,7 @@ import { openAllHosts } from '../tasks/hosts/main'; import { waitForIpsTableToBeLoaded } from '../tasks/network/flows'; import { clearSearchBar, kqlSearch, navigateFromHeaderTo } from '../tasks/security_header'; -import { openTimeline } from '../tasks/security_main'; +import { openTimelineUsingToggle } from '../tasks/security_main'; import { addDescriptionToTimeline, addNameToTimeline, @@ -82,7 +82,7 @@ describe('url state', () => { it('sets the timeline start and end dates from the url when locked to global time', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.url); - openTimeline(); + openTimelineUsingToggle(); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should( 'have.attr', @@ -105,7 +105,7 @@ describe('url state', () => { ); cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON).should('have.attr', 'title', ABSOLUTE_DATE.endTime); - openTimeline(); + openTimelineUsingToggle(); cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).should( 'have.attr', @@ -121,7 +121,7 @@ describe('url state', () => { it('sets the url state when timeline/global date pickers are unlinked and timeline start and end date are set', () => { loginAndWaitForPageWithoutDateRange(ABSOLUTE_DATE_RANGE.urlUnlinked); - openTimeline(); + openTimelineUsingToggle(); setTimelineStartDate(ABSOLUTE_DATE.newStartTimeTyped); updateTimelineDates(); setTimelineEndDate(ABSOLUTE_DATE.newEndTimeTyped); @@ -220,7 +220,7 @@ describe('url state', () => { it('sets and reads the url state for timeline by id', () => { loginAndWaitForPage(HOSTS_URL); - openTimeline(); + openTimelineUsingToggle(); executeTimelineKQL('host.name: *'); cy.get(SERVER_SIDE_EVENT_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/objects/timeline.ts b/x-pack/plugins/security_solution/cypress/objects/timeline.ts index ff7e80e5661ad..6121cb9a99b14 100644 --- a/x-pack/plugins/security_solution/cypress/objects/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/objects/timeline.ts @@ -10,6 +10,30 @@ export interface Timeline { query: string; } +export interface CompleteTimeline extends Timeline { + notes: string; + filter: TimelineFilter; +} + +export interface TimelineFilter { + field: string; + operator: string; + value?: string; +} + export interface TimelineWithId extends Timeline { id: string; } + +export const filter: TimelineFilter = { + field: 'host.name', + operator: 'exists', +}; + +export const timeline: CompleteTimeline = { + title: 'Security Timeline', + description: 'This is the best timeline', + query: 'host.name: * ', + notes: 'Yes, the best timeline', + filter, +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index f2cdaa6994356..7b995f5395543 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ACTION = 2; - -export const CASE_DETAILS_DESCRIPTION = '[data-test-subj="markdown-root"]'; +export const CASE_DETAILS_DESCRIPTION = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -17,14 +16,17 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; -export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]'; +export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = + '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = + '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; -export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem'; +export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = + '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; export const PARTICIPANTS = 1; export const REPORTER = 0; - -export const USER = 1; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts index 9431c054d96a4..4f348b4dcdbd1 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export const ADD_COMMENT_INPUT = '[data-test-subj="add-comment"] textarea'; + export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]'; -export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]'; +export const DESCRIPTION_INPUT = '[data-test-subj="caseDescription"] textarea'; -export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]'; +export const INSERT_TIMELINE_BTN = '.euiMarkdownEditorToolbar [aria-label="Insert timeline link"]'; export const LOADING_SPINNER = '[data-test-subj="create-case-loading-spinner"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index bcb64fc947feb..94255a2af8976 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -4,6 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TimelineFilter } from '../objects/timeline'; + +export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]'; + +export const ADD_FILTER = '[data-test-subj="timeline"] [data-test-subj="addFilter"]'; + export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-case"]'; export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON = @@ -15,14 +21,18 @@ export const CASE = (id: string) => { return `[data-test-subj="cases-table-row-${id}"]`; }; +export const CLOSE_NOTES_BTN = '[data-test-subj="notesModal"] .euiButtonIcon'; + export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]'; +export const COMBO_BOX = '.euiComboBoxOption__content'; + export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]'; export const DRAGGABLE_HEADER = '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"] [data-test-subj="draggable-header"]'; -export const EXPORT_TIMELINE_ACTION = '[data-test-subj="export-timeline-action"]'; +export const FAVORITE_TIMELINE = '[data-test-subj="timeline-favorite-filled-star"]'; export const HEADER = '[data-test-subj="header"]'; @@ -34,6 +44,16 @@ export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]'; +export const LOCKED_ICON = '[data-test-subj="timeline-date-picker-lock-button"]'; + +export const NOTES = '[data-test-subj="markdown-root"]'; + +export const NOTES_TEXT_AREA = '[data-test-subj="add-a-note"]'; + +export const NOTES_BUTTON = '[data-test-subj="timeline-notes-button-large"]'; + +export const NOTES_COUNT = '[data-test-subj="timeline-notes-count"]'; + export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]'; export const PIN_EVENT = '[data-test-subj="pin"]'; @@ -45,21 +65,17 @@ export const REMOVE_COLUMN = '[data-test-subj="remove-column"]'; export const RESET_FIELDS = '[data-test-subj="events-viewer-panel"] [data-test-subj="reset-fields"]'; +export const SAVE_FILTER_BTN = '[data-test-subj="saveFilter"]'; + export const SEARCH_OR_FILTER_CONTAINER = '[data-test-subj="timeline-search-or-filter-search-container"]'; export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; -export const TIMELINE = (id: string) => { - return `[data-test-subj="title-${id}"]`; -}; +export const STAR_ICON = '[data-test-subj="timeline-favorite-empty-star"]'; export const TIMELINE_CHANGES_IN_PROGRESS = '[data-test-subj="timeline"] .euiProgress'; -export const TIMELINE_CHECKBOX = (id: string) => { - return `[data-test-subj="checkboxSelectRow-${id}"]`; -}; - export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinner"]'; export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; @@ -74,6 +90,17 @@ export const TIMELINE_DROPPED_DATA_PROVIDERS = '[data-test-subj="providerContain export const TIMELINE_FIELDS_BUTTON = '[data-test-subj="timeline"] [data-test-subj="show-field-browser"]'; +export const TIMELINE_FILTER = (filter: TimelineFilter) => { + return `[data-test-subj="filter filter-enabled filter-key-${filter.field} filter-value-${filter.value} filter-unpinned"]`; +}; + +export const TIMELINE_FILTER_FIELD = '[data-test-subj="filterFieldSuggestionList"]'; + +export const TIMELINE_FILTER_OPERATOR = '[data-test-subj="filterOperatorList"]'; + +export const TIMELINE_FILTER_VALUE = + '[data-test-subj="filterParamsComboBox phraseParamsComboxBox"]'; + export const TIMELINE_FLYOUT_HEADER = '[data-test-subj="eui-flyout-header"]'; export const TIMELINE_FLYOUT_BODY = '[data-test-subj="eui-flyout-body"]'; @@ -89,8 +116,6 @@ export const TIMELINE_SETTINGS_ICON = '[data-test-subj="settings-gear"]'; export const TIMELINE_TITLE = '[data-test-subj="timeline-title"]'; -export const TIMELINES_TABLE = '[data-test-subj="timelines-table"]'; - export const TIMESTAMP_HEADER_FIELD = '[data-test-subj="header-text-@timestamp"]'; export const TIMESTAMP_TOGGLE_FIELD = '[data-test-subj="toggle-field-@timestamp"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timelines.ts b/x-pack/plugins/security_solution/cypress/screens/timelines.ts new file mode 100644 index 0000000000000..e87e3c4f72ca5 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/timelines.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BULK_ACTIONS = '[data-test-subj="utility-bar-action-button"]'; + +export const EXPORT_TIMELINE_ACTION = '[data-test-subj="export-timeline-action"]'; + +export const TIMELINE = (id: string) => { + return `[data-test-subj="title-${id}"]`; +}; + +export const TIMELINE_CHECKBOX = (id: string) => { + return `[data-test-subj="checkboxSelectRow-${id}"]`; +}; + +export const TIMELINES_FAVORITE = '[data-test-subj="favorite-starFilled-star"]'; + +export const TIMELINES_DESCRIPTION = '[data-test-subj="description"]'; + +export const TIMELINES_NOTES_COUNT = '[data-test-subj="notes-count"]'; + +export const TIMELINES_PINNED_EVENT_COUNT = '[data-test-subj="pinned-event-count"]'; + +export const TIMELINES_TABLE = '[data-test-subj="timelines-table"]'; + +export const TIMELINES_USERNAME = '[data-test-subj="username"]'; diff --git a/x-pack/plugins/security_solution/cypress/support/index.d.ts b/x-pack/plugins/security_solution/cypress/support/index.d.ts index f66aeff5d578d..f0b0b8c92c616 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.d.ts +++ b/x-pack/plugins/security_solution/cypress/support/index.d.ts @@ -6,6 +6,7 @@ declare namespace Cypress { interface Chainable { + promisify(): Promise; stubSecurityApi(dataFileName: string): Chainable; stubSearchStrategyApi(dataFileName: string): Chainable; attachFile(fileName: string, fileType?: string): Chainable; diff --git a/x-pack/plugins/security_solution/cypress/support/index.js b/x-pack/plugins/security_solution/cypress/support/index.js index 244781e0ccd01..1328bea0e2918 100644 --- a/x-pack/plugins/security_solution/cypress/support/index.js +++ b/x-pack/plugins/security_solution/cypress/support/index.js @@ -21,6 +21,7 @@ // Import commands.js using ES2015 syntax: import './commands'; +import 'cypress-promise/register'; Cypress.Cookies.defaults({ preserve: 'sid', diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index 1d5d240c5c53d..f5013eed07d29 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -13,7 +13,6 @@ import { INSERT_TIMELINE_BTN, LOADING_SPINNER, TAGS_INPUT, - TIMELINE, TIMELINE_SEARCHBOX, TITLE_INPUT, } from '../screens/create_new_case'; @@ -43,9 +42,6 @@ export const createNewCaseWithTimeline = (newCase: TestCase) => { cy.get(INSERT_TIMELINE_BTN).click({ force: true }); cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`); - cy.get(TIMELINE).should('be.visible'); - cy.wait(300); - cy.get(TIMELINE).eq(0).click({ force: true }); cy.get(SUBMIT_BTN).click({ force: true }); cy.get(LOADING_SPINNER).should('exist'); 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 0daff52de7063..dc89a39d082bc 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 @@ -55,7 +55,7 @@ import { EQL_TYPE, EQL_QUERY_INPUT, } from '../screens/create_new_rule'; -import { TIMELINE } from '../screens/timeline'; +import { TIMELINE } from '../screens/timelines'; export const createAndActivateRule = () => { cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index 47b73db8b96df..6b1f3699d333a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -6,14 +6,14 @@ import { MAIN_PAGE, TIMELINE_TOGGLE_BUTTON } from '../screens/security_main'; -export const openTimeline = () => { +export const openTimelineUsingToggle = () => { cy.get(TIMELINE_TOGGLE_BUTTON).click(); }; export const openTimelineIfClosed = () => { cy.get(MAIN_PAGE).then(($page) => { if ($page.find(TIMELINE_TOGGLE_BUTTON).length === 1) { - openTimeline(); + openTimelineUsingToggle(); } }); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index cd8b197fc4dec..438700bdfca80 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -4,36 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TimelineFilter } from '../objects/timeline'; + import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases'; + import { - BULK_ACTIONS, + ADD_FILTER, + ADD_NOTE_BUTTON, + ATTACH_TIMELINE_TO_EXISTING_CASE_ICON, + ATTACH_TIMELINE_TO_NEW_CASE_ICON, + CASE, CLOSE_TIMELINE_BTN, + CLOSE_NOTES_BTN, + COMBO_BOX, CREATE_NEW_TIMELINE, - EXPORT_TIMELINE_ACTION, - TIMELINE_CHECKBOX, HEADER, ID_FIELD, ID_HEADER_FIELD, ID_TOGGLE_FIELD, + NOTES_BUTTON, + NOTES_TEXT_AREA, + OPEN_TIMELINE_ICON, PIN_EVENT, + REMOVE_COLUMN, + RESET_FIELDS, + SAVE_FILTER_BTN, SEARCH_OR_FILTER_CONTAINER, SERVER_SIDE_EVENT_COUNT, + STAR_ICON, TIMELINE_CHANGES_IN_PROGRESS, TIMELINE_DESCRIPTION, TIMELINE_FIELDS_BUTTON, + TIMELINE_FILTER_FIELD, + TIMELINE_FILTER_OPERATOR, + TIMELINE_FILTER_VALUE, TIMELINE_INSPECT_BUTTON, TIMELINE_SETTINGS_ICON, TIMELINE_TITLE, - TIMELINES_TABLE, TIMESTAMP_TOGGLE_FIELD, TOGGLE_TIMELINE_EXPAND_EVENT, - REMOVE_COLUMN, - RESET_FIELDS, - ATTACH_TIMELINE_TO_NEW_CASE_ICON, - OPEN_TIMELINE_ICON, - ATTACH_TIMELINE_TO_EXISTING_CASE_ICON, - CASE, } from '../screens/timeline'; +import { TIMELINES_TABLE } from '../screens/timelines'; import { drag, drop } from '../tasks/common'; @@ -49,6 +60,24 @@ export const addNameToTimeline = (name: string) => { cy.get(TIMELINE_TITLE).should('have.attr', 'value', name); }; +export const addNotesToTimeline = (notes: string) => { + cy.get(NOTES_BUTTON).click(); + cy.get(NOTES_TEXT_AREA).type(notes); + cy.get(ADD_NOTE_BUTTON).click(); +}; + +export const addFilter = (filter: TimelineFilter) => { + cy.get(ADD_FILTER).click(); + cy.get(TIMELINE_FILTER_FIELD).type(filter.field); + cy.get(COMBO_BOX).contains(filter.field).click(); + cy.get(TIMELINE_FILTER_OPERATOR).type(filter.operator); + cy.get(COMBO_BOX).contains(filter.operator).click(); + if (filter.operator !== 'exists') { + cy.get(TIMELINE_FILTER_VALUE).type(`${filter.value}{enter}`); + } + cy.get(SAVE_FILTER_BTN).click(); +}; + export const addNewCase = () => { cy.get(ALL_CASES_CREATE_NEW_CASE_TABLE_BTN).click(); }; @@ -71,6 +100,10 @@ export const checkIdToggleField = () => { }); }; +export const closeNotes = () => { + cy.get(CLOSE_NOTES_BTN).click(); +}; + export const closeTimeline = () => { cy.get(CLOSE_TIMELINE_BTN).click({ force: true }); }; @@ -89,10 +122,8 @@ export const expandFirstTimelineEventDetails = () => { cy.get(TOGGLE_TIMELINE_EXPAND_EVENT).first().click({ force: true }); }; -export const exportTimeline = (timelineId: string) => { - cy.get(TIMELINE_CHECKBOX(timelineId)).click({ force: true }); - cy.get(BULK_ACTIONS).click({ force: true }); - cy.get(EXPORT_TIMELINE_ACTION).click(); +export const markAsFavorite = () => { + cy.get(STAR_ICON).click(); }; export const openTimelineFieldsBrowser = () => { @@ -160,11 +191,11 @@ export const selectCase = (caseId: string) => { cy.get(CASE(caseId)).click(); }; -export const waitForTimelinesPanelToBeLoaded = () => { - cy.get(TIMELINES_TABLE).should('exist'); -}; - export const waitForTimelineChanges = () => { cy.get(TIMELINE_CHANGES_IN_PROGRESS).should('exist'); cy.get(TIMELINE_CHANGES_IN_PROGRESS).should('not.exist'); }; + +export const waitForTimelinesPanelToBeLoaded = () => { + cy.get(TIMELINES_TABLE).should('exist'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts new file mode 100644 index 0000000000000..1c5ce246a35b3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + TIMELINE_CHECKBOX, + BULK_ACTIONS, + EXPORT_TIMELINE_ACTION, + TIMELINES_TABLE, + TIMELINE, +} from '../screens/timelines'; + +export const exportTimeline = (timelineId: string) => { + cy.get(TIMELINE_CHECKBOX(timelineId)).click({ force: true }); + cy.get(BULK_ACTIONS).click({ force: true }); + cy.get(EXPORT_TIMELINE_ACTION).click(); +}; + +export const openTimeline = (id: string) => { + cy.get(TIMELINE(id)).click(); +}; + +export const waitForTimelinesPanelToBeLoaded = () => { + cy.get(TIMELINES_TABLE).should('exist'); +}; diff --git a/x-pack/plugins/security_solution/package.json b/x-pack/plugins/security_solution/package.json index fd7941fb17cc5..6982c200a5afd 100644 --- a/x-pack/plugins/security_solution/package.json +++ b/x-pack/plugins/security_solution/package.json @@ -1,4 +1,4 @@ -{ + { "author": "Elastic", "name": "security_solution", "version": "8.0.0", diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index ef13c87a92dbb..14c42697dcbb4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -11,14 +11,14 @@ import styled from 'styled-components'; import { CommentRequest } from '../../../../../case/common/api'; import { usePostComment } from '../../containers/use_post_comment'; import { Case } from '../../containers/types'; -import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; import * as i18n from './translations'; import { schema } from './schema'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index e1d7d98ba8c51..246df1c94b817 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -114,34 +114,41 @@ describe('CaseView ', () => { expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual( data.title ); + expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual( data.status ); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`) .first() .text() ).toEqual(data.tags[0]); + expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`) .first() .text() ).toEqual(data.tags[1]); + expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( data.createdBy.username ); + expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false); + expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual( data.createdAt ); + expect( wrapper .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`) .first() - .prop('raw') - ).toEqual(data.description); + .text() + ).toBe(data.description); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 3c3cc95218b03..a8babe729fde0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -31,10 +31,10 @@ import { schema } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import * as i18n from '../../translations'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; +import { MarkdownEditorForm } from '../../../common/components/markdown_editor/eui_form'; import { useGetTags } from '../../containers/use_get_tags'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { useTimelineClick } from '../../../common/utils/timeline/use_timeline_click'; export const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 7c3fcde687033..a60167a18762f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -58,6 +58,7 @@ describe('TagList ', () => { fetchTags, })); }); + it('Renders no tags, and then edit', () => { const wrapper = mount( @@ -69,6 +70,7 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="no-tags"]`).last().exists()).toBeFalsy(); expect(wrapper.find(`[data-test-subj="edit-tags"]`).last().exists()).toBeTruthy(); }); + it('Edit tag on submit', async () => { const wrapper = mount( @@ -81,6 +83,7 @@ describe('TagList ', () => { await waitFor(() => expect(onSubmit).toBeCalledWith(sampleTags)); }); }); + it('Tag options render with new tags added', () => { const wrapper = mount( @@ -92,6 +95,7 @@ describe('TagList ', () => { wrapper.find(`[data-test-subj="caseTags"] [data-test-subj="input"]`).first().prop('options') ).toEqual([{ label: 'coke' }, { label: 'pepsi' }, { label: 'rad' }, { label: 'dude' }]); }); + it('Cancels on cancel', async () => { const props = { ...defaultProps, @@ -102,17 +106,19 @@ describe('TagList ', () => { ); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); }); }); + it('Renders disabled button', () => { const props = { ...defaultProps, disabled: true }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index eeb7c49eceab5..4af781e3c31f4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -10,8 +10,6 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, - EuiBadgeGroup, - EuiBadge, EuiButton, EuiButtonEmpty, EuiButtonIcon, @@ -25,6 +23,8 @@ import { schema } from './schema'; import { CommonUseField } from '../create'; import { useGetTags } from '../../containers/use_get_tags'; +import { Tags } from './tags'; + interface TagListProps { disabled?: boolean; isLoading: boolean; @@ -99,15 +99,7 @@ export const TagList = React.memo( {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} - - {tags.length > 0 && - !isEditTags && - tags.map((tag) => ( - - {tag} - - ))} - + {!isEditTags && } {isEditTags && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx new file mode 100644 index 0000000000000..e257563ce751e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/tags.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiBadgeGroup, EuiBadge, EuiBadgeGroupProps } from '@elastic/eui'; + +interface TagsProps { + tags: string[]; + color?: string; + gutterSize?: EuiBadgeGroupProps['gutterSize']; +} + +const TagsComponent: React.FC = ({ tags, color = 'default', gutterSize }) => { + return ( + <> + {tags.length > 0 && ( + + {tags.map((tag) => ( + + {tag} + + ))} + + )} + + ); +}; + +export const Tags = memo(TagsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx index b5be84db59920..4e5c05f2f1404 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.test.tsx @@ -14,7 +14,7 @@ import { connectorsMock } from '../../containers/configure/mock'; describe('User action tree helpers', () => { const connectors = connectorsMock; it('label title generated for update tags', () => { - const action = getUserAction(['title'], 'update'); + const action = getUserAction(['tags'], 'update'); const result: string | JSX.Element = getLabelTitle({ action, connectors, @@ -27,8 +27,11 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); + it('label title generated for update title', () => { const action = getUserAction(['title'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -44,6 +47,7 @@ describe('User action tree helpers', () => { }"` ); }); + it('label title generated for update description', () => { const action = getUserAction(['description'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -55,6 +59,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`); }); + it('label title generated for update status to open', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'open' }; const result: string | JSX.Element = getLabelTitle({ @@ -66,6 +71,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update status to closed', () => { const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' }; const result: string | JSX.Element = getLabelTitle({ @@ -77,6 +83,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`); }); + it('label title generated for update comment', () => { const action = getUserAction(['comment'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -88,6 +95,7 @@ describe('User action tree helpers', () => { expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`); }); + it('label title generated for pushed incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -105,6 +113,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for needs update incident', () => { const action = getUserAction(['pushed'], 'push-to-service'); const result: string | JSX.Element = getLabelTitle({ @@ -122,6 +131,7 @@ describe('User action tree helpers', () => { JSON.parse(action.newValue).external_url ); }); + it('label title generated for update connector', () => { const action = getUserAction(['connector_id'], 'update'); const result: string | JSX.Element = getLabelTitle({ @@ -136,6 +146,8 @@ describe('User action tree helpers', () => { ` ${i18n.TAGS.toLowerCase()}` ); - expect(wrapper.find(`[data-test-subj="ua-tag"]`).first().text()).toEqual(action.newValue); + expect(wrapper.find(`[data-test-subj="tag-${action.newValue}"]`).first().text()).toEqual( + action.newValue + ); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index e343c3da6cc8b..4d8bb9ba078e5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import React from 'react'; import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; import { CaseUserActions } from '../../containers/types'; +import { CaseServices } from '../../containers/use_get_case_user_actions'; import * as i18n from '../case_view/translations'; +import { Tags } from '../tag_list/tags'; interface LabelTitle { action: CaseUserActions; @@ -44,22 +46,21 @@ export const getLabelTitle = ({ action, connectors, field, firstPush }: LabelTit return ''; }; -const getTagsLabelTitle = (action: CaseUserActions) => ( - - - {action.action === 'add' && i18n.ADDED_FIELD} - {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - - {action.newValue != null && - action.newValue.split(',').map((tag) => ( - - {tag} - - ))} - - -); +const getTagsLabelTitle = (action: CaseUserActions) => { + const tags = action.newValue != null ? action.newValue.split(',') : []; + + return ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + + + + + ); +}; const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) => { const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; @@ -78,3 +79,20 @@ const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: boolean) ); }; + +export const getPushInfo = ( + caseServices: CaseServices, + parsedValue: { connector_id: string; connector_name: string }, + index: number +) => + parsedValue != null + ? { + firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, + parsedConnectorId: parsedValue.connector_id, + parsedConnectorName: parsedValue.connector_name, + } + : { + firstPush: false, + parsedConnectorId: 'none', + parsedConnectorName: 'none', + }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx index d67c364bbda10..d2bb2fb243458 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.test.tsx @@ -6,6 +6,9 @@ import React from 'react'; import { mount } from 'enzyme'; +// we don't have the types for waitFor just yet, so using "as waitFor" until when we do +import { wait as waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form'; @@ -13,9 +16,6 @@ import { useUpdateComment } from '../../containers/use_update_comment'; import { basicCase, basicPush, getUserAction } from '../../containers/mock'; import { UserActionTree } from '.'; import { TestProviders } from '../../../common/mock'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; const fetchUserActions = jest.fn(); const onUpdateField = jest.fn(); @@ -66,9 +66,10 @@ describe('UserActionTree ', () => { expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual( defaultProps.data.createdBy.fullName ); - expect(wrapper.find(`[data-test-subj="user-action-title"] strong`).first().text()).toEqual( - defaultProps.data.createdBy.username - ); + + expect( + wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text() + ).toEqual(defaultProps.data.createdBy.username); }); it('Renders service now update line with top and bottom when push is required', async () => { @@ -76,6 +77,7 @@ describe('UserActionTree ', () => { getUserAction(['pushed'], 'push-to-service'), getUserAction(['comment'], 'update'), ]; + const props = { ...defaultProps, caseServices: { @@ -90,20 +92,18 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeTruthy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy(); }); it('Renders service now update line with top only when push is up to date', async () => { @@ -122,20 +122,17 @@ describe('UserActionTree ', () => { }, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); + const wrapper = mount( + + + + + + ); + expect(wrapper.find(`[data-test-subj="top-footer"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="bottom-footer"]`).exists()).toBeFalsy(); }); - - expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy(); }); it('Outlines comment when update move to link is clicked', async () => { @@ -145,89 +142,104 @@ describe('UserActionTree ', () => { caseUserActions: ourActions, }; - const wrapper = mount( - - - - - - ); - await act(async () => { - wrapper.update(); - }); + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeFalsy(); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(''); - wrapper - .find(`[data-test-subj="comment-update-action"] [data-test-subj="move-to-link"]`) - .first() - .simulate('click'); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(ourActions[0].commentId); + wrapper + .find( + `[data-test-subj="comment-update-action-${ourActions[1].actionId}"] [data-test-subj="move-to-link-${props.data.comments[0].id}"]` + ) + .first() + .simulate('click'); + + await waitFor(() => { + wrapper.update(); + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${props.data.comments[0].id}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); + }); }); it('Switches to markdown when edit is clicked and back to panel when canceled', async () => { - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - - const wrapper = mount( - - - - - - ); - - await act(async () => { - wrapper.update(); - }); + await waitFor(() => { + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) - .first() - .simulate('click'); - - wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) - .first() - .simulate('click'); + wrapper.update(); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` ) - .exists() - ).toEqual(true); + .first() + .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` - ) - .first() - .simulate('click'); + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(true); - expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]` ) - .exists() - ).toEqual(false); + .first() + .simulate('click'); + + expect( + wrapper + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + ) + .exists() + ).toEqual(false); + }); }); it('calls update comment when comment markdown is saved', async () => { @@ -236,6 +248,7 @@ describe('UserActionTree ', () => { ...defaultProps, caseUserActions: ourActions, }; + const wrapper = mount( @@ -243,27 +256,35 @@ describe('UserActionTree ', () => { ); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-ellipses"]` + ) .first() .simulate('click'); + wrapper - .find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`) + .find( + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="property-actions-pencil"]` + ) .first() .simulate('click'); + wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-save-markdown"]` ) .first() .simulate('click'); + await act(async () => { await waitFor(() => { wrapper.update(); expect( wrapper .find( - `[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` + `[data-test-subj="comment-create-action-${props.data.comments[0].id}"] [data-test-subj="user-action-markdown-form"]` ) .exists() ).toEqual(false); @@ -288,93 +309,101 @@ describe('UserActionTree ', () => {
); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) .first() .simulate('click'); + wrapper .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-pencil"]`) .first() .simulate('click'); - wrapper - .find( - `[data-test-subj="user-action-description"] [data-test-subj="user-action-save-markdown"]` - ) - .first() - .simulate('click'); + await act(async () => { - await waitFor(() => { - expect( - wrapper - .find( - `[data-test-subj="user-action-${props.data.id}"] [data-test-subj="user-action-markdown-form"]` - ) - .exists() - ).toEqual(false); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-save-markdown"]`) + .first() + .simulate('click'); }); + + wrapper.update(); + + expect( + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown-form"]`) + .exists() + ).toEqual(false); + + expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); }); it('quotes', async () => { - const commentData = { - comment: '', - }; - const formHookMock = getFormMock(commentData); - const setFieldValue = jest.fn(); - useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); - const props = defaultProps; - const wrapper = mount( - - - - - - ); - await act(async () => { + const commentData = { + comment: '', + }; + const setFieldValue = jest.fn(); + + const formHookMock = getFormMock(commentData); + useFormMock.mockImplementation(() => ({ form: { ...formHookMock, setFieldValue } })); + + const props = defaultProps; + const wrapper = mount( + + + + + + ); + + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`) + .first() + .simulate('click'); + await waitFor(() => { - wrapper - .find( - `[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]` - ) - .first() - .simulate('click'); wrapper.update(); }); - }); - wrapper - .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) - .first() - .simulate('click'); + wrapper + .find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`) + .first() + .simulate('click'); - expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`); + }); }); it('Outlines comment when url param is provided', async () => { - const commentId = 'neat-comment-id'; - const ourActions = [getUserAction(['comment'], 'create')]; - const props = { - ...defaultProps, - caseUserActions: ourActions, - }; - + const commentId = 'basic-comment-id'; jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId }); - const wrapper = mount( - - - - - - ); await act(async () => { - wrapper.update(); - }); + const ourActions = [getUserAction(['comment'], 'create')]; + const props = { + ...defaultProps, + caseUserActions: ourActions, + }; + + const wrapper = mount( + + + + + + ); - expect( - wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') - ).toEqual(commentId); + await waitFor(() => { + wrapper.update(); + }); + + expect( + wrapper + .find(`[data-test-subj="comment-create-action-${commentId}"]`) + .first() + .hasClass('outlined') + ).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index d1263ab13f41b..bada15294de09 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -3,25 +3,38 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import classNames from 'classnames'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiCommentList, + EuiCommentProps, +} from '@elastic/eui'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; -import * as i18n from '../case_view/translations'; +import * as i18n from './translations'; import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { getLabelTitle } from './helpers'; -import { UserActionItem } from './user_action_item'; -import { UserActionMarkdown } from './user_action_markdown'; import { Connector } from '../../../../../case/common/api/cases'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { OnUpdateFields } from '../case_view'; +import { getLabelTitle, getPushInfo } from './helpers'; +import { UserActionAvatar } from './user_action_avatar'; +import { UserActionMarkdown } from './user_action_markdown'; +import { UserActionTimestamp } from './user_action_timestamp'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; +import { UserActionUsername } from './user_action_username'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; export interface UserActionTreeProps { caseServices: CaseServices; @@ -40,6 +53,31 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` margin-bottom: 8px; `; +const MyEuiCommentList = styled(EuiCommentList)` + ${({ theme }) => ` + & .userAction__comment.outlined .euiCommentEvent { + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + } + + & .euiComment.isEdit { + & .euiCommentEvent { + border: none; + box-shadow: none; + } + + & .euiCommentEvent__body { + padding: 0; + } + + & .euiCommentEvent__header { + display: none; + } + } + `} +`; + const DESCRIPTION_ID = 'description'; const NEW_ID = 'newComment'; @@ -86,8 +124,7 @@ export const UserActionTree = React.memo( updateCase, }); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [caseData, handleManageMarkdownEditId, patchComment, updateCase] + [caseData.id, fetchUserActions, patchComment, updateCase] ); const handleOutlineComment = useCallback( @@ -172,117 +209,246 @@ export const UserActionTree = React.memo( } } }, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]); - return ( - <> - {i18n.ADDED_DESCRIPTION}} - fullName={caseData.createdBy.fullName ?? caseData.createdBy.username ?? ''} - markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} - onQuote={handleManageQuote.bind(null, caseData.description)} - username={caseData.createdBy.username ?? i18n.UNKNOWN} - /> - {caseUserActions.map((action, index) => { - if (action.commentId != null && action.action === 'create') { - const comment = caseData.comments.find((c) => c.id === action.commentId); - if (comment != null) { - return ( - {i18n.ADDED_COMMENT}} - fullName={comment.createdBy.fullName ?? comment.createdBy.username ?? ''} - markdown={ - - } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - onQuote={handleManageQuote.bind(null, comment.comment)} - outlineComment={handleOutlineComment} - username={comment.createdBy.username ?? ''} - updatedAt={comment.updatedAt} - /> + const descriptionCommentListObj: EuiCommentProps = useMemo( + () => ({ + username: ( + + ), + event: i18n.ADDED_DESCRIPTION, + 'data-test-subj': 'description-action', + timestamp: , + children: MarkdownDescription, + timelineIcon: ( + + ), + className: classNames({ + isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), + }), + actions: ( + + ), + }), + [ + MarkdownDescription, + caseData, + handleManageMarkdownEditId, + handleManageQuote, + isLoadingDescription, + userCanCrud, + manageMarkdownEditIds, + ] + ); + + const userActions: EuiCommentProps[] = useMemo( + () => + caseUserActions.reduce( + (comments, action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = caseData.comments.find((c) => c.id === action.commentId); + if (comment != null) { + return [ + ...comments, + { + username: ( + + ), + 'data-test-subj': `comment-create-action-${comment.id}`, + timestamp: ( + + ), + className: classNames('userAction__comment', { + outlined: comment.id === selectedOutlineCommentId, + isEdit: manageMarkdownEditIds.includes(comment.id), + }), + children: ( + + ), + timelineIcon: ( + + ), + actions: ( + + ), + }, + ]; + } + } + + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const parsedValue = parseString(`${action.newValue}`); + const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo( + caseServices, + parsedValue, + index ); + + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstPush, + connectors, + }); + + const showTopFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex; + + const showBottomFooter = + action.action === 'push-to-service' && + index === caseServices[parsedConnectorId].lastPushIndex && + caseServices[parsedConnectorId].hasDataToPush; + + let footers: EuiCommentProps[] = []; + + if (showTopFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.ALREADY_PUSHED_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortUp', + 'data-test-subj': 'top-footer', + }, + ]; + } + + if (showBottomFooter) { + footers = [ + ...footers, + { + username: '', + type: 'update', + event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${parsedConnectorName}`), + timelineIcon: 'sortDown', + 'data-test-subj': 'bottom-footer', + }, + ]; + } + + return [ + ...comments, + { + username: ( + + ), + type: 'update', + event: labelTitle, + 'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`, + timestamp: , + timelineIcon: + action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot', + actions: ( + + + + + {action.action === 'update' && action.commentId != null && ( + + + + )} + + ), + }, + ...footers, + ]; } - } - if (action.actionField.length === 1) { - const myField = action.actionField[0]; - const parsedValue = parseString(`${action.newValue}`); - const { firstPush, parsedConnectorId, parsedConnectorName } = - parsedValue != null - ? { - firstPush: caseServices[parsedValue.connector_id].firstPushIndex === index, - parsedConnectorId: parsedValue.connector_id, - parsedConnectorName: parsedValue.connector_name, - } - : { - firstPush: false, - parsedConnectorId: 'none', - parsedConnectorName: 'none', - }; - const labelTitle: string | JSX.Element = getLabelTitle({ - action, - field: myField, - firstPush, - connectors, - }); - - return ( - {labelTitle}} - linkId={ - action.action === 'update' && action.commentId != null ? action.commentId : null - } - fullName={action.actionBy.fullName ?? action.actionBy.username ?? ''} - outlineComment={handleOutlineComment} - showTopFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex - } - showBottomFooter={ - action.action === 'push-to-service' && - index === caseServices[parsedConnectorId].lastPushIndex && - caseServices[parsedConnectorId].hasDataToPush - } - username={action.actionBy.username ?? ''} - /> - ); - } - return null; - })} + + return comments; + }, + [descriptionCommentListObj] + ), + [ + caseData, + caseServices, + caseUserActions, + connectors, + handleOutlineComment, + descriptionCommentListObj, + handleManageMarkdownEditId, + handleManageQuote, + handleSaveComment, + isLoadingIds, + manageMarkdownEditIds, + selectedOutlineCommentId, + userCanCrud, + ] + ); + + const bottomActions = [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ]; + + const comments = [...userActions, ...bottomActions]; + + return ( + <> + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( @@ -290,17 +456,6 @@ export const UserActionTree = React.memo( )} - ); } diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx new file mode 100644 index 0000000000000..df5c51394b88a --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionAvatar } from './user_action_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('E'); + }); + + it('it shows the username if the fullName is undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeTruthy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().text()).toBe('e'); + }); + + it('shows the loading spinner when the username AND the fullName are undefined', async () => { + wrapper = mount(); + expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().exists()).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="user-action-avatar-loading-spinner"]`).first().exists() + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx index f3276bd50e72c..8339d9bedd123 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_avatar.tsx @@ -4,15 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiAvatar } from '@elastic/eui'; -import React from 'react'; +import React, { memo } from 'react'; +import { EuiAvatar, EuiLoadingSpinner } from '@elastic/eui'; interface UserActionAvatarProps { - name: string; + username?: string | null; + fullName?: string | null; } -export const UserActionAvatar = ({ name }: UserActionAvatarProps) => { +const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => { + const avatarName = fullName && fullName.length > 0 ? fullName : username ?? null; + return ( - + <> + {avatarName ? ( + + ) : ( + + )} + ); }; + +export const UserActionAvatar = memo(UserActionAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx new file mode 100644 index 0000000000000..1f4c858e9581e --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionContentToolbar } from './user_action_content_toolbar'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn().mockReturnValue({ detailName: 'case-1' }), + }; +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: jest.fn(), + }, + }, + }), + }; +}); + +const props = { + id: '1', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionContentToolbar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx new file mode 100644 index 0000000000000..89239c9e8392c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_content_toolbar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { UserActionCopyLink } from './user_action_copy_link'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +interface UserActionContentToolbarProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionContentToolbarComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionContentToolbarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionContentToolbar = memo(UserActionContentToolbarComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx new file mode 100644 index 0000000000000..0566281dac130 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { TestProviders } from '../../../common/mock'; +import { UserActionCopyLink } from './user_action_copy_link'; +import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; + +const searchURL = + '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + }; +}); + +jest.mock('copy-to-clipboard', () => { + return jest.fn(); +}); + +jest.mock('../../../common/components/navigation/use_get_url_search'); + +const mockGetUrlForApp = jest.fn( + (appId: string, options?: { path?: string; absolute?: boolean }) => + `${appId}${options?.path ?? ''}` +); + +jest.mock('../../../common/lib/kibana', () => { + return { + useKibana: () => ({ + services: { + application: { + getUrlForApp: mockGetUrlForApp, + }, + }, + }), + }; +}); + +const props = { + id: 'comment-id', +}; + +describe('UserActionCopyLink ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ detailName: 'case-1' }); + (useGetUrlSearch as jest.Mock).mockReturnValue(searchURL); + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect(wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().exists()).toBeTruthy(); + }); + + it('calls copy clipboard correctly', async () => { + wrapper.find(`[data-test-subj="copy-link-${props.id}"]`).first().simulate('click'); + expect(copy).toHaveBeenCalledWith( + 'securitySolution:case/case-1/comment-id?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx new file mode 100644 index 0000000000000..98de2ab3288a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_copy_link.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { useParams } from 'react-router-dom'; +import copy from 'copy-to-clipboard'; + +import { useFormatUrl, getCaseDetailsUrlWithCommentId } from '../../../common/components/link_to'; +import { SecurityPageName } from '../../../app/types'; +import * as i18n from './translations'; + +interface UserActionCopyLinkProps { + id: string; +} + +const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => { + const { detailName: caseId } = useParams<{ detailName: string }>(); + const { formatUrl } = useFormatUrl(SecurityPageName.case); + + const handleAnchorLink = useCallback(() => { + copy( + formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true }) + ); + }, [caseId, formatUrl, id]); + + return ( + {i18n.COPY_REFERENCE_LINK}

}> + +
+ ); +}; + +export const UserActionCopyLink = memo(UserActionCopyLinkComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx deleted file mode 100644 index eeb728aa7d1df..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_item.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPanel, - EuiHorizontalRule, - EuiText, -} from '@elastic/eui'; -import React from 'react'; -import styled, { css } from 'styled-components'; - -import { UserActionAvatar } from './user_action_avatar'; -import { UserActionTitle } from './user_action_title'; -import * as i18n from './translations'; - -interface UserActionItemProps { - caseConnectorName?: string; - createdAt: string; - 'data-test-subj'?: string; - disabled: boolean; - id: string; - isEditable: boolean; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle?: JSX.Element; - linkId?: string | null; - fullName?: string | null; - markdown?: React.ReactNode; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - username: string; - updatedAt?: string | null; - outlineComment?: (id: string) => void; - showBottomFooter?: boolean; - showTopFooter?: boolean; - idToOutline?: string | null; -} - -export const UserActionItemContainer = styled(EuiFlexGroup)` - ${({ theme }) => css` - & { - background-image: linear-gradient( - to right, - transparent 0, - transparent 15px, - ${theme.eui.euiBorderColor} 15px, - ${theme.eui.euiBorderColor} 17px, - transparent 17px, - transparent 100% - ); - background-repeat: no-repeat; - background-position: left ${theme.eui.euiSizeXXL}; - margin-bottom: ${theme.eui.euiSizeS}; - } - .userAction__panel { - margin-bottom: ${theme.eui.euiSize}; - } - .userAction__circle { - flex-shrink: 0; - margin-right: ${theme.eui.euiSize}; - vertical-align: top; - } - .userAction_loadingAvatar { - position: relative; - margin-right: ${theme.eui.euiSizeXL}; - top: ${theme.eui.euiSizeM}; - left: ${theme.eui.euiSizeS}; - } - .userAction__title { - padding: ${theme.eui.euiSizeS} ${theme.eui.euiSizeL}; - background: ${theme.eui.euiColorLightestShade}; - border-bottom: ${theme.eui.euiBorderThin}; - border-radius: ${theme.eui.euiBorderRadius} ${theme.eui.euiBorderRadius} 0 0; - } - .euiText--small * { - margin-bottom: 0; - } - `} -`; - -const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` - flex-grow: 0; - ${({ theme, showoutline }) => - showoutline === 'true' - ? ` - outline: solid 5px ${theme.eui.euiColorVis1_behindText}; - margin: 0.5em; - transition: 0.8s; - ` - : ''} -`; - -const PushedContainer = styled(EuiFlexItem)` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSizeS}; - margin-bottom: ${theme.eui.euiSizeXL}; - hr { - margin: 5px; - height: ${theme.eui.euiBorderWidthThick}; - } - `} -`; - -const PushedInfoContainer = styled.div` - margin-left: 48px; -`; - -export const UserActionItem = ({ - caseConnectorName, - createdAt, - disabled, - 'data-test-subj': dataTestSubj, - id, - idToOutline, - isEditable, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - fullName, - markdown, - onEdit, - onQuote, - outlineComment, - showBottomFooter, - showTopFooter, - username, - updatedAt, -}: UserActionItemProps) => ( - - - - - {(fullName && fullName.length > 0) || (username && username.length > 0) ? ( - 0 ? fullName : username ?? ''} /> - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - } - linkId={linkId} - onEdit={onEdit} - onQuote={onQuote} - outlineComment={outlineComment} - updatedAt={updatedAt} - username={username} - /> - {markdown} - - )} - - - - {showTopFooter && ( - - - - {i18n.ALREADY_PUSHED_TO_SERVICE(`${caseConnectorName}`)} - - - - {showBottomFooter && ( - - - {i18n.REQUIRED_UPDATE_TO_SERVICE(`${caseConnectorName}`)} - - - )} - - )} - -); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx index 6cf827ea55f1f..f1f7d40009045 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.test.tsx @@ -17,8 +17,9 @@ const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; +const timelineMarkdown = `[timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; const defaultProps = { - content: `A link to a timeline [timeline](http://localhost:5601/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t))`, + content: `A link to a timeline ${timelineMarkdown}`, id: 'markdown-id', isEditable: false, onChangeEditable, @@ -40,7 +41,11 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), @@ -59,8 +64,19 @@ describe('UserActionMarkdown ', () => { ); - wrapper.find(`[data-test-subj="preview-tab"]`).first().simulate('click'); - wrapper.find(`[data-test-subj="markdown-timeline-link"]`).first().simulate('click'); + + // Preview button of Markdown editor + wrapper + .find( + `[data-test-subj="user-action-markdown-form"] .euiMarkdownEditorToolbar .euiButtonEmpty` + ) + .first() + .simulate('click'); + + wrapper + .find(`[data-test-subj="markdown-timeline-link-${timelineId}"]`) + .first() + .simulate('click'); expect(queryTimelineByIdSpy).toBeCalledWith({ apolloClient: mockUseApolloClient(), graphEventId: '', diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index ac2ad179ec60c..45e46b2d7d2db 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -4,18 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiMarkdownFormat, +} from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; -import { Markdown } from '../../../common/components/markdown'; -import { Form, useForm, UseField, useFormData } from '../../../shared_imports'; +import { Form, useForm, UseField } from '../../../shared_imports'; import { schema, Content } from './schema'; -import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; -import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; -import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; -import { useTimelineClick } from '../utils/use_timeline_click'; +import { + MarkdownEditorForm, + parsingPlugins, + processingPlugins, +} from '../../../common/components/markdown_editor/eui_form'; const ContentWrapper = styled.div` padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; @@ -43,24 +49,12 @@ export const UserActionMarkdown = ({ }); const fieldName = 'content'; - const { submit, setFieldValue } = form; - const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] }); - - const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [ - setFieldValue, - ]); - - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - contentFormValue, - onContentChange - ); + const { submit } = form; const handleCancelAction = useCallback(() => { onChangeEditable(id); }, [id, onChangeEditable]); - const handleTimelineClick = useTimelineClick(); - const handleSaveAction = useCallback(async () => { const { isValid, data } = await submit(); if (isValid) { @@ -105,29 +99,24 @@ export const UserActionMarkdown = ({ path={fieldName} component={MarkdownEditorForm} componentProps={{ + 'aria-label': 'Cases markdown editor', + value: content, + id, bottomRightContent: renderButtons({ cancelAction: handleCancelAction, saveAction: handleSaveAction, }), - onClickTimeline: handleTimelineClick, - onCursorPositionUpdate: handleCursorChange, - topRightContent: ( - - ), }} /> ) : ( - - + + + {content} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx new file mode 100644 index 0000000000000..5bb0f50ce25e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionMoveToReference } from './user_action_move_to_reference'; + +const outlineComment = jest.fn(); +const props = { + id: 'move-to-ref-id', + outlineComment, +}; + +describe('UserActionMoveToReference ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().exists() + ).toBeTruthy(); + }); + + it('calls outlineComment correctly', async () => { + wrapper.find(`[data-test-subj="move-to-link-${props.id}"]`).first().simulate('click'); + expect(outlineComment).toHaveBeenCalledWith(props.id); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx new file mode 100644 index 0000000000000..39d016dd69520 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_move_to_reference.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +import * as i18n from './translations'; + +interface UserActionMoveToReferenceProps { + id: string; + outlineComment: (id: string) => void; +} + +const UserActionMoveToReferenceComponent = ({ + id, + outlineComment, +}: UserActionMoveToReferenceProps) => { + const handleMoveToLink = useCallback(() => { + outlineComment(id); + }, [id, outlineComment]); + + return ( + {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> + +
+ ); +}; + +export const UserActionMoveToReference = memo(UserActionMoveToReferenceComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx new file mode 100644 index 0000000000000..bd5da8aca7d4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionPropertyActions } from './user_action_property_actions'; + +const props = { + id: 'property-actions-id', + editLabel: 'edit', + quoteLabel: 'quote', + disabled: false, + isLoading: false, + onEdit: jest.fn(), + onQuote: jest.fn(), +}; + +describe('UserActionPropertyActions ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeFalsy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeTruthy(); + }); + + it('it shows the edit and quote buttons', async () => { + wrapper.find('[data-test-subj="property-actions-ellipses"]').first().simulate('click'); + wrapper.find('[data-test-subj="property-actions-pencil"]').exists(); + wrapper.find('[data-test-subj="property-actions-quote"]').exists(); + }); + + it('it shows the spinner when loading', async () => { + wrapper = mount(); + expect( + wrapper.find('[data-test-subj="user-action-title-loading"]').first().exists() + ).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="property-actions"]').first().exists()).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx new file mode 100644 index 0000000000000..454880e93a27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_property_actions.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo, useCallback } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { PropertyActions } from '../property_actions'; + +interface UserActionPropertyActionsProps { + id: string; + editLabel: string; + quoteLabel: string; + disabled: boolean; + isLoading: boolean; + onEdit: (id: string) => void; + onQuote: (id: string) => void; +} + +const UserActionPropertyActionsComponent = ({ + id, + editLabel, + quoteLabel, + disabled, + isLoading, + onEdit, + onQuote, +}: UserActionPropertyActionsProps) => { + const onEditClick = useCallback(() => onEdit(id), [id, onEdit]); + const onQuoteClick = useCallback(() => onQuote(id), [id, onQuote]); + + const propertyActions = useMemo(() => { + return [ + { + disabled, + iconType: 'pencil', + label: editLabel, + onClick: onEditClick, + }, + { + disabled, + iconType: 'quote', + label: quoteLabel, + onClick: onQuoteClick, + }, + ]; + }, [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]); + return ( + <> + {isLoading && } + {!isLoading && } + + ); +}; + +export const UserActionPropertyActions = memo(UserActionPropertyActionsComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx new file mode 100644 index 0000000000000..a65806520c854 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { TestProviders } from '../../../common/mock'; +import { UserActionTimestamp } from './user_action_timestamp'; + +jest.mock('@kbn/i18n/react', () => { + const originalModule = jest.requireActual('@kbn/i18n/react'); + const FormattedRelative = jest.fn(); + FormattedRelative.mockImplementationOnce(() => '2 days ago'); + FormattedRelative.mockImplementation(() => '20 hours ago'); + + return { + ...originalModule, + FormattedRelative, + }; +}); + +const props = { + createdAt: '2020-09-06T14:40:59.889Z', + updatedAt: '2020-09-07T14:40:59.889Z', +}; + +describe('UserActionTimestamp ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(, { wrappingComponent: TestProviders }); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-title-creation-relative-time"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows only the created time when the updated time is missing', async () => { + const newWrapper = mount(, { + wrappingComponent: TestProviders, + }); + + expect( + newWrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .exists() + ).toBeTruthy(); + expect( + newWrapper.find('[data-test-subj="user-action-title-edited-relative-time"]').first().exists() + ).toBeFalsy(); + }); + + it('it shows the timestamp correctly', async () => { + const createdText = wrapper + .find('[data-test-subj="user-action-title-creation-relative-time"]') + .first() + .text(); + + const updatedText = wrapper + .find('[data-test-subj="user-action-title-edited-relative-time"]') + .first() + .text(); + + expect(`${createdText} (${updatedText})`).toBe('2 days ago (20 hours ago)'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.tsx new file mode 100644 index 0000000000000..72dc5de9cdb3b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_timestamp.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiTextColor } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; + +import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; +import * as i18n from './translations'; + +interface UserActionAvatarProps { + createdAt: string; + updatedAt?: string | null; +} + +const UserActionTimestampComponent = ({ createdAt, updatedAt }: UserActionAvatarProps) => { + return ( + <> + + + + {updatedAt && ( + + {/* be careful of the extra space at the beginning of the parenthesis */} + {' ('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + )} + + ); +}; + +export const UserActionTimestamp = memo(UserActionTimestampComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx deleted file mode 100644 index 0bb02ce69a544..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import copy from 'copy-to-clipboard'; -import { Router, routeData, mockHistory } from '../__mock__/router'; -import { caseUserActions as basicUserActions } from '../../containers/mock'; -import { UserActionTitle } from './user_action_title'; -import { TestProviders } from '../../../common/mock'; - -const outlineComment = jest.fn(); -const onEdit = jest.fn(); -const onQuote = jest.fn(); - -jest.mock('copy-to-clipboard'); -const defaultProps = { - createdAt: basicUserActions[0].actionAt, - disabled: false, - fullName: basicUserActions[0].actionBy.fullName, - id: basicUserActions[0].actionId, - isLoading: false, - labelEditAction: 'labelEditAction', - labelQuoteAction: 'labelQuoteAction', - labelTitle: <>{'cool'}, - linkId: basicUserActions[0].commentId, - onEdit, - onQuote, - outlineComment, - updatedAt: basicUserActions[0].actionAt, - username: basicUserActions[0].actionBy.username, -}; - -describe('UserActionTitle ', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId: '123' }); - }); - - it('Calls copy when copy link is clicked', async () => { - const wrapper = mount( - - - - - - ); - wrapper.find(`[data-test-subj="copy-link"]`).first().simulate('click'); - expect(copy).toBeCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx deleted file mode 100644 index 9477299e563a8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_title.tsx +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiLoadingSpinner, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiButtonIcon, - EuiToolTip, -} from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import copy from 'copy-to-clipboard'; -import { isEmpty } from 'lodash/fp'; -import React, { useMemo, useCallback } from 'react'; -import styled from 'styled-components'; -import { useParams } from 'react-router-dom'; - -import { LocalizedDateTooltip } from '../../../common/components/localized_date_tooltip'; -import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { navTabs } from '../../../app/home/home_navigations'; -import { PropertyActions } from '../property_actions'; -import { SecurityPageName } from '../../../app/types'; -import * as i18n from './translations'; - -const MySpinner = styled(EuiLoadingSpinner)` - .euiLoadingSpinner { - margin-top: 1px; // yes it matters! - } -`; - -interface UserActionTitleProps { - createdAt: string; - disabled: boolean; - id: string; - isLoading: boolean; - labelEditAction?: string; - labelQuoteAction?: string; - labelTitle: JSX.Element; - linkId?: string | null; - fullName?: string | null; - updatedAt?: string | null; - username?: string | null; - onEdit?: (id: string) => void; - onQuote?: (id: string) => void; - outlineComment?: (id: string) => void; -} - -export const UserActionTitle = ({ - createdAt, - disabled, - fullName, - id, - isLoading, - labelEditAction, - labelQuoteAction, - labelTitle, - linkId, - onEdit, - onQuote, - outlineComment, - updatedAt, - username = i18n.UNKNOWN, -}: UserActionTitleProps) => { - const { detailName: caseId } = useParams<{ detailName: string }>(); - const urlSearch = useGetUrlSearch(navTabs.case); - const propertyActions = useMemo(() => { - return [ - ...(labelEditAction != null && onEdit != null - ? [ - { - disabled, - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ] - : []), - ...(labelQuoteAction != null && onQuote != null - ? [ - { - disabled, - iconType: 'quote', - label: labelQuoteAction, - onClick: () => onQuote(id), - }, - ] - : []), - ]; - }, [disabled, id, labelEditAction, onEdit, labelQuoteAction, onQuote]); - - const handleAnchorLink = useCallback(() => { - copy( - `${window.location.origin}${window.location.pathname}#${SecurityPageName.case}/${caseId}/${id}${urlSearch}` - ); - }, [caseId, id, urlSearch]); - - const handleMoveToLink = useCallback(() => { - if (outlineComment != null && linkId != null) { - outlineComment(linkId); - } - }, [linkId, outlineComment]); - return ( - - - - - - {fullName ?? username}

}> - {username} -
-
- {labelTitle} - - - - - - {updatedAt != null && ( - - - {'('} - {i18n.EDITED_FIELD}{' '} - - - - {')'} - - - )} -
-
- - - {!isEmpty(linkId) && ( - - {i18n.MOVE_TO_ORIGINAL_COMMENT}

}> - -
-
- )} - - {i18n.COPY_REFERENCE_LINK}

}> - -
-
- {propertyActions.length > 0 && ( - - {isLoading && } - {!isLoading && } - - )} -
-
-
-
- ); -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx new file mode 100644 index 0000000000000..008eb18aef074 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsername } from './user_action_username'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsername ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the username', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-tooltip"]').text()).toBe('elastic'); + }); + + test('it shows the fullname when hovering the username', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + wrapper.find('[data-test-subj="user-action-username-tooltip"]').first().simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + wrapper.update(); + expect(wrapper.find('.euiToolTipPopover').text()).toBe('Elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); + + test('it shows the username when hovering the username and the fullname is missing', () => { + // Use fake timers so we don't have to wait for the EuiToolTip timeout + jest.useFakeTimers(); + + const newWrapper = mount(); + newWrapper + .find('[data-test-subj="user-action-username-tooltip"]') + .first() + .simulate('mouseOver'); + + // Run the timers so the EuiTooltip will be visible + jest.runAllTimers(); + + newWrapper.update(); + expect(newWrapper.find('.euiToolTipPopover').text()).toBe('elastic'); + + // Clearing all mocks will also reset fake timers. + jest.clearAllMocks(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx new file mode 100644 index 0000000000000..dbc153ddbe577 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiToolTip } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +interface UserActionUsernameProps { + username: string; + fullName?: string; +} + +const UserActionUsernameComponent = ({ username, fullName }: UserActionUsernameProps) => { + return ( + {isEmpty(fullName) ? username : fullName}

} + data-test-subj="user-action-username-tooltip" + > + {username} +
+ ); +}; + +export const UserActionUsername = memo(UserActionUsernameComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx new file mode 100644 index 0000000000000..f8403738c24ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar'; + +const props = { + username: 'elastic', + fullName: 'Elastic', +}; + +describe('UserActionUsernameWithAvatar ', () => { + let wrapper: ReactWrapper; + + beforeAll(() => { + wrapper = mount(); + }); + + it('it renders', async () => { + expect( + wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists() + ).toBeTruthy(); + }); + + it('it shows the avatar', async () => { + expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E'); + }); + + it('it shows the avatar without fullName', async () => { + const newWrapper = mount(); + expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe( + 'e' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx new file mode 100644 index 0000000000000..e2326a3580e6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_username_with_avatar.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; + +import { UserActionUsername } from './user_action_username'; + +interface UserActionUsernameWithAvatarProps { + username: string; + fullName?: string; +} + +const UserActionUsernameWithAvatarComponent = ({ + username, + fullName, +}: UserActionUsernameWithAvatarProps) => { + return ( + + + + + + + + + ); +}; + +export const UserActionUsernameWithAvatar = memo(UserActionUsernameWithAvatarComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts index b1ab509417fe5..980ec89f524bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/translations.ts @@ -16,7 +16,7 @@ export const ALERTS_DOCUMENT_TYPE = i18n.translate( export const TOTAL_COUNT_OF_ALERTS = i18n.translate( 'xpack.securitySolution.alertsView.totalCountOfAlerts', { - defaultMessage: 'external alerts match the search criteria', + defaultMessage: 'external alerts', } ); 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 8068d51a80153..074e6faf80c7d 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 @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; @@ -28,7 +28,7 @@ CollapseLink.displayName = 'CollapseLink'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; id: string; view: View; onEventToggled: () => void; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index 9737a09c89f49..79250ae9bec52 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { BrowserFields, getAllFieldsByName } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { getColumns } from './columns'; @@ -19,7 +19,7 @@ import { search } from './helpers'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; eventId: string; onUpdateColumns: OnUpdateColumns; timelineId: string; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx index f4028c988acb8..bb74935d5703e 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/stateful_event_details.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useState } from 'react'; import { BrowserFields } from '../../containers/source'; -import { DetailItem } from '../../../../common/search_strategy/timeline'; +import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; @@ -16,7 +16,7 @@ import { EventDetails, View } from './event_details'; interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; - data: DetailItem[]; + data: TimelineEventsDetailsItem[]; id: string; onEventToggled: () => void; onUpdateColumns: OnUpdateColumns; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 833688ae57993..037655f594241 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { MockedProvider } from 'react-apollo/test-utils'; import useResizeObserver from 'use-resize-observer/polyfilled'; import '../../mock/match_media'; @@ -26,6 +25,11 @@ import { TimelineId } from '../../../../common/types/timeline'; import { KqlMode } from '../../../timelines/store/timeline/model'; import { SortDirection } from '../../../timelines/components/timeline/body/sort'; import { AlertsTableFilterGroup } from '../../../detections/components/alerts_table/alerts_filter_group'; +import { useTimelineEvents } from '../../../timelines/containers'; + +jest.mock('../../../timelines/containers', () => ({ + useTimelineEvents: jest.fn(), +})); jest.mock('../../components/url_state/normalize_time_range.ts'); @@ -83,20 +87,19 @@ describe('EventsViewer', () => { const mount = useMountAppended(); beforeEach(() => { + (useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]); mockUseFetchIndexPatterns.mockImplementation(() => [{ ...defaultMocks }]); }); test('it renders the "Showing..." subtitle with the expected event count', async () => { const wrapper = mount( - - - + ); @@ -114,14 +117,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -139,14 +140,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -164,14 +163,12 @@ describe('EventsViewer', () => { const wrapper = mount( - - - + ); @@ -187,14 +184,12 @@ describe('EventsViewer', () => { test('it renders the Fields Browser as a settings gear', async () => { const wrapper = mount( - - - + ); @@ -208,21 +203,19 @@ describe('EventsViewer', () => { test('it renders the footer containing the Load More button', async () => { const wrapper = mount( - - - + ); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="TimelineMoreButton"]`).first().exists()).toBe(true); + expect(wrapper.find(`[data-test-subj="timeline-pagination"]`).first().exists()).toBe(true); }); }); @@ -230,14 +223,12 @@ describe('EventsViewer', () => { test(`it renders the ${header.id} default EventsViewer column header`, async () => { const wrapper = mount( - - - + ); @@ -257,13 +248,11 @@ describe('EventsViewer', () => { test('it renders the provided headerFilterGroup', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -277,13 +266,11 @@ describe('EventsViewer', () => { test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -299,13 +286,11 @@ describe('EventsViewer', () => { test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -321,13 +306,11 @@ describe('EventsViewer', () => { test('it does NOT have a visible HeaderFilterGroupWrapper when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -343,13 +326,11 @@ describe('EventsViewer', () => { test('it (still) renders an invisible headerFilterGroup (to maintain state while hidden) when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - } - /> - + } + /> ); @@ -365,9 +346,7 @@ describe('EventsViewer', () => { test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - - + ); @@ -381,9 +360,7 @@ describe('EventsViewer', () => { test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - - + ); @@ -397,9 +374,7 @@ describe('EventsViewer', () => { test('it does NOT render the provided utilityBar when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - - + ); @@ -415,9 +390,7 @@ describe('EventsViewer', () => { test('it renders the inspect button when Resolver is NOT showing, because graphEventId is undefined', async () => { const wrapper = mount( - - - + ); @@ -431,9 +404,7 @@ describe('EventsViewer', () => { test('it renders the inspect button when Resolver is NOT showing, because graphEventId is an empty string', async () => { const wrapper = mount( - - - + ); @@ -447,9 +418,7 @@ describe('EventsViewer', () => { test('it does NOT render the inspect button when Resolver is showing, because graphEventId is a valid id', async () => { const wrapper = mount( - - - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 3d193856a8ae4..2998bd031d674 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -10,9 +10,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; +import { Direction } from '../../../../common/search_strategy'; import { BrowserFields, DocValueFields } from '../../containers/source'; -import { TimelineQuery } from '../../../timelines/containers'; -import { Direction } from '../../../graphql/types'; +import { useTimelineEvents } from '../../../timelines/containers'; import { useKibana } from '../../lib/kibana'; import { ColumnHeaderOptions, KqlMode } from '../../../timelines/store/timeline/model'; import { HeaderSection } from '../header_section'; @@ -196,14 +196,51 @@ const EventsViewerComponent: React.FC = ({ ), [columnsHeader, queryFields] ); + const sortField = useMemo( () => ({ - sortFieldId: sort.columnId, + field: sort.columnId, direction: sort.sortDirection as Direction, }), [sort.columnId, sort.sortDirection] ); + const [ + loading, + { events, updatedAt, inspect, loadPage, pageInfo, refetch, totalCount = 0 }, + ] = useTimelineEvents({ + docValueFields, + fields, + filterQuery: combinedQueries!.filterQuery, + id, + indexPattern, + limit: itemsPerPage, + sort: sortField, + startDate: start, + endDate: end, + skip: !canQueryTimeline, + }); + + const totalCountMinusDeleted = useMemo( + () => (totalCount > 0 ? totalCount - deletedEventIds.length : 0), + [deletedEventIds.length, totalCount] + ); + + const subtitle = useMemo( + () => + `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`, + [totalCountMinusDeleted, unit] + ); + + const nonDeletedEvents = useMemo(() => events.filter((e) => !deletedEventIds.includes(e._id)), [ + deletedEventIds, + events, + ]); + + useEffect(() => { + setIsQueryLoading(loading); + }, [loading]); + return ( = ({ > {canQueryTimeline ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - setIsQueryLoading(loading); - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; - - const subtitle = `${i18n.SHOWING}: ${totalCountMinusDeleted.toLocaleString()} ${unit( - totalCountMinusDeleted - )}`; - - return ( - <> - - {headerFilterGroup && ( - - {headerFilterGroup} - - )} - - {utilityBar && !resolverIsShowing(graphEventId) && ( - {utilityBar?.(refetch, totalCountMinusDeleted)} - )} - - + <> + + {headerFilterGroup && ( + + {headerFilterGroup} + + )} + + {utilityBar && !resolverIsShowing(graphEventId) && ( + {utilityBar?.(refetch, totalCountMinusDeleted)} + )} + + - !deletedEventIds.includes(e._id))} - docValueFields={docValueFields} - id={id} - isEventViewer={true} - refetch={refetch} - sort={sort} - toggleColumn={toggleColumn} - /> + - { - /** Hide the footer if Resolver is showing. */ - !graphEventId && ( -