diff --git a/.eslintignore b/.eslintignore index 4b5e781c26971..d983c4bedfaab 100644 --- a/.eslintignore +++ b/.eslintignore @@ -26,6 +26,7 @@ target /src/plugins/data/common/es_query/kuery/ast/_generated_/** /src/plugins/vis_type_timelion/public/_generated_/** /src/plugins/vis_type_timelion/public/webpackShims/jquery.flot.* +/src/plugins/timelion/public/webpackShims/jquery.flot.* /x-pack/legacy/plugins/**/__tests__/fixtures/** /x-pack/plugins/apm/e2e/**/snapshots.js /x-pack/plugins/apm/e2e/tmp/* diff --git a/.i18nrc.json b/.i18nrc.json index 9af7f17067b8e..e8431fdb3f0e1 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -44,7 +44,7 @@ "src/plugins/telemetry_management_section" ], "tileMap": "src/plugins/tile_map", - "timelion": ["src/legacy/core_plugins/timelion", "src/plugins/vis_type_timelion"], + "timelion": ["src/plugins/timelion", "src/plugins/vis_type_timelion"], "uiActions": "src/plugins/ui_actions", "visDefaultEditor": "src/plugins/vis_default_editor", "visTypeMarkdown": "src/plugins/vis_type_markdown", diff --git a/.sass-lint.yml b/.sass-lint.yml index 56b85adca8a71..50cbe81cc7da2 100644 --- a/.sass-lint.yml +++ b/.sass-lint.yml @@ -1,7 +1,7 @@ files: include: - 'src/legacy/core_plugins/metrics/**/*.s+(a|c)ss' - - 'src/legacy/core_plugins/timelion/**/*.s+(a|c)ss' + - 'src/plugins/timelion/**/*.s+(a|c)ss' - 'src/plugins/vis_type_vislib/**/*.s+(a|c)ss' - 'src/plugins/vis_type_xy/**/*.s+(a|c)ss' - 'x-pack/plugins/canvas/**/*.s+(a|c)ss' diff --git a/NOTICE.txt b/NOTICE.txt index 94312d46c35ec..56280e6e3883e 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -147,6 +147,70 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- +Detection Rules +Copyright 2020 Elasticsearch B.V. + +--- +This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack +which is available under a "MIT" license. The files based on this license are: + +- defense_evasion_via_filter_manager +- discovery_process_discovery_via_tasklist_command +- persistence_priv_escalation_via_accessibility_features +- persistence_via_application_shimming +- defense_evasion_execution_via_trusted_developer_utilities + +MIT License + +Copyright (c) 2019 Edoardo Gerosa, Olaf Hartong + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- +This product bundles rules based on https://github.com/FSecureLABS/leonidas +which is available under a "MIT" license. The files based on this license are: + +- credential_access_secretsmanager_getsecretvalue.toml + +MIT License + +Copyright (c) 2020 F-Secure LABS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + --- This product bundles bootstrap@3.3.6 which is available under a "MIT" license. @@ -220,38 +284,6 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ---- -This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack -which is available under a "MIT" license. The files based on this license are: - -- windows_defense_evasion_via_filter_manager.json -- windows_process_discovery_via_tasklist_command.json -- windows_priv_escalation_via_accessibility_features.json -- windows_persistence_via_application_shimming.json -- windows_execution_via_trusted_developer_utilities.json - -MIT License - -Copyright (c) 2019 Edoardo Gerosa, Olaf Hartong - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --- This product includes code that is adapted from mapbox-gl-js, which is available under a "BSD-3-Clause" license. diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 48d4f929b6851..4ea7b04ebef6d 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -3,11 +3,18 @@ This guide applies to all development within the Kibana project and is recommended for the development of all Kibana plugins. +- [General](#general) +- [HTML](#html) +- [API endpoints](#api-endpoints) +- [TypeScript/JavaScript](#typeScript/javaScript) +- [SASS files](#sass-files) +- [React](#react) + Besides the content in this style guide, the following style guides may also apply to all development within the Kibana project. Please make sure to also read them: -- [Accessibility style guide](https://elastic.github.io/eui/#/guidelines/accessibility) -- [SASS style guide](https://elastic.github.io/eui/#/guidelines/sass) +- [Accessibility style guide (EUI Docs)](https://elastic.github.io/eui/#/guidelines/accessibility) +- [SASS style guide (EUI Docs)](https://elastic.github.io/eui/#/guidelines/sass) ## General @@ -582,6 +589,39 @@ Do not use setters, they cause more problems than they can solve. [sideeffect]: http://en.wikipedia.org/wiki/Side_effect_(computer_science) +## SASS files + +When writing a new component, create a sibling SASS file of the same name and import directly into the **top** of the JS/TS component file. Doing so ensures the styles are never separated or lost on import and allows for better modularization (smaller individual plugin asset footprint). + +All SASS (.scss) files will automatically build with the [EUI](https://elastic.github.io/eui/#/guidelines/sass) & Kibana invisibles (SASS variables, mixins, functions) from the [`globals_[theme].scss` file](src/legacy/ui/public/styles/_globals_v7light.scss). + +While the styles for this component will only be loaded if the component exists on the page, +the styles **will** be global and so it is recommended to use a three letter prefix on your +classes to ensure proper scope. + +**Example:** + +```tsx +// component.tsx + +import './component.scss'; +// All other imports below the SASS import + +export const Component = () => { + return ( +
+ ); +} +``` + +```scss +// component.scss + +.plgComponent { ... } +``` + +Do not use the underscore `_` SASS file naming pattern when importing directly into a javascript file. + ## React The following style guide rules are specific for working with the React framework. diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index ff1623e22f1eb..47c4a52daf303 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -29,7 +29,7 @@ you can switch to the correct version when using nvm by running: ---- nvm use ---- - + Install the latest version of https://yarnpkg.com[yarn]. Bootstrap {kib} and install all the dependencies: @@ -93,13 +93,13 @@ yarn es snapshot --license trial `trial` will give you access to all capabilities. -Read about more options for <>, like connecting to a remote host, running from source, -preserving data inbetween runs, running remote cluster, etc. +Read about more options for <>, like connecting to a remote host, running from source, +preserving data inbetween runs, running remote cluster, etc. [float] === Run {kib} -In another terminal window, start up {kib}. Include developer examples by adding an optional `--run-examples` flag. +In another terminal window, start up {kib}. Include developer examples by adding an optional `--run-examples` flag. [source,bash] ---- @@ -125,8 +125,6 @@ cause the {kib} server to reboot. * <> -* <> - * <> * <> @@ -137,8 +135,6 @@ include::sample-data.asciidoc[] include::debugging.asciidoc[] -include::sass.asciidoc[] - include::building-kibana.asciidoc[] include::development-plugin-resources.asciidoc[] \ No newline at end of file diff --git a/docs/developer/getting-started/sass.asciidoc b/docs/developer/getting-started/sass.asciidoc deleted file mode 100644 index 194e001f642e1..0000000000000 --- a/docs/developer/getting-started/sass.asciidoc +++ /dev/null @@ -1,36 +0,0 @@ -[[kibana-sass]] -=== Styling with SASS - -When writing a new component, create a sibling SASS file of the same -name and import directly into the JS/TS component file. Doing so ensures -the styles are never separated or lost on import and allows for better -modularization (smaller individual plugin asset footprint). - -All SASS (.scss) files will automatically build with the -https://elastic.github.io/eui/#/guidelines/sass[EUI] & {kib} invisibles (SASS variables, mixins, functions) from -the {kib-repo}tree/{branch}/src/legacy/ui/public/styles/_globals_v7light.scss[globals_THEME.scss] file. - -*Example:* - -[source,tsx] ----- -// component.tsx - -import './component.scss'; - -export const Component = () => { - return ( -
- ); -} ----- - -[source,scss] ----- -// component.scss - -.plgComponent { ... } ----- - -Do not use the underscore `_` SASS file naming pattern when importing -directly into a javascript file. \ No newline at end of file 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 5f33d62382818..70ad235fb8971 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions +export interface SavedObjectsFindOptions ``` ## Properties @@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [fields](./kibana-plugin-core-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md new file mode 100644 index 0000000000000..9cc9d64db1f65 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) + +## SavedObjectsFindOptions.namespaces property + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md index b12983836d9e5..474dc6b7d6f28 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md @@ -88,8 +88,9 @@ async (context, request, response) => { | [csp](./kibana-plugin-core-server.httpservicesetup.csp.md) | ICspConfig | The CSP config used for Kibana. | | [getServerInfo](./kibana-plugin-core-server.httpservicesetup.getserverinfo.md) | () => HttpServerInfo | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. | | [registerAuth](./kibana-plugin-core-server.httpservicesetup.registerauth.md) | (handler: AuthenticationHandler) => void | To define custom authentication and/or authorization mechanism for incoming requests. | -| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic to perform for incoming requests. | -| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests. | +| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic after Auth interceptor did make sure a user has access to the requested resource. | +| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. | | [registerOnPreResponse](./kibana-plugin-core-server.httpservicesetup.registeronpreresponse.md) | (handler: OnPreResponseHandler) => void | To define custom logic to perform for the server response. | +| [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) | (handler: OnPreRoutingHandler) => void | To define custom logic to perform for incoming requests before server performs a route lookup. | | [registerRouteHandlerContext](./kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md) | <T extends keyof RequestHandlerContext>(contextName: T, provider: RequestHandlerContextProvider<T>) => RequestHandlerContextContainer | Register a context provider for a route handler. | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md index 01294693e282f..eff53b7b75fa5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPostAuth property -To define custom logic to perform for incoming requests. +To define custom logic after Auth interceptor did make sure a user has access to the requested resource. Signature: @@ -14,5 +14,5 @@ registerOnPostAuth: (handler: OnPostAuthHandler) => void; ## Remarks -Runs the handler after Auth interceptor did make sure a user has access to the requested resource. The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). +The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md index f11453c8cda98..ce4cacb1c8749 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPreAuth property -To define custom logic to perform for incoming requests. +To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. Signature: @@ -14,5 +14,5 @@ registerOnPreAuth: (handler: OnPreAuthHandler) => void; ## Remarks -Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md). +Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md new file mode 100644 index 0000000000000..bdf5f15828669 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) > [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) + +## HttpServiceSetup.registerOnPreRouting property + +To define custom logic to perform for incoming requests before server performs a route lookup. + +Signature: + +```typescript +registerOnPreRouting: (handler: OnPreRoutingHandler) => void; +``` + +## Remarks + +It's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 8d4c0c915437e..a665327454c1a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -122,7 +122,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | -| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | +| [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | | [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) | OS related metrics | | [OpsProcessMetrics](./kibana-plugin-core-server.opsprocessmetrics.md) | Process related metrics | @@ -256,7 +257,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | | [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md) | See [OnPostAuthToolkit](./kibana-plugin-core-server.onpostauthtoolkit.md). | | [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | -| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | +| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | +| [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | | [PluginConfigSchema](./kibana-plugin-core-server.pluginconfigschema.md) | Dedicated type for plugin configuration schema. | | [PluginInitializer](./kibana-plugin-core-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-core-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md index 4097cb32c397a..8031dbc64fa6d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md @@ -17,5 +17,4 @@ export interface OnPreAuthToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreauthtoolkit.next.md) | () => OnPreAuthResult | To pass request to the next handler | -| [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) | (url: string) => OnPreAuthResult | Rewrite requested resources url before is was authenticated and routed to a handler | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md deleted file mode 100644 index 7ecde62f88302..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) - -## OnPreAuthToolkit.rewriteUrl property - -Rewrite requested resources url before is was authenticated and routed to a handler - -Signature: - -```typescript -rewriteUrl: (url: string) => OnPreAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md index e7eab8ee34d6f..10696fb79a2f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md @@ -4,7 +4,7 @@ ## OnPreResponseHandler type -See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 8e33e945b4ef9..306c375ba4a3c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -4,7 +4,7 @@ ## OnPreResponseToolkit interface -A tool set defining an outcome of OnPreAuth interceptor for incoming request. +A tool set defining an outcome of OnPreRouting interceptor for incoming request. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md new file mode 100644 index 0000000000000..46016bcd5476a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) + +## OnPreRoutingHandler type + +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). + +Signature: + +```typescript +export declare type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md new file mode 100644 index 0000000000000..c564896b46a27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) + +## OnPreRoutingToolkit interface + +A tool set defining an outcome of OnPreRouting interceptor for incoming request. + +Signature: + +```typescript +export interface OnPreRoutingToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) | () => OnPreRoutingResult | To pass request to the next handler | +| [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) | (url: string) => OnPreRoutingResult | Rewrite requested resources url before is was authenticated and routed to a handler | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md new file mode 100644 index 0000000000000..7fb0b2ce67ba5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) + +## OnPreRoutingToolkit.next property + +To pass request to the next handler + +Signature: + +```typescript +next: () => OnPreRoutingResult; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md new file mode 100644 index 0000000000000..346a12711c723 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) + +## OnPreRoutingToolkit.rewriteUrl property + +Rewrite requested resources url before is was authenticated and routed to a handler + +Signature: + +```typescript +rewriteUrl: (url: string) => OnPreRoutingResult; +``` 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 6db16d979f1fe..67e931f0cb3b3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions +export interface SavedObjectsFindOptions ``` ## Properties @@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [fields](./kibana-plugin-core-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md new file mode 100644 index 0000000000000..cae707baa58c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) + +## SavedObjectsFindOptions.namespaces property + +Signature: + +```typescript +namespaces?: string[]; +``` 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 8b89c802ec9ce..6c41441302c0b 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, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, } | SavedObjectsFindOptions | | +| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | 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 b9a92561f29fb..5b02707a3c0f4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -23,7 +23,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, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./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/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md index ddbf1a8459d1f..25f046983cbce 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md @@ -7,5 +7,5 @@ Signature: ```typescript -baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateFormat)[] +baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateNanosFormat | typeof DateFormat)[] ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md index 45fc1a608e8ca..0dddc65f4db92 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md @@ -13,7 +13,6 @@ fieldFormats: { BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; - DateNanosFormat: typeof DateNanosFormat; DurationFormat: typeof DurationFormat; IpFormat: typeof IpFormat; NumberFormat: typeof NumberFormat; diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.es.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.es.md deleted file mode 100644 index 9cebff05dc9db..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.es.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IRequestTypesMap](./kibana-plugin-plugins-data-server.irequesttypesmap.md) > [es](./kibana-plugin-plugins-data-server.irequesttypesmap.es.md) - -## IRequestTypesMap.es property - -Signature: - -```typescript -[ES_SEARCH_STRATEGY]: IEsSearchRequest; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.md deleted file mode 100644 index 3f5e4ba0f7799..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.irequesttypesmap.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IRequestTypesMap](./kibana-plugin-plugins-data-server.irequesttypesmap.md) - -## IRequestTypesMap interface - -The map of search strategy IDs to the corresponding request type definitions. - -Signature: - -```typescript -export interface IRequestTypesMap -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [es](./kibana-plugin-plugins-data-server.irequesttypesmap.es.md) | IEsSearchRequest | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.es.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.es.md deleted file mode 100644 index 1154fc141d6c7..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.es.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IResponseTypesMap](./kibana-plugin-plugins-data-server.iresponsetypesmap.md) > [es](./kibana-plugin-plugins-data-server.iresponsetypesmap.es.md) - -## IResponseTypesMap.es property - -Signature: - -```typescript -[ES_SEARCH_STRATEGY]: IEsSearchResponse; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.md deleted file mode 100644 index 629ab4347eda8..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.iresponsetypesmap.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [IResponseTypesMap](./kibana-plugin-plugins-data-server.iresponsetypesmap.md) - -## IResponseTypesMap interface - -The map of search strategy IDs to the corresponding response type definitions. - -Signature: - -```typescript -export interface IResponseTypesMap -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [es](./kibana-plugin-plugins-data-server.iresponsetypesmap.es.md) | IEsSearchResponse | | - diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearch.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearch.md deleted file mode 100644 index 96991579c1716..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearch.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearch](./kibana-plugin-plugins-data-server.isearch.md) - -## ISearch type - -Signature: - -```typescript -export declare type ISearch = (context: RequestHandlerContext, request: IRequestTypesMap[T], options?: ISearchOptions) => Promise; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md deleted file mode 100644 index b5a687d1b19d8..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchcancel.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) - -## ISearchCancel type - -Signature: - -```typescript -export declare type ISearchCancel = (context: RequestHandlerContext, id: string) => Promise; -``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 49412fc42d3b5..002ce864a1aa4 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -15,4 +15,5 @@ export interface ISearchOptions | Property | Type | Description | | --- | --- | --- | | [signal](./kibana-plugin-plugins-data-server.isearchoptions.signal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | +| [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) | string | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.strategy.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.strategy.md new file mode 100644 index 0000000000000..6df72d023e2c0 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.strategy.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [strategy](./kibana-plugin-plugins-data-server.isearchoptions.strategy.md) + +## ISearchOptions.strategy property + +Signature: + +```typescript +strategy?: string; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md index c06b8b00806bf..73c575e7095ed 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchsetup.registersearchstrategy.md @@ -9,5 +9,5 @@ Extension point exposed for other plugins to register their own search strategie Signature: ```typescript -registerSearchStrategy: TRegisterSearchStrategy; +registerSearchStrategy: (name: string, strategy: ISearchStrategy) => void; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md index 0ba4bf578d6cc..970b2811a574b 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md @@ -9,5 +9,5 @@ Get other registered search strategies. For example, if a new strategy needs to Signature: ```typescript -getSearchStrategy: TGetSearchStrategy; +getSearchStrategy: (name: string) => ISearchStrategy; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.md index abe72396f61e1..308ce3cb568bc 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.md @@ -14,5 +14,6 @@ export interface ISearchStart | Property | Type | Description | | --- | --- | --- | -| [getSearchStrategy](./kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md) | TGetSearchStrategy | Get other registered search strategies. For example, if a new strategy needs to use the already-registered ES search strategy, it can use this function to accomplish that. | +| [getSearchStrategy](./kibana-plugin-plugins-data-server.isearchstart.getsearchstrategy.md) | (name: string) => ISearchStrategy | Get other registered search strategies. For example, if a new strategy needs to use the already-registered ES search strategy, it can use this function to accomplish that. | +| [search](./kibana-plugin-plugins-data-server.isearchstart.search.md) | (context: RequestHandlerContext, request: IKibanaSearchRequest, options: ISearchOptions) => Promise<IKibanaSearchResponse> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.search.md new file mode 100644 index 0000000000000..1c2ae91699559 --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstart.search.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchStart](./kibana-plugin-plugins-data-server.isearchstart.md) > [search](./kibana-plugin-plugins-data-server.isearchstart.search.md) + +## ISearchStart.search property + +Signature: + +```typescript +search: (context: RequestHandlerContext, request: IKibanaSearchRequest, options: ISearchOptions) => Promise; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.cancel.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.cancel.md index c1e0c3d9f2330..34903697090ea 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.cancel.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.cancel.md @@ -7,5 +7,5 @@ Signature: ```typescript -cancel?: ISearchCancel; +cancel?: (context: RequestHandlerContext, id: string) => Promise; ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.md index 167c6ab6e5a16..d54e027c4b847 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.md @@ -9,13 +9,13 @@ Search strategy interface contains a search method that takes in a request and r Signature: ```typescript -export interface ISearchStrategy +export interface ISearchStrategy ``` ## Properties | Property | Type | Description | | --- | --- | --- | -| [cancel](./kibana-plugin-plugins-data-server.isearchstrategy.cancel.md) | ISearchCancel<T> | | -| [search](./kibana-plugin-plugins-data-server.isearchstrategy.search.md) | ISearch<T> | | +| [cancel](./kibana-plugin-plugins-data-server.isearchstrategy.cancel.md) | (context: RequestHandlerContext, id: string) => Promise<void> | | +| [search](./kibana-plugin-plugins-data-server.isearchstrategy.search.md) | (context: RequestHandlerContext, request: IEsSearchRequest, options?: ISearchOptions) => Promise<IEsSearchResponse> | | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.search.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.search.md index 34a17ca87807a..1a225d0c9aeab 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.search.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchstrategy.search.md @@ -7,5 +7,5 @@ Signature: ```typescript -search: ISearch; +search: (context: RequestHandlerContext, request: IEsSearchRequest, options?: ISearchOptions) => Promise; ``` 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 c80112fb17dde..9adefda718338 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 @@ -40,8 +40,6 @@ | [IIndexPattern](./kibana-plugin-plugins-data-server.iindexpattern.md) | | | [IndexPatternAttributes](./kibana-plugin-plugins-data-server.indexpatternattributes.md) | Use data plugin interface instead | | [IndexPatternFieldDescriptor](./kibana-plugin-plugins-data-server.indexpatternfielddescriptor.md) | | -| [IRequestTypesMap](./kibana-plugin-plugins-data-server.irequesttypesmap.md) | The map of search strategy IDs to the corresponding request type definitions. | -| [IResponseTypesMap](./kibana-plugin-plugins-data-server.iresponsetypesmap.md) | The map of search strategy IDs to the corresponding response type definitions. | | [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) | | | [ISearchSetup](./kibana-plugin-plugins-data-server.isearchsetup.md) | | | [ISearchStart](./kibana-plugin-plugins-data-server.isearchstart.md) | | @@ -73,8 +71,5 @@ | --- | --- | | [FieldFormatsGetConfigFn](./kibana-plugin-plugins-data-server.fieldformatsgetconfigfn.md) | | | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | -| [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | -| [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | | [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | -| [TStrategyTypes](./kibana-plugin-plugins-data-server.tstrategytypes.md) | Contains all known strategy type identifiers that will be used to map to request and response shapes. Plugins that wish to add their own custom search strategies should extend this type via:const MY\_STRATEGY = 'MY\_STRATEGY';declare module 'src/plugins/search/server' { export interface IRequestTypesMap { \[MY\_STRATEGY\]: IMySearchRequest; }export interface IResponseTypesMap { \[MY\_STRATEGY\]: IMySearchResponse } } | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.tstrategytypes.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.tstrategytypes.md deleted file mode 100644 index 443d8d1b424d0..0000000000000 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.tstrategytypes.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [TStrategyTypes](./kibana-plugin-plugins-data-server.tstrategytypes.md) - -## TStrategyTypes type - -Contains all known strategy type identifiers that will be used to map to request and response shapes. Plugins that wish to add their own custom search strategies should extend this type via: - -const MY\_STRATEGY = 'MY\_STRATEGY'; - -declare module 'src/plugins/search/server' { export interface IRequestTypesMap { \[MY\_STRATEGY\]: IMySearchRequest; } - -export interface IResponseTypesMap { \[MY\_STRATEGY\]: IMySearchResponse } } - -Signature: - -```typescript -export declare type TStrategyTypes = typeof ES_SEARCH_STRATEGY | string; -``` diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 561919738786e..9a94c25bcdf6e 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -247,7 +247,7 @@ retrieved. `timelion:es.timefield`:: The default field containing a timestamp when using the `.es()` query. `timelion:graphite.url`:: [experimental] Used with graphite queries, this is the URL of your graphite host in the form https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite. This URL can be -selected from a whitelist configured in the `kibana.yml` under `timelion.graphiteUrls`. +selected from an allow-list configured in the `kibana.yml` under `timelion.graphiteUrls`. `timelion:max_buckets`:: The maximum number of buckets a single data source can return. This value is used for calculating automatic intervals in visualizations. `timelion:min_interval`:: The smallest interval to calculate when using "auto". diff --git a/docs/settings/ingest-manager-settings.asciidoc b/docs/settings/ingest-manager-settings.asciidoc index f46c769079040..30e11f726c26b 100644 --- a/docs/settings/ingest-manager-settings.asciidoc +++ b/docs/settings/ingest-manager-settings.asciidoc @@ -8,8 +8,7 @@ experimental[] You can configure `xpack.ingestManager` settings in your `kibana.yml`. -By default, {ingest-manager} is not enabled. You need to -enable it. To use {fleet}, you also need to configure {kib} and {es} hosts. +By default, {ingest-manager} is enabled. To use {fleet}, you also need to configure {kib} and {es} hosts. See the {ingest-guide}/index.html[Ingest Management] docs for more information. @@ -19,9 +18,7 @@ See the {ingest-guide}/index.html[Ingest Management] docs for more information. [cols="2*<"] |=== | `xpack.ingestManager.enabled` {ess-icon} - | Set to `true` to enable {ingest-manager}. -| `xpack.ingestManager.epm.enabled` {ess-icon} - | Set to `true` (default) to enable {package-manager}. + | Set to `true` (default) to enable {ingest-manager}. | `xpack.ingestManager.fleet.enabled` {ess-icon} | Set to `true` (default) to enable {fleet}. |=== @@ -32,7 +29,7 @@ See the {ingest-guide}/index.html[Ingest Management] docs for more information. [cols="2*<"] |=== -| `xpack.ingestManager.epm.registryUrl` +| `xpack.ingestManager.registryUrl` | The address to use to reach {package-manager} registry. |=== diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 4fb8a816d1ec9..f6a02b9038c02 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -77,3 +77,122 @@ Email actions have the following configuration properties: To, CC, BCC:: Each is a list of addresses. Addresses can be specified in `user@host-name` format, or in `name ` format. One of To, CC, or BCC must contain an entry. Subject:: The subject line of the email. Message:: The message text of the email. Markdown format is supported. + +[[configuring-email]] +==== Configuring email accounts + +The email action can send email using many popular SMTP email services. + +You configure the email action to send emails using the connector form. +For more information about configuring the email connector to work with different email +systems, refer to: + +* <> +* <> +* <> +* <> + +[float] +[[gmail]] +===== Sending email from Gmail + +Use the following email account settings to send email from the +https://mail.google.com[Gmail] SMTP service: + +[source,text] +-------------------------------------------------- + config: + host: smtp.gmail.com + port: 465 + secure: true + secrets: + user: + password: +-------------------------------------------------- +// CONSOLE + +If you get an authentication error that indicates that you need to continue the +sign-in process from a web browser when the action attempts to send email, you need +to configure Gmail to https://support.google.com/accounts/answer/6010255?hl=en[allow +less secure apps to access your account]. + +If two-step verification is enabled for your account, you must generate and use +a unique App Password to send email from {watcher}. See +https://support.google.com/accounts/answer/185833?hl=en[Sign in using App Passwords] +for more information. + +[float] +[[outlook]] +===== Sending email from Outlook.com + +Use the following email account settings to send email action from the +https://www.outlook.com/[Outlook.com] SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: smtp-mail.outlook.com + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- + +When sending emails, you must provide a from address, either as the default +in your account configuration or as part of the email action in the watch. + +NOTE: You must use a unique App Password if two-step verification is enabled. + See http://windows.microsoft.com/en-us/windows/app-passwords-two-step-verification[App + passwords and two-step verification] for more information. + +[float] +[[amazon-ses]] +===== Sending email from Amazon SES (Simple Email Service) + +Use the following email account settings to send email from the +http://aws.amazon.com/ses[Amazon Simple Email Service] (SES) SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: email-smtp.us-east-1.amazonaws.com <1> + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- +<1> `smtp.host` varies depending on the region + +NOTE: You must use your Amazon SES SMTP credentials to send email through + Amazon SES. For more information, see + http://docs.aws.amazon.com/ses/latest/DeveloperGuide/smtp-credentials.html[Obtaining + Your Amazon SES SMTP Credentials]. You might also need to verify + https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html[your email address] + or https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-domains.html[your whole domain] + at AWS. + +[float] +[[exchange]] +===== Sending email from Microsoft Exchange + +Use the following email account settings to send email action from Microsoft +Exchange: + +[source,text] +-------------------------------------------------- +config: + host: + port: 465 + secure: true + from: <1> +secrets: + user: <2> + password: +-------------------------------------------------- +<1> Some organizations configure Exchange to validate that the `from` field is a + valid local email account. +<2> Many organizations support use of your email address as your username. + Check with your system administrator if you receive + authentication-related failures. diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 115423086bae3..3a57c44494394 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -2,7 +2,7 @@ [[index-action-type]] === Index action -The index action type will index a document into {es}. +The index action type will index a document into {es}. See also the {ref}/indices-create-index.html[create index API]. [float] [[index-connector-configuration]] @@ -53,4 +53,38 @@ Execution time field:: This field will be automatically set to the time the ale Index actions have the following properties: -Document:: The document to index in json format. +Document:: The document to index in JSON format. + +Example of the index document for Index Threshold alert: + +[source,text] +-------------------------------------------------- +{ + "alert_id": "{{alertId}}", + "alert_name": "{{alertName}}", + "alert_instance_id": "{{alertInstanceId}}", + "context_message": "{{context.message}}" +} +-------------------------------------------------- + +Example of create test index using the API. + +[source,text] +-------------------------------------------------- +PUT test +{ + "settings" : { + "number_of_shards" : 1 + }, + "mappings" : { + "_doc" : { + "properties" : { + "alert_id" : { "type" : "text" }, + "alert_name" : { "type" : "text" }, + "alert_instance_id" : { "type" : "text" }, + "context_message": { "type" : "text" } + } + } + } +} +-------------------------------------------------- diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 5bad8a53f898c..99bf73c0f5597 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -38,3 +38,23 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa Slack actions have the following properties: Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. + +[[configuring-slack]] +==== Configuring Slack Accounts + +You configure the accounts Slack action type can use to communicate with Slack in the +connector form. + +You need a https://api.slack.com/incoming-webhooks[Slack webhook URL] to +configure a Slack account. To create a webhook +URL, set up an an **Incoming Webhook Integration** through the Slack console: + +. Log in to http://slack.com[slack.com] as a team administrator. +. Go to https://my.slack.com/services/new/incoming-webhook. +. Select a default channel for the integration. ++ +image::images/slack-add-webhook-integration.png[] +. Click *Add Incoming Webhook Integration*. +. Copy the generated webhook URL so you can paste it into your Slack connector form. ++ +image::images/slack-copy-webhook-url.png[] diff --git a/docs/user/alerting/images/slack-add-webhook-integration.png b/docs/user/alerting/images/slack-add-webhook-integration.png new file mode 100644 index 0000000000000..347822ddd9fac Binary files /dev/null and b/docs/user/alerting/images/slack-add-webhook-integration.png differ diff --git a/docs/user/alerting/images/slack-copy-webhook-url.png b/docs/user/alerting/images/slack-copy-webhook-url.png new file mode 100644 index 0000000000000..0acc9488e22a3 Binary files /dev/null and b/docs/user/alerting/images/slack-copy-webhook-url.png differ diff --git a/package.json b/package.json index 7ab6bfb91a376..55a099b4e5c0c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", "**/cypress/@types/lodash": "^4.14.155", - "**/cypress/lodash": "^4.15.19", "**/typescript": "3.9.5", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", diff --git a/packages/kbn-config-schema/src/types/string_type.ts b/packages/kbn-config-schema/src/types/string_type.ts index cb780bcbbc6bd..c7d386df7c3ba 100644 --- a/packages/kbn-config-schema/src/types/string_type.ts +++ b/packages/kbn-config-schema/src/types/string_type.ts @@ -29,8 +29,8 @@ export type StringOptions = TypeOptions & { export class StringType extends Type { constructor(options: StringOptions = {}) { - // We want to allow empty strings, however calling `allow('')` casues - // Joi to whitelist the value and skip any additional validation. + // We want to allow empty strings, however calling `allow('')` causes + // Joi to allow the value and skip any additional validation. // Instead, we reimplement the string validator manually except in the // hostname case where empty strings aren't allowed anyways. let schema = diff --git a/packages/kbn-dev-utils/src/run/run.ts b/packages/kbn-dev-utils/src/run/run.ts index 894db0d3fdadb..029d428565163 100644 --- a/packages/kbn-dev-utils/src/run/run.ts +++ b/packages/kbn-dev-utils/src/run/run.ts @@ -22,7 +22,7 @@ import { inspect } from 'util'; // @ts-ignore @types are outdated and module is super simple import exitHook from 'exit-hook'; -import { pickLevelFromFlags, ToolingLog } from '../tooling_log'; +import { pickLevelFromFlags, ToolingLog, LogLevel } from '../tooling_log'; import { createFlagError, isFailError } from './fail'; import { Flags, getFlags, getHelp } from './flags'; import { ProcRunner, withProcRunner } from '../proc_runner'; @@ -38,6 +38,9 @@ type RunFn = (args: { export interface Options { usage?: string; description?: string; + log?: { + defaultLevel?: LogLevel; + }; flags?: { allowUnexpected?: boolean; guessTypesForUnexpectedFlags?: boolean; @@ -58,7 +61,9 @@ export async function run(fn: RunFn, options: Options = {}) { } const log = new ToolingLog({ - level: pickLevelFromFlags(flags), + level: pickLevelFromFlags(flags, { + default: options.log?.defaultLevel, + }), writeTo: process.stdout, }); diff --git a/packages/kbn-test/src/failed_tests_reporter/README.md b/packages/kbn-test/src/failed_tests_reporter/README.md index 20592ecd733b6..0473ae7357def 100644 --- a/packages/kbn-test/src/failed_tests_reporter/README.md +++ b/packages/kbn-test/src/failed_tests_reporter/README.md @@ -7,15 +7,15 @@ A little CLI that runs in CI to find the failed tests in the JUnit reports, then To fetch some JUnit reports from a recent build on CI, visit its `Google Cloud Storage Upload Report` and execute the following in the JS Console: ```js -copy(`wget "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) +copy(`wget -x -nH --cut-dirs 5 -P "target/downloaded_junit" "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) ``` -This copies a script to download the reports, which you should execute in the `test/junit` directory. +This copies a script to download the reports, which you should execute in the root of the Kibana repository. Next, run the CLI in `--no-github-update` mode so that it doesn't actually communicate with Github and `--no-report-update` to prevent the script from mutating the reports on disk and instead log the updated report. ```sh -node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update +node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update target/downloaded_junit/**/*.xml ``` Unless you specify the `GITHUB_TOKEN` environment variable requests to read existing issues will use anonymous access which is limited to 60 requests per hour. \ No newline at end of file diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 3bcea44cf73b6..8a951ac969199 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -17,6 +17,8 @@ * under the License. */ +import Path from 'path'; + import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils'; import globby from 'globby'; @@ -28,6 +30,8 @@ import { readTestReport } from './test_report'; import { addMessagesToReport } from './add_messages_to_report'; import { getReportMessageIter } from './report_metadata'; +const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; + export function runFailedTestsReporterCli() { run( async ({ log, flags }) => { @@ -67,11 +71,15 @@ export function runFailedTestsReporterCli() { throw createFlagError('Missing --build-url or process.env.BUILD_URL'); } - const reportPaths = await globby(['target/junit/**/*.xml'], { - cwd: REPO_ROOT, + const patterns = flags._.length ? flags._ : DEFAULT_PATTERNS; + const reportPaths = await globby(patterns, { absolute: true, }); + if (!reportPaths.length) { + throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); + } + const newlyCreatedIssues: Array<{ failure: TestFailure; newIssue: GithubIssueMini; diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 2a8e0c3d7de9a..d744be9467311 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -113,6 +113,9 @@ export function runFtrCli() { } }, { + log: { + defaultLevel: 'debug', + }, flags: { string: [ 'config', @@ -126,7 +129,6 @@ export function runFtrCli() { boolean: ['bail', 'invert', 'test-stats', 'updateBaselines', 'throttle', 'headless'], default: { config: 'test/functional/config.js', - debug: true, }, help: ` --config=path path to a config file diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 90b1bb4fd5320..f7acff14915a7 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -942,7 +942,7 @@ export class MyPlugin implements Plugin { return mountApp(await core.getStartServices(), params); }, }); - plugins.management.sections.getSection('another').registerApp({ + plugins.management.sections.section.kibana.registerApp({ id: 'app', title: 'My app', order: 1, @@ -1309,7 +1309,7 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `hacks` | n/a | Just run the code in your plugin's `start` method. | | `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | -| `injectDefaultVars` | n/a | Plugins will only be able to "whitelist" config values for the frontend. See [#41990](https://github.com/elastic/kibana/issues/41990) | +| `injectDefaultVars` | n/a | Plugins will only be able to allow config values for the frontend. See [#41990](https://github.com/elastic/kibana/issues/41990) | | `inspectorViews` | | Should be an API on the data (?) plugin. | | `interpreter` | | Should be an API on the interpreter plugin. | | `links` | n/a | Not necessary, just register your app via `core.application.register` | @@ -1389,7 +1389,7 @@ class MyPlugin { } ``` -If your plugin also have a client-side part, you can also expose configuration properties to it using a whitelisting mechanism with the configuration `exposeToBrowser` property. +If your plugin also have a client-side part, you can also expose configuration properties to it using the configuration `exposeToBrowser` allow-list property. ```typescript // my_plugin/server/index.ts diff --git a/src/core/public/application/scoped_history.mock.ts b/src/core/public/application/scoped_history.mock.ts index 41c72306a99f9..3b954313700f2 100644 --- a/src/core/public/application/scoped_history.mock.ts +++ b/src/core/public/application/scoped_history.mock.ts @@ -20,16 +20,16 @@ import { Location } from 'history'; import { ScopedHistory } from './scoped_history'; -type ScopedHistoryMock = jest.Mocked>; +export type ScopedHistoryMock = jest.Mocked; + const createMock = ({ pathname = '/', search = '', hash = '', key, state, - ...overrides -}: Partial = {}) => { - const mock: ScopedHistoryMock = { +}: Partial = {}) => { + const mock: jest.Mocked> = { block: jest.fn(), createHref: jest.fn(), createSubHistory: jest.fn(), @@ -39,7 +39,6 @@ const createMock = ({ listen: jest.fn(), push: jest.fn(), replace: jest.fn(), - ...overrides, action: 'PUSH', length: 1, location: { @@ -51,7 +50,9 @@ const createMock = ({ }, }; - return mock; + // jest.Mocked still expects private methods and properties to be present, even + // if not part of the public contract. + return mock as ScopedHistoryMock; }; export const scopedHistoryMock = { diff --git a/src/core/public/notifications/toasts/error_toast.tsx b/src/core/public/notifications/toasts/error_toast.tsx index 6b53719839b0f..df8214ce771af 100644 --- a/src/core/public/notifications/toasts/error_toast.tsx +++ b/src/core/public/notifications/toasts/error_toast.tsx @@ -31,8 +31,7 @@ import { } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { OverlayStart } from '../../overlays'; +import { OverlayStart } from 'kibana/public'; import { I18nStart } from '../../i18n'; interface ErrorToastProps { @@ -43,6 +42,17 @@ interface ErrorToastProps { i18nContext: () => I18nStart['Context']; } +interface RequestError extends Error { + body?: { attributes?: { error: { caused_by: { type: string; reason: string } } } }; +} + +const isRequestError = (e: Error | RequestError): e is RequestError => { + if ('body' in e) { + return e.body?.attributes?.error?.caused_by !== undefined; + } + return false; +}; + /** * This should instead be replaced by the overlay service once it's available. * This does not use React portals so that if the parent toast times out, this modal @@ -56,6 +66,17 @@ function showErrorDialog({ i18nContext, }: Pick) { const I18nContext = i18nContext(); + let text = ''; + + if (isRequestError(error)) { + text += `${error?.body?.attributes?.error?.caused_by.type}\n`; + text += `${error?.body?.attributes?.error?.caused_by.reason}\n\n`; + } + + if (error.stack) { + text += error.stack; + } + const modal = openModal( mount( @@ -65,11 +86,11 @@ function showErrorDialog({ - {error.stack && ( + {text && ( - {error.stack} + {text} )} diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 303d005197588..c811209dfa80f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1282,7 +1282,7 @@ export interface SavedObjectsCreateOptions { } // @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; @@ -1294,6 +1294,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { id: string; }; // (undocumented) + namespaces?: string[]; + // (undocumented) page?: number; // (undocumented) perPage?: number; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index c4daaf5d7f307..209f489e29139 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -294,6 +294,7 @@ export class SavedObjectsClient { sortField: 'sort_field', type: 'type', filter: 'filter', + namespaces: 'namespaces', preference: 'preference', }; diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index 75644435a7f2a..34e83922d4d86 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -28,7 +28,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { node: 'http://localhost', }) as any; - const blackListedProps = [ + const omittedProps = [ '_events', '_eventsCount', '_maxListeners', @@ -39,9 +39,9 @@ const createInternalClientMock = (): DeeplyMockedKeys => { 'helpers', ]; - const mockify = (obj: Record, blacklist: string[] = []) => { + const mockify = (obj: Record, omitted: string[] = []) => { Object.keys(obj) - .filter((key) => !blacklist.includes(key)) + .filter((key) => !omitted.includes(key)) .forEach((key) => { const propType = typeof obj[key]; if (propType === 'function') { @@ -52,7 +52,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { }); }; - mockify(client, blackListedProps); + mockify(client, omittedProps); client.transport = { request: jest.fn(), diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index bbef0a105c089..7d37af833d4c1 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -33,7 +33,7 @@ import { } from './router'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; interface RequestFixtureOptions

{ auth?: { isAuthenticated: boolean }; @@ -161,7 +161,7 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked; +type ToolkitMock = jest.Mocked; const createToolkitMock = (): ToolkitMock => { return { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 72cb0b2821c5c..601eba835a54e 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1089,6 +1089,16 @@ describe('setup contract', () => { }); }); + describe('#registerOnPreRouting', () => { + test('does not throw if called after stop', async () => { + const { registerOnPreRouting } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPreRouting((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); + describe('#registerOnPreAuth', () => { test('does not throw if called after stop', async () => { const { registerOnPreAuth } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 1abf5c0c133bb..9c16162d69334 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -24,8 +24,9 @@ import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getListenerOptions, getServerOptions } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; +import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; -import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; +import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router'; import { @@ -49,8 +50,9 @@ export interface HttpServerSetup { basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; - registerAuth: HttpServiceSetup['registerAuth']; + registerOnPreRouting: HttpServiceSetup['registerOnPreRouting']; registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; + registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; registerOnPreResponse: HttpServiceSetup['registerOnPreResponse']; getAuthHeaders: GetAuthHeaders; @@ -64,7 +66,11 @@ export interface HttpServerSetup { /** @internal */ export type LifecycleRegistrar = Pick< HttpServerSetup, - 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse' + | 'registerOnPreRouting' + | 'registerOnPreAuth' + | 'registerAuth' + | 'registerOnPostAuth' + | 'registerOnPreResponse' >; export class HttpServer { @@ -113,12 +119,13 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), registerStaticDir: this.registerStaticDir.bind(this), + registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), + registerAuth: this.registerAuth.bind(this), registerOnPostAuth: this.registerOnPostAuth.bind(this), registerOnPreResponse: this.registerOnPreResponse.bind(this), createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => this.createCookieSessionStorageFactory(cookieOptions, config.basePath), - registerAuth: this.registerAuth.bind(this), basePath: basePathService, csp: config.csp, auth: { @@ -222,7 +229,7 @@ export class HttpServer { return; } - this.registerOnPreAuth((request, response, toolkit) => { + this.registerOnPreRouting((request, response, toolkit) => { const oldUrl = request.url.href!; const newURL = basePathService.remove(oldUrl); const shouldRedirect = newURL !== oldUrl; @@ -263,6 +270,17 @@ export class HttpServer { } } + private registerOnPreAuth(fn: OnPreAuthHandler) { + if (this.server === undefined) { + throw new Error('Server is not created yet'); + } + if (this.stopped) { + this.log.warn(`registerOnPreAuth called after stop`); + } + + this.server.ext('onPreAuth', adoptToHapiOnPreAuth(fn, this.log)); + } + private registerOnPostAuth(fn: OnPostAuthHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); @@ -274,15 +292,15 @@ export class HttpServer { this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log)); } - private registerOnPreAuth(fn: OnPreAuthHandler) { + private registerOnPreRouting(fn: OnPreRoutingHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); } if (this.stopped) { - this.log.warn(`registerOnPreAuth called after stop`); + this.log.warn(`registerOnPreRouting called after stop`); } - this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log)); + this.server.ext('onRequest', adoptToHapiOnRequest(fn, this.log)); } private registerOnPreResponse(fn: OnPreResponseHandler) { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 5e7ee7b658eca..51f11b15f2e09 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -29,7 +29,7 @@ import { } from './types'; import { HttpService } from './http_service'; import { AuthStatus } from './auth_state_storage'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; import { AuthToolkit } from './lifecycle/auth'; import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; @@ -87,6 +87,7 @@ const createInternalSetupContractMock = () => { config: jest.fn().mockReturnValue(configMock.create()), } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), + registerOnPreRouting: jest.fn(), registerOnPreAuth: jest.fn(), registerAuth: jest.fn(), registerOnPostAuth: jest.fn(), @@ -117,7 +118,8 @@ const createSetupContractMock = () => { const mock: HttpServiceSetupMock = { createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory, - registerOnPreAuth: internalMock.registerOnPreAuth, + registerOnPreRouting: internalMock.registerOnPreRouting, + registerOnPreAuth: jest.fn(), registerAuth: internalMock.registerAuth, registerOnPostAuth: internalMock.registerOnPostAuth, registerOnPreResponse: internalMock.registerOnPreResponse, @@ -173,7 +175,7 @@ const createHttpServiceMock = () => { return mocked; }; -const createOnPreAuthToolkitMock = (): jest.Mocked => ({ +const createOnPreAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), rewriteUrl: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 65d633260a791..e91f7d9375842 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -64,7 +64,7 @@ export { SafeRouteMethod, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; -export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; export { AuthenticationHandler, AuthHeaders, @@ -78,6 +78,7 @@ export { AuthResultType, } from './lifecycle/auth'; export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth'; +export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 0ee53a04d9f87..3c5f22500e5e0 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -337,7 +337,7 @@ describe('http service', () => { it('basePath information for an incoming request is available in legacy server', async () => { const reqBasePath = '/requests-specific-base-path'; const { http } = await root.setup(); - http.registerOnPreAuth((req, res, toolkit) => { + http.registerOnPreRouting((req, res, toolkit) => { http.basePath.set(req, reqBasePath); return toolkit.next(); }); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index cbab14115ba6b..b9548bf7a8d70 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -57,20 +57,22 @@ interface StorageData { expires: number; } -describe('OnPreAuth', () => { +describe('OnPreRouting', () => { it('supports registering a request interceptor', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); const callingOrder: string[] = []; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('first'); return t.next(); }); - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('second'); return t.next(); }); @@ -82,7 +84,9 @@ describe('OnPreAuth', () => { }); it('supports request forwarding to specified url', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/initial', validate: false }, (context, req, res) => @@ -93,13 +97,13 @@ describe('OnPreAuth', () => { ); let urlBeforeForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { urlBeforeForwarding = ensureRawRequest(req).raw.req.url; return t.rewriteUrl('/redirectUrl'); }); let urlAfterForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { // used by legacy platform urlAfterForwarding = ensureRawRequest(req).raw.req.url; return t.next(); @@ -113,6 +117,152 @@ describe('OnPreAuth', () => { expect(urlAfterForwarding).toBe('/redirectUrl'); }); + it('supports redirection from the interceptor', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + const redirectUrl = '/redirectUrl'; + router.get({ path: '/initial', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.redirected({ + headers: { + location: redirectUrl, + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/initial').expect(302); + + expect(result.header.location).toBe(redirectUrl); + }); + + it('supports rejecting request and adjusting response headers', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.unauthorized({ + headers: { + 'www-authenticate': 'challenge', + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(401); + + expect(result.header['www-authenticate']).toBe('challenge'); + }); + + it('does not expose error details if interceptor throws', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => { + throw new Error('reason'); + }); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: reason], + ], + ] + `); + }); + + it('returns internal error if interceptor returns unexpected result', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => ({} as any)); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: [object Object].], + ], + ] + `); + }); + + it(`doesn't share request object between interceptors`, async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + (req as any).customField = { value: 42 }; + return t.next(); + }); + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + if (typeof (req as any).customField !== 'undefined') { + throw new Error('Request object was mutated'); + } + return t.next(); + }); + router.get({ path: '/', validate: false }, (context, req, res) => + // don't complain customField is not defined on Request type + res.ok({ body: { customField: String((req as any).customField) } }) + ); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); + }); +}); + +describe('OnPreAuth', () => { + it('supports registering a request interceptor', async () => { + const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + const callingOrder: string[] = []; + registerOnPreAuth((req, res, t) => { + callingOrder.push('first'); + return t.next(); + }); + + registerOnPreAuth((req, res, t) => { + callingOrder.push('second'); + return t.next(); + }); + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, 'ok'); + + expect(callingOrder).toEqual(['first', 'second']); + }); + it('supports redirection from the interceptor', async () => { const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -203,20 +353,20 @@ describe('OnPreAuth', () => { const router = createRouter('/'); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - (req as any).customField = { value: 42 }; + // @ts-expect-error customField property is not defined on request object + req.customField = { value: 42 }; return t.next(); }); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - if (typeof (req as any).customField !== 'undefined') { + // @ts-expect-error customField property is not defined on request object + if (typeof req.customField !== 'undefined') { throw new Error('Request object was mutated'); } return t.next(); }); router.get({ path: '/', validate: false }, (context, req, res) => - // don't complain customField is not defined on Request type - res.ok({ body: { customField: String((req as any).customField) } }) + // @ts-expect-error customField property is not defined on request object + res.ok({ body: { customField: String(req.customField) } }) ); await server.start(); @@ -664,7 +814,7 @@ describe('Auth', () => { it.skip('is the only place with access to the authorization header', async () => { const { - registerOnPreAuth, + registerOnPreRouting, registerAuth, registerOnPostAuth, server: innerServer, @@ -672,9 +822,9 @@ describe('Auth', () => { } = await server.setup(setupDeps); const router = createRouter('/'); - let fromRegisterOnPreAuth; - await registerOnPreAuth((req, res, toolkit) => { - fromRegisterOnPreAuth = req.headers.authorization; + let fromregisterOnPreRouting; + await registerOnPreRouting((req, res, toolkit) => { + fromregisterOnPreRouting = req.headers.authorization; return toolkit.next(); }); @@ -701,7 +851,7 @@ describe('Auth', () => { const token = 'Basic: user:password'; await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200); - expect(fromRegisterOnPreAuth).toEqual({}); + expect(fromregisterOnPreRouting).toEqual({}); expect(fromRegisterAuth).toEqual({ authorization: token }); expect(fromRegisterOnPostAuth).toEqual({}); expect(fromRouteHandler).toEqual({}); @@ -1137,3 +1287,135 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); }); + +describe('run interceptors in the right order', () => { + it('with Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return t.authenticated({}); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual([ + 'onPreRouting', + 'onPreAuth', + 'auth', + 'onPostAuth', + 'onPreResponse', + ]); + }); + + it('with no Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']); + }); + + it('when a user failed auth', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return res.forbidden(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(403); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']); + }); +}); diff --git a/src/core/server/http/lifecycle/on_pre_auth.ts b/src/core/server/http/lifecycle/on_pre_auth.ts index dc2ae6922fb94..f76fe87fd14a3 100644 --- a/src/core/server/http/lifecycle/on_pre_auth.ts +++ b/src/core/server/http/lifecycle/on_pre_auth.ts @@ -29,33 +29,21 @@ import { enum ResultType { next = 'next', - rewriteUrl = 'rewriteUrl', } interface Next { type: ResultType.next; } -interface RewriteUrl { - type: ResultType.rewriteUrl; - url: string; -} - -type OnPreAuthResult = Next | RewriteUrl; +type OnPreAuthResult = Next; const preAuthResult = { next(): OnPreAuthResult { return { type: ResultType.next }; }, - rewriteUrl(url: string): OnPreAuthResult { - return { type: ResultType.rewriteUrl, url }; - }, isNext(result: OnPreAuthResult): result is Next { return result && result.type === ResultType.next; }, - isRewriteUrl(result: OnPreAuthResult): result is RewriteUrl { - return result && result.type === ResultType.rewriteUrl; - }, }; /** @@ -65,13 +53,10 @@ const preAuthResult = { export interface OnPreAuthToolkit { /** To pass request to the next handler */ next: () => OnPreAuthResult; - /** Rewrite requested resources url before is was authenticated and routed to a handler */ - rewriteUrl: (url: string) => OnPreAuthResult; } const toolkit: OnPreAuthToolkit = { next: preAuthResult.next, - rewriteUrl: preAuthResult.rewriteUrl, }; /** @@ -88,9 +73,9 @@ export type OnPreAuthHandler = ( * @public * Adopt custom request interceptor to Hapi lifecycle system. * @param fn - an extension point allowing to perform custom logic for - * incoming HTTP requests. + * incoming HTTP requests before a user has been authenticated. */ -export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { +export function adoptToHapiOnPreAuth(fn: OnPreAuthHandler, log: Logger) { return async function interceptPreAuthRequest( request: Request, responseToolkit: HapiResponseToolkit @@ -107,13 +92,6 @@ export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { return responseToolkit.continue; } - if (preAuthResult.isRewriteUrl(result)) { - const { url } = result; - request.setUrl(url); - // We should update raw request as well since it can be proxied to the old platform - request.raw.req.url = url; - return responseToolkit.continue; - } throw new Error( `Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: ${result}.` ); diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 9c8c6fba690d1..4d1b53313a51f 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -64,7 +64,7 @@ const preResponseResult = { }; /** - * A tool set defining an outcome of OnPreAuth interceptor for incoming request. + * A tool set defining an outcome of OnPreResponse interceptor for incoming request. * @public */ export interface OnPreResponseToolkit { @@ -77,7 +77,7 @@ const toolkit: OnPreResponseToolkit = { }; /** - * See {@link OnPreAuthToolkit}. + * See {@link OnPreRoutingToolkit}. * @public */ export type OnPreResponseHandler = ( diff --git a/src/core/server/http/lifecycle/on_pre_routing.ts b/src/core/server/http/lifecycle/on_pre_routing.ts new file mode 100644 index 0000000000000..e62eb54f2398f --- /dev/null +++ b/src/core/server/http/lifecycle/on_pre_routing.ts @@ -0,0 +1,125 @@ +/* + * 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 { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Logger } from '../../logging'; +import { + HapiResponseAdapter, + KibanaRequest, + KibanaResponse, + lifecycleResponseFactory, + LifecycleResponseFactory, +} from '../router'; + +enum ResultType { + next = 'next', + rewriteUrl = 'rewriteUrl', +} + +interface Next { + type: ResultType.next; +} + +interface RewriteUrl { + type: ResultType.rewriteUrl; + url: string; +} + +type OnPreRoutingResult = Next | RewriteUrl; + +const preRoutingResult = { + next(): OnPreRoutingResult { + return { type: ResultType.next }; + }, + rewriteUrl(url: string): OnPreRoutingResult { + return { type: ResultType.rewriteUrl, url }; + }, + isNext(result: OnPreRoutingResult): result is Next { + return result && result.type === ResultType.next; + }, + isRewriteUrl(result: OnPreRoutingResult): result is RewriteUrl { + return result && result.type === ResultType.rewriteUrl; + }, +}; + +/** + * @public + * A tool set defining an outcome of OnPreRouting interceptor for incoming request. + */ +export interface OnPreRoutingToolkit { + /** To pass request to the next handler */ + next: () => OnPreRoutingResult; + /** Rewrite requested resources url before is was authenticated and routed to a handler */ + rewriteUrl: (url: string) => OnPreRoutingResult; +} + +const toolkit: OnPreRoutingToolkit = { + next: preRoutingResult.next, + rewriteUrl: preRoutingResult.rewriteUrl, +}; + +/** + * See {@link OnPreRoutingToolkit}. + * @public + */ +export type OnPreRoutingHandler = ( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreRoutingToolkit +) => OnPreRoutingResult | KibanaResponse | Promise; + +/** + * @public + * Adopt custom request interceptor to Hapi lifecycle system. + * @param fn - an extension point allowing to perform custom logic for + * incoming HTTP requests. + */ +export function adoptToHapiOnRequest(fn: OnPreRoutingHandler, log: Logger) { + return async function interceptPreRoutingRequest( + request: Request, + responseToolkit: HapiResponseToolkit + ): Promise { + const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); + + try { + const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit); + if (result instanceof KibanaResponse) { + return hapiResponseAdapter.handle(result); + } + + if (preRoutingResult.isNext(result)) { + return responseToolkit.continue; + } + + if (preRoutingResult.isRewriteUrl(result)) { + const { url } = result; + request.setUrl(url); + // We should update raw request as well since it can be proxied to the old platform + request.raw.req.url = url; + return responseToolkit.continue; + } + throw new Error( + `Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: ${result}.` + ); + } catch (error) { + log.error(error); + return hapiResponseAdapter.toInternalError(); + } + }; +} diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 241af1a3020cb..3df098a1df00d 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -25,6 +25,7 @@ import { HttpServerSetup } from './http_server'; import { SessionStorageCookieOptions } from './cookie_session_storage'; import { SessionStorageFactory } from './session_storage'; import { AuthenticationHandler } from './lifecycle/auth'; +import { OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; @@ -145,15 +146,26 @@ export interface HttpServiceSetup { ) => Promise>; /** - * To define custom logic to perform for incoming requests. + * To define custom logic to perform for incoming requests before server performs a route lookup. * * @remarks - * Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the - * only place when you can forward a request to another URL right on the server. - * Can register any number of registerOnPostAuth, which are called in sequence + * It's the only place when you can forward a request to another URL right on the server. + * Can register any number of registerOnPreRouting, which are called in sequence + * (from the first registered to the last). See {@link OnPreRoutingHandler}. + * + * @param handler {@link OnPreRoutingHandler} - function to call. + */ + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; + + /** + * To define custom logic to perform for incoming requests before + * the Auth interceptor performs a check that user has access to requested resources. + * + * @remarks + * Can register any number of registerOnPreAuth, which are called in sequence * (from the first registered to the last). See {@link OnPreAuthHandler}. * - * @param handler {@link OnPreAuthHandler} - function to call. + * @param handler {@link OnPreRoutingHandler} - function to call. */ registerOnPreAuth: (handler: OnPreAuthHandler) => void; @@ -170,13 +182,11 @@ export interface HttpServiceSetup { registerAuth: (handler: AuthenticationHandler) => void; /** - * To define custom logic to perform for incoming requests. + * To define custom logic after Auth interceptor did make sure a user has access to the requested resource. * * @remarks - * Runs the handler after Auth interceptor - * did make sure a user has access to the requested resource. * The auth state is available at stage via http.auth.get(..) - * Can register any number of registerOnPreAuth, which are called in sequence + * Can register any number of registerOnPostAuth, which are called in sequence * (from the first registered to the last). See {@link OnPostAuthHandler}. * * @param handler {@link OnPostAuthHandler} - function to call. diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dcaa5f2367214..706ec88c6ebfd 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -148,6 +148,8 @@ export { LegacyRequest, OnPreAuthHandler, OnPreAuthToolkit, + OnPreRoutingHandler, + OnPreRoutingToolkit, OnPostAuthHandler, OnPostAuthToolkit, OnPreResponseHandler, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 6b34a4eb58319..fada40e773f12 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -301,6 +301,7 @@ export class LegacyService implements CoreService { ), createRouter: () => router, resources: setupDeps.core.httpResources.createRegistrar(router), + registerOnPreRouting: setupDeps.core.http.registerOnPreRouting, registerOnPreAuth: setupDeps.core.http.registerOnPreAuth, registerAuth: setupDeps.core.http.registerAuth, registerOnPostAuth: setupDeps.core.http.registerOnPostAuth, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index a6dd13a12b527..c17b8df8bb52c 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -157,6 +157,7 @@ export function createPluginSetupContext( ), createRouter: () => router, resources: deps.httpResources.createRegistrar(router), + registerOnPreRouting: deps.http.registerOnPreRouting, registerOnPreAuth: deps.http.registerOnPreAuth, registerAuth: deps.http.registerAuth, registerOnPostAuth: deps.http.registerOnPostAuth, diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 5da2235828b5c..27c0a5205ae38 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -107,7 +107,97 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": undefined, + "namespaces": undefined, + "perPage": 500, + "search": undefined, + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + test('omits the `namespaces` property from the export', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + namespaces: ['foo', 'bar'], + score: 0, + references: [ + { + name: 'name', + type: 'index-pattern', + id: '1', + }, + ], + }, + { + id: '1', + type: 'index-pattern', + attributes: {}, + namespaces: ['foo', 'bar'], + score: 0, + references: [], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await exportSavedObjectsToStream({ + savedObjectsClient, + exportSizeLimit: 500, + types: ['index-pattern', 'search'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "exportedCount": 2, + "missingRefCount": 0, + "missingReferences": Array [], + }, + ] + `); + expect(savedObjectsClient.find).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Object { + "namespaces": undefined, "perPage": 500, "search": undefined, "type": Array [ @@ -257,7 +347,7 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": undefined, + "namespaces": undefined, "perPage": 500, "search": "foo", "type": Array [ @@ -346,7 +436,9 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": "foo", + "namespaces": Array [ + "foo", + ], "perPage": 500, "search": undefined, "type": Array [ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 6e985c25aeaef..6cfe6f1be5669 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -109,7 +109,7 @@ async function fetchObjectsToExport({ type: types, search, perPage: exportSizeLimit, - namespace, + namespaces: namespace ? [namespace] : undefined, }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -162,10 +162,15 @@ export async function exportSavedObjectsToStream({ exportedObjects = sortObjects(rootObjects); } + // redact attributes that should not be exported + const redactedObjects = exportedObjects.map>( + ({ namespaces, ...object }) => object + ); + const exportDetails: SavedObjectsExportResultDetails = { exportedCount: exportedObjects.length, missingRefCount: missingReferences.length, missingReferences, }; - return createListStream([...exportedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); + return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } diff --git a/src/core/server/saved_objects/mappings/lib/get_root_properties_objects.ts b/src/core/server/saved_objects/mappings/lib/get_root_properties_objects.ts index 81ba1d8235561..a998dbee0259e 100644 --- a/src/core/server/saved_objects/mappings/lib/get_root_properties_objects.ts +++ b/src/core/server/saved_objects/mappings/lib/get_root_properties_objects.ts @@ -39,14 +39,14 @@ import { getRootProperties } from './get_root_properties'; * @return {EsPropertyMappings} */ -const blacklist = ['migrationVersion', 'references']; +const omittedRootProps = ['migrationVersion', 'references']; export function getRootPropertiesObjects(mappings: IndexMapping) { const rootProperties = getRootProperties(mappings); return Object.entries(rootProperties).reduce((acc, [key, value]) => { // we consider the existence of the properties or type of object to designate that this is an object datatype if ( - !blacklist.includes(key) && + !omittedRootProps.includes(key) && ((value as SavedObjectsComplexFieldMapping).properties || value.type === 'object') ) { acc[key] = value; diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 5c1c2c9a9ab87..6313a95b1fefa 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -45,11 +45,18 @@ export const registerFindRoute = (router: IRouter) => { ), fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filter: schema.maybe(schema.string()), + namespaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const query = req.query; + + const namespaces = + typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + const result = await context.core.savedObjects.client.find({ perPage: query.per_page, page: query.page, @@ -62,6 +69,7 @@ export const registerFindRoute = (router: IRouter) => { hasReference: query.has_reference, fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, + namespaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index 33e12dd4e517d..d5a7710f04b39 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -81,6 +81,7 @@ describe('GET /api/saved_objects/_find', () => { attributes: {}, score: 1, references: [], + namespaces: ['default'], }, { type: 'index-pattern', @@ -91,6 +92,7 @@ describe('GET /api/saved_objects/_find', () => { attributes: {}, score: 1, references: [], + namespaces: ['default'], }, ], }; @@ -241,4 +243,38 @@ describe('GET /api/saved_objects/_find', () => { defaultSearchOperator: 'OR', }); }); + + it('accepts the query parameter namespaces as a string', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&namespaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + namespaces: ['foo'], + defaultSearchOperator: 'OR', + }); + }); + + it('accepts the query parameter namespaces as an array', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&namespaces=default&namespaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + namespaces: ['default', 'foo'], + defaultSearchOperator: 'OR', + }); + }); }); 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 ea749235cbb41..d563edbe66c9b 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -494,6 +494,7 @@ describe('SavedObjectsRepository', () => { ...obj, migrationVersion: { [obj.type]: '1.1.1' }, version: mockVersion, + namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], ...mockTimestampFields, }); @@ -826,9 +827,19 @@ describe('SavedObjectsRepository', () => { // Assert that both raw docs from the ES response are deserialized expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(1, { ...response.items[0].create, + _source: { + ...response.items[0].create._source, + namespaces: response.items[0].create._source.namespaces, + }, _id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/), }); - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, response.items[1].create); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, { + ...response.items[1].create, + _source: { + ...response.items[1].create._source, + namespaces: response.items[1].create._source.namespaces, + }, + }); // Assert that ID's are deserialized to remove the type and namespace expect(result.saved_objects[0].id).toEqual( @@ -985,7 +996,7 @@ describe('SavedObjectsRepository', () => { const expectSuccessResult = ({ type, id }, doc) => ({ type, id, - ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + namespaces: doc._source.namespaces ?? ['default'], ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -1027,12 +1038,12 @@ describe('SavedObjectsRepository', () => { }); }); - it(`includes namespaces property for multi-namespace documents`, async () => { + it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ - expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: ['default'] }), expect.objectContaining({ namespaces: expect.any(Array) }), ], }); @@ -1350,12 +1361,13 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ type, id, attributes, references }) => ({ + const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ type, id, attributes, references, version: mockVersion, + namespaces: namespaces ?? ['default'], ...mockTimestampFields, }); @@ -1389,12 +1401,12 @@ describe('SavedObjectsRepository', () => { }); }); - it(`includes namespaces property for multi-namespace documents`, async () => { + it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ - expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), expect.objectContaining({ namespaces: expect.any(Array) }), ], }); @@ -1651,6 +1663,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, attributes, references, + namespaces: [namespace ?? 'default'], migrationVersion: { [type]: '1.1.1' }, }); }); @@ -1907,7 +1920,7 @@ describe('SavedObjectsRepository', () => { await deleteByNamespaceSuccess(namespace); const allTypes = registry.getAllTypes().map((type) => type.name); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { - namespace, + namespaces: [namespace], type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), }); }); @@ -2134,6 +2147,7 @@ describe('SavedObjectsRepository', () => { score: doc._score, attributes: doc._source[doc._source.type], references: [], + namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : ['default'], }); }); }); @@ -2143,7 +2157,7 @@ describe('SavedObjectsRepository', () => { callAdminCluster.mockReturnValue(namespacedSearchResults); const count = namespacedSearchResults.hits.hits.length; - const response = await savedObjectsRepository.find({ type, namespace }); + const response = await savedObjectsRepository.find({ type, namespaces: [namespace] }); expect(response.total).toBe(count); expect(response.saved_objects).toHaveLength(count); @@ -2157,6 +2171,7 @@ describe('SavedObjectsRepository', () => { score: doc._score, attributes: doc._source[doc._source.type], references: [], + namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace], }); }); }); @@ -2176,7 +2191,7 @@ describe('SavedObjectsRepository', () => { describe('search dsl', () => { it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { const relevantOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: [type], @@ -2374,6 +2389,7 @@ describe('SavedObjectsRepository', () => { title: 'Testing', }, references: [], + namespaces: ['default'], }); }); @@ -2384,10 +2400,10 @@ describe('SavedObjectsRepository', () => { }); }); - it(`doesn't include namespaces if type is not multi-namespace`, async () => { + it(`include namespaces if type is not multi-namespace`, async () => { const result = await getSuccess(type, id); - expect(result).not.toMatchObject({ - namespaces: expect.anything(), + expect(result).toMatchObject({ + namespaces: ['default'], }); }); }); @@ -2908,10 +2924,10 @@ describe('SavedObjectsRepository', () => { _id: `${type}:${id}`, ...mockVersionProps, result: 'updated', - ...(registry.isMultiNamespace(type) && { - // don't need the rest of the source for test purposes, just the namespaces attribute - get: { _source: { namespaces: [options?.namespace ?? 'default'] } }, - }), + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace }, + }, }); // this._writeToCluster('update', ...) const result = await savedObjectsRepository.update(type, id, attributes, options); expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); @@ -3011,15 +3027,15 @@ describe('SavedObjectsRepository', () => { it(`includes _sourceIncludes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2); + expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2); }); - it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => { + it(`includes _sourceIncludes when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); expect(callAdminCluster).toHaveBeenLastCalledWith( expect.any(String), - expect.not.objectContaining({ - _sourceIncludes: expect.anything(), + expect.objectContaining({ + _sourceIncludes: ['namespace', 'namespaces'], }) ); }); @@ -3093,6 +3109,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, attributes, references, + namespaces: [namespace], }); }); @@ -3103,10 +3120,10 @@ describe('SavedObjectsRepository', () => { }); }); - it(`doesn't include namespaces if type is not multi-namespace`, async () => { + it(`includes namespaces if type is not multi-namespace`, async () => { const result = await updateSuccess(type, id, attributes); - expect(result).not.toMatchObject({ - namespaces: expect.anything(), + expect(result).toMatchObject({ + namespaces: ['default'], }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 880b71e164b5b..7a5ac9204627c 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -423,7 +423,7 @@ export class SavedObjectsRepository { // When method == 'index' the bulkResponse doesn't include the indexed // _source so we return rawMigratedDoc but have to spread the latest // _seq_no and _primary_term values from the rawResponse. - return this._serializer.rawToSavedObject({ + return this._rawToSavedObject({ ...rawMigratedDoc, ...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term }, }); @@ -554,7 +554,7 @@ export class SavedObjectsRepository { }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { - namespace, + namespaces: namespace ? [namespace] : undefined, type: typesToUpdate, }), }, @@ -590,7 +590,7 @@ export class SavedObjectsRepository { sortField, sortOrder, fields, - namespace, + namespaces, type, filter, preference, @@ -651,7 +651,7 @@ export class SavedObjectsRepository { type: allowedTypes, sortField, sortOrder, - namespace, + namespaces, hasReference, kueryNode, }), @@ -768,10 +768,16 @@ export class SavedObjectsRepository { } const time = doc._source.updated_at; + + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)]; + } + return { id, type, - ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + namespaces, ...(time && { updated_at: time }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -817,10 +823,15 @@ export class SavedObjectsRepository { const { updated_at: updatedAt } = response._source; + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)]; + } + return { id, type, - ...(response._source.namespaces && { namespaces: response._source.namespaces }), + namespaces, ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -874,7 +885,7 @@ export class SavedObjectsRepository { body: { doc, }, - ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }), + _sourceIncludes: ['namespace', 'namespaces'], }); if (updateResponse.status === 404) { @@ -882,14 +893,19 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = updateResponse.get._source.namespaces ?? [ + getNamespaceString(updateResponse.get._source.namespace), + ]; + } + return { id, type, updated_at: time, version: encodeHitVersion(updateResponse), - ...(this._registry.isMultiNamespace(type) && { - namespaces: updateResponse.get._source.namespaces, - }), + namespaces, references, attributes, }; @@ -1142,9 +1158,14 @@ export class SavedObjectsRepository { }, }; } - namespaces = actualResult._source.namespaces; + namespaces = actualResult._source.namespaces ?? [ + getNamespaceString(actualResult._source.namespace), + ]; versionProperties = getExpectedVersionProperties(version, actualResult); } else { + if (this._registry.isSingleNamespace(type)) { + namespaces = [getNamespaceString(namespace)]; + } versionProperties = getExpectedVersionProperties(version); } @@ -1340,12 +1361,12 @@ export class SavedObjectsRepository { return new Date().toISOString(); } - // The internal representation of the saved object that the serializer returns - // includes the namespace, and we use this for migrating documents. However, we don't - // want the namespace to be returned from the repository, as the repository scopes each - // method transparently to the specified namespace. private _rawToSavedObject(raw: SavedObjectsRawDoc): SavedObject { const savedObject = this._serializer.rawToSavedObject(raw); + const { namespace, type } = savedObject; + if (this._registry.isSingleNamespace(type)) { + savedObject.namespaces = [getNamespaceString(namespace)]; + } return omit(savedObject, 'namespace') as SavedObject; } 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 a0ffa91f53671..f916638c5251b 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 @@ -196,19 +196,29 @@ describe('#getQueryParams', () => { }); }); - describe('`namespace` parameter', () => { - const createTypeClause = (type: string, namespace?: string) => { + describe('`namespaces` parameter', () => { + const createTypeClause = (type: string, namespaces?: string[]) => { if (registry.isMultiNamespace(type)) { return { bool: { - must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]), + must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), must_not: [{ exists: { field: 'namespace' } }], }, }; - } else if (namespace && registry.isSingleNamespace(type)) { + } 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: expect.arrayContaining([{ term: { namespace } }]), + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, must_not: [{ exists: { field: 'namespaces' } }], }, }; @@ -229,23 +239,45 @@ describe('#getQueryParams', () => { ); }; - const test = (namespace?: string) => { + const test = (namespaces?: string[]) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { - const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace }); + const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces }); const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - expectResult(result, ...types.map((x) => createTypeClause(x, namespace))); + expectResult(result, ...types.map((x) => createTypeClause(x, namespaces))); } // also test with no specified type/s - const result = getQueryParams({ mappings, registry, type: undefined, namespace }); - expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespace))); + const result = getQueryParams({ mappings, registry, type: undefined, namespaces }); + expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces))); }; - it('filters results with "namespace" field when `namespace` is not specified', () => { + it('normalizes and deduplicates provided namespaces', () => { + const result = getQueryParams({ + mappings, + registry, + search: '*', + namespaces: ['foo', '*', 'foo', 'bar', 'default'], + }); + + expectResult( + result, + ...ALL_TYPES.map((x) => createTypeClause(x, ['foo', 'default', 'bar'])) + ); + }); + + it('filters results with "namespace" field when `namespaces` is not specified', () => { test(undefined); }); it('filters results for specified namespace for appropriate type/s', () => { - test('foo-namespace'); + test(['foo-namespace']); + }); + + it('filters results for specified namespaces for appropriate type/s', () => { + test(['foo-namespace', 'default']); + }); + + it('filters results for specified `default` namespace for appropriate type/s', () => { + test(['default']); }); }); }); @@ -353,4 +385,18 @@ describe('#getQueryParams', () => { }); }); }); + + describe('namespaces property', () => { + ALL_TYPES.forEach((type) => { + it(`throws for ${type} when namespaces is an empty array`, () => { + expect(() => + getQueryParams({ + mappings, + registry, + namespaces: [], + }) + ).toThrowError('cannot specify empty namespaces array'); + }); + }); + }); }); 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 40485564176a6..164756f9796a5 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 @@ -63,25 +63,42 @@ function getFieldsForTypes(types: string[], searchFields?: string[]) { */ function getClauseForType( registry: ISavedObjectTypeRegistry, - namespace: string | undefined, + namespaces: string[] = ['default'], type: string ) { + if (namespaces.length === 0) { + throw new Error('cannot specify empty namespaces array'); + } if (registry.isMultiNamespace(type)) { return { bool: { - must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }], + must: [{ term: { type } }, { terms: { namespaces } }], must_not: [{ exists: { field: 'namespace' } }], }, }; - } else if (namespace && registry.isSingleNamespace(type)) { + } else if (registry.isSingleNamespace(type)) { + const should: Array> = []; + const eligibleNamespaces = namespaces.filter((namespace) => namespace !== 'default'); + if (eligibleNamespaces.length > 0) { + should.push({ terms: { namespace: eligibleNamespaces } }); + } + if (namespaces.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + if (should.length === 0) { + // This is indicitive of a bug, and not user error. + throw new Error('unhandled search condition: expected at least 1 `should` clause.'); + } return { bool: { - must: [{ term: { type } }, { term: { namespace } }], + must: [{ term: { type } }], + should, + minimum_should_match: 1, must_not: [{ exists: { field: 'namespaces' } }], }, }; } - // isSingleNamespace in the default namespace, or isNamespaceAgnostic + // isNamespaceAgnostic return { bool: { must: [{ term: { type } }], @@ -98,7 +115,7 @@ interface HasReferenceQueryParams { interface QueryParams { mappings: IndexMapping; registry: ISavedObjectTypeRegistry; - namespace?: string; + namespaces?: string[]; type?: string | string[]; search?: string; searchFields?: string[]; @@ -113,7 +130,7 @@ interface QueryParams { export function getQueryParams({ mappings, registry, - namespace, + namespaces, type, search, searchFields, @@ -122,6 +139,22 @@ export function getQueryParams({ kueryNode, }: QueryParams) { const types = getTypes(mappings, type); + + // A de-duplicated set of namespaces makes for a more effecient query. + // + // Additonally, we treat the `*` namespace as the `default` namespace. + // In the Default Distribution, the `*` is automatically expanded to include all available namespaces. + // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` + // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, + // 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((namespace) => (namespace === '*' ? 'default' : namespace))) + ) + : undefined; + const bool: any = { filter: [ ...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []), @@ -152,7 +185,9 @@ export function getQueryParams({ }, ] : undefined, - should: types.map((shouldType) => getClauseForType(registry, namespace, shouldType)), + should: types.map((shouldType) => + 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 95b7ffd117ee9..08ad72397e4a2 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,9 +57,9 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => { const opts = { - namespace: 'foo-namespace', + namespaces: ['foo-namespace'], type: 'foo', search: 'bar', searchFields: ['baz'], @@ -75,7 +75,7 @@ describe('getSearchDsl', () => { expect(getQueryParams).toHaveBeenCalledWith({ mappings, registry, - namespace: opts.namespace, + namespaces: opts.namespaces, type: opts.type, search: opts.search, searchFields: opts.searchFields, 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 74c25491aff8b..6de868c320240 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 @@ -33,7 +33,7 @@ interface GetSearchDslOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; - namespace?: string; + namespaces?: string[]; hasReference?: { type: string; id: string; @@ -53,7 +53,7 @@ export function getSearchDsl( searchFields, sortField, sortOrder, - namespace, + namespaces, hasReference, kueryNode, } = options; @@ -70,7 +70,7 @@ export function getSearchDsl( ...getQueryParams({ mappings, registry, - namespace, + namespaces, type, search, searchFields, diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 2183b47b732f9..f9301d6598b1d 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -63,7 +63,7 @@ export interface SavedObjectStatusMeta { * * @public */ -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { type: string | string[]; page?: number; perPage?: number; @@ -82,6 +82,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; filter?: string; + namespaces?: string[]; /** 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 3d3e1905577d9..a0e16602ba4bf 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -811,6 +811,7 @@ export interface HttpServiceSetup { registerOnPostAuth: (handler: OnPostAuthHandler) => void; registerOnPreAuth: (handler: OnPreAuthHandler) => void; registerOnPreResponse: (handler: OnPreResponseHandler) => void; + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; registerRouteHandlerContext: (contextName: T, provider: RequestHandlerContextProvider) => RequestHandlerContextContainer; } @@ -1536,7 +1537,6 @@ export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleRespo // @public export interface OnPreAuthToolkit { next: () => OnPreAuthResult; - rewriteUrl: (url: string) => OnPreAuthResult; } // @public @@ -1560,6 +1560,17 @@ export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } +// Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts +// +// @public +export type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; + +// @public +export interface OnPreRoutingToolkit { + next: () => OnPreRoutingResult; + rewriteUrl: (url: string) => OnPreRoutingResult; +} + // @public export interface OpsMetrics { concurrent_connections: OpsServerMetrics['concurrent_connections']; @@ -2164,7 +2175,7 @@ export interface SavedObjectsExportResultDetails { export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjectsComplexFieldMapping; // @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; @@ -2176,6 +2187,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { id: string; }; // (undocumented) + namespaces?: string[]; + // (undocumented) page?: number; // (undocumented) perPage?: number; @@ -2387,7 +2400,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, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ id: string; diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_graph.hjson b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_graph.hjson deleted file mode 100644 index db19c937ca990..0000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_graph.hjson +++ /dev/null @@ -1,76 +0,0 @@ -{ - // Adapted from Vega's https://vega.github.io/vega/examples/stacked-area-chart/ - - $schema: https://vega.github.io/schema/vega/v5.json - data: [ - { - name: table - values: [ - {x: 0, y: 28, c: 0}, {x: 0, y: 55, c: 1}, {x: 1, y: 43, c: 0}, {x: 1, y: 91, c: 1}, - {x: 2, y: 81, c: 0}, {x: 2, y: 53, c: 1}, {x: 3, y: 19, c: 0}, {x: 3, y: 87, c: 1}, - {x: 4, y: 52, c: 0}, {x: 4, y: 48, c: 1}, {x: 5, y: 24, c: 0}, {x: 5, y: 49, c: 1}, - {x: 6, y: 87, c: 0}, {x: 6, y: 66, c: 1}, {x: 7, y: 17, c: 0}, {x: 7, y: 27, c: 1}, - {x: 8, y: 68, c: 0}, {x: 8, y: 16, c: 1}, {x: 9, y: 49, c: 0}, {x: 9, y: 15, c: 1} - ] - transform: [ - { - type: stack - groupby: ["x"] - sort: {field: "c"} - field: y - } - ] - } - ] - scales: [ - { - name: x - type: point - range: width - domain: {data: "table", field: "x"} - } - { - name: y - type: linear - range: height - nice: true - zero: true - domain: {data: "table", field: "y1"} - } - { - name: color - type: ordinal - range: category - domain: {data: "table", field: "c"} - } - ] - marks: [ - { - type: group - from: { - facet: {name: "series", data: "table", groupby: "c"} - } - marks: [ - { - type: area - from: {data: "series"} - encode: { - enter: { - interpolate: {value: "monotone"} - x: {scale: "x", field: "x"} - y: {scale: "y", field: "y0"} - y2: {scale: "y", field: "y1"} - fill: {scale: "color", field: "c"} - } - update: { - fillOpacity: {value: 1} - } - hover: { - fillOpacity: {value: 0.5} - } - } - } - ] - } - ] -} diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_image_512.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_image_512.png deleted file mode 100644 index cc28886794f03..0000000000000 Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_image_512.png and /dev/null differ diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_image_256.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_image_256.png deleted file mode 100644 index ac455ada3900b..0000000000000 Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_image_256.png and /dev/null differ diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_test.hjson b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_test.hjson deleted file mode 100644 index 633b8658ad849..0000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_map_test.hjson +++ /dev/null @@ -1,20 +0,0 @@ -# This graph creates a single rectangle for the whole graph on top of a map -# Note that the actual map tiles are not loaded -{ - $schema: https://vega.github.io/schema/vega/v5.json - config: { - kibana: {type: "map", mapStyle: false} - } - marks: [ - { - type: rect - encode: { - enter: { - fill: {value: "#0f0"} - width: {signal: "width"} - height: {signal: "height"} - } - } - } - ] -} diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_tooltip_test.hjson b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_tooltip_test.hjson deleted file mode 100644 index 77465c8b3f007..0000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_tooltip_test.hjson +++ /dev/null @@ -1,44 +0,0 @@ -# This graph creates a single rectangle for the whole graph, -# backed by a datum with two fields - fld1 & fld2 -# On mouse over, with 0 delay, it should show tooltip -{ - config: { - kibana: { - tooltips: { - // always center on the mark, not mouse x,y - centerOnMark: false - position: top - padding: 20 - } - } - } - data: [ - { - name: table - values: [ - { - title: This is a long title - fieldA: value of fld1 - fld2: 42 - } - ] - } - ] - $schema: https://vega.github.io/schema/vega/v5.json - marks: [ - { - from: {data: "table"} - type: rect - encode: { - enter: { - fill: {value: "#060"} - x: {signal: "0"} - y: {signal: "0"} - width: {signal: "width"} - height: {signal: "height"} - tooltip: {signal: "datum || null"} - } - } - } - ] -} diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js deleted file mode 100644 index 30e7587707d2e..0000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vega_visualization.js +++ /dev/null @@ -1,362 +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 Bluebird from 'bluebird'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import $ from 'jquery'; - -import 'leaflet/dist/leaflet.js'; -import 'leaflet-vega'; -// Will be replaced with new path when tests are moved -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { createVegaVisualization } from '../../../../../../plugins/vis_type_vega/public/vega_visualization'; -import { ImageComparator } from 'test_utils/image_comparator'; - -import vegaliteGraph from '!!raw-loader!./vegalite_graph.hjson'; -import vegaliteImage256 from './vegalite_image_256.png'; -import vegaliteImage512 from './vegalite_image_512.png'; - -import vegaGraph from '!!raw-loader!./vega_graph.hjson'; -import vegaImage512 from './vega_image_512.png'; - -import vegaTooltipGraph from '!!raw-loader!./vega_tooltip_test.hjson'; - -import vegaMapGraph from '!!raw-loader!./vega_map_test.hjson'; -import vegaMapImage256 from './vega_map_image_256.png'; -// Will be replaced with new path when tests are moved -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { VegaParser } from '../../../../../../plugins/vis_type_vega/public/data_model/vega_parser'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SearchAPI } from '../../../../../../plugins/vis_type_vega/public/data_model/search_api'; - -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { createVegaTypeDefinition } from '../../../../../../plugins/vis_type_vega/public/vega_type'; -// TODO This is an integration test and thus requires a running platform. When moving to the new platform, -// this test has to be migrated to the newly created integration test environment. -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { npStart } from 'ui/new_platform'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { BaseVisType } from '../../../../../../plugins/visualizations/public/vis_types/base_vis_type'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ExprVis } from '../../../../../../plugins/visualizations/public/expressions/vis'; - -import { - setInjectedVars, - setData, - setSavedObjects, - setNotifications, - setKibanaMapFactory, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../plugins/vis_type_vega/public/services'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ServiceSettings } from '../../../../../../plugins/maps_legacy/public/map/service_settings'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { KibanaMap } from '../../../../../../plugins/maps_legacy/public/map/kibana_map'; - -const THRESHOLD = 0.1; -const PIXEL_DIFF = 30; - -describe('VegaVisualizations', () => { - let domNode; - let VegaVisualization; - let vis; - let imageComparator; - let vegaVisualizationDependencies; - let vegaVisType; - - setKibanaMapFactory((...args) => new KibanaMap(...args)); - setInjectedVars({ - emsTileLayerId: {}, - enableExternalUrls: true, - esShardTimeout: 10000, - }); - setData(npStart.plugins.data); - setSavedObjects(npStart.core.savedObjects); - setNotifications(npStart.core.notifications); - - const mockMapConfig = { - includeElasticMapsService: true, - proxyElasticMapsServiceInMaps: false, - tilemap: { - deprecated: { - config: { - options: { - attribution: '', - }, - }, - }, - options: { - attribution: '', - minZoom: 0, - maxZoom: 10, - }, - }, - regionmap: { - includeElasticMapsService: true, - layers: [], - }, - manifestServiceUrl: '', - emsFileApiUrl: 'https://vector.maps.elastic.co', - emsTileApiUrl: 'https://tiles.maps.elastic.co', - emsLandingPageUrl: 'https://maps.elastic.co/v7.7', - emsFontLibraryUrl: 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf', - emsTileLayerId: { - bright: 'road_map', - desaturated: 'road_map_desaturated', - dark: 'dark_map', - }, - }; - - beforeEach(ngMock.module('kibana')); - beforeEach( - ngMock.inject(() => { - const serviceSettings = new ServiceSettings(mockMapConfig, mockMapConfig.tilemap); - vegaVisualizationDependencies = { - serviceSettings, - core: { - uiSettings: npStart.core.uiSettings, - }, - plugins: { - data: { - query: { - timefilter: { - timefilter: {}, - }, - }, - }, - }, - }; - - vegaVisType = new BaseVisType(createVegaTypeDefinition(vegaVisualizationDependencies)); - VegaVisualization = createVegaVisualization(vegaVisualizationDependencies); - }) - ); - - describe('VegaVisualization - basics', () => { - beforeEach(async function () { - setupDOM('512px', '512px'); - imageComparator = new ImageComparator(); - - vis = new ExprVis({ - type: vegaVisType, - }); - }); - - afterEach(function () { - teardownDOM(); - imageComparator.destroy(); - }); - - it('should show vegalite graph and update on resize (may fail in dev env)', async function () { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - - const vegaParser = new VegaParser( - vegaliteGraph, - new SearchAPI({ - search: npStart.plugins.data.search, - uiSettings: npStart.core.uiSettings, - injectedMetadata: npStart.core.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - - await vegaVis.render(vegaParser, vis.params, { data: true }); - const mismatchedPixels1 = await compareImage(vegaliteImage512); - expect(mismatchedPixels1).to.be.lessThan(PIXEL_DIFF); - - domNode.style.width = '256px'; - domNode.style.height = '256px'; - - await vegaVis.render(vegaParser, vis.params, { resize: true }); - const mismatchedPixels2 = await compareImage(vegaliteImage256); - expect(mismatchedPixels2).to.be.lessThan(PIXEL_DIFF); - } finally { - vegaVis.destroy(); - } - }); - - it('should show vega graph (may fail in dev env)', async function () { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser( - vegaGraph, - new SearchAPI({ - search: npStart.plugins.data.search, - uiSettings: npStart.core.uiSettings, - injectedMetadata: npStart.core.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - - await vegaVis.render(vegaParser, vis.params, { data: true }); - const mismatchedPixels = await compareImage(vegaImage512); - - expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF); - } finally { - vegaVis.destroy(); - } - }); - - it('should show vegatooltip on mouseover over a vega graph (may fail in dev env)', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser( - vegaTooltipGraph, - new SearchAPI({ - search: npStart.plugins.data.search, - uiSettings: npStart.core.uiSettings, - injectedMetadata: npStart.core.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - await vegaVis.render(vegaParser, vis.params, { data: true }); - - const $el = $(domNode); - const offset = $el.offset(); - - const event = new MouseEvent('mousemove', { - view: window, - bubbles: true, - cancelable: true, - clientX: offset.left + 10, - clientY: offset.top + 10, - }); - - $el.find('canvas')[0].dispatchEvent(event); - - await Bluebird.delay(10); - - let tooltip = document.getElementById('vega-kibana-tooltip'); - expect(tooltip).to.be.ok(); - expect(tooltip.innerHTML).to.be( - '

This is a long title

' + - '' + - '' + - '' + - '
fieldA:value of fld1
fld2:42
' - ); - - vegaVis.destroy(); - - tooltip = document.getElementById('vega-kibana-tooltip'); - expect(tooltip).to.not.be.ok(); - } finally { - vegaVis.destroy(); - } - }); - - it('should show vega blank rectangle on top of a map (vegamap)', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser( - vegaMapGraph, - new SearchAPI({ - search: npStart.plugins.data.search, - uiSettings: npStart.core.uiSettings, - injectedMetadata: npStart.core.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - - domNode.style.width = '256px'; - domNode.style.height = '256px'; - - await vegaVis.render(vegaParser, vis.params, { data: true }); - const mismatchedPixels = await compareImage(vegaMapImage256); - expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF); - } finally { - vegaVis.destroy(); - } - }); - - it('should add a small subpixel value to the height of the canvas to avoid getting it set to 0', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser( - `{ - "$schema": "https://vega.github.io/schema/vega/v5.json", - "marks": [ - { - "type": "text", - "encode": { - "update": { - "text": { - "value": "Test" - }, - "align": {"value": "center"}, - "baseline": {"value": "middle"}, - "xc": {"signal": "width/2"}, - "yc": {"signal": "height/2"} - fontSize: {value: "14"} - } - } - } - ] - }`, - new SearchAPI({ - search: npStart.plugins.data.search, - uiSettings: npStart.core.uiSettings, - injectedMetadata: npStart.core.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - - domNode.style.width = '256px'; - domNode.style.height = '256px'; - - await vegaVis.render(vegaParser, vis.params, { data: true }); - const vegaView = vegaVis._vegaView._view; - expect(vegaView.height()).to.be(250.00000001); - } finally { - vegaVis.destroy(); - } - }); - }); - - async function compareImage(expectedImageSource) { - const elementList = domNode.querySelectorAll('canvas'); - expect(elementList.length).to.equal(1); - const firstCanvasOnMap = elementList[0]; - return imageComparator.compareImage(firstCanvasOnMap, expectedImageSource, THRESHOLD); - } - - function setupDOM(width, height) { - domNode = document.createElement('div'); - domNode.style.top = '0'; - domNode.style.left = '0'; - domNode.style.width = width; - domNode.style.height = height; - domNode.style.position = 'fixed'; - domNode.style.border = '1px solid blue'; - domNode.style['pointer-events'] = 'none'; - document.body.appendChild(domNode); - } - - function teardownDOM() { - domNode.innerHTML = ''; - document.body.removeChild(domNode); - } -}); diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_graph.hjson b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_graph.hjson deleted file mode 100644 index 2132b0f77e6bc..0000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_graph.hjson +++ /dev/null @@ -1,45 +0,0 @@ -{ - $schema: https://vega.github.io/schema/vega-lite/v4.json - data: { - format: {property: "aggregations.time_buckets.buckets"} - values: { - aggregations: { - time_buckets: { - buckets: [ - {key: 1512950400000, doc_count: 0} - {key: 1513036800000, doc_count: 0} - {key: 1513123200000, doc_count: 0} - {key: 1513209600000, doc_count: 4545} - {key: 1513296000000, doc_count: 4667} - {key: 1513382400000, doc_count: 4660} - {key: 1513468800000, doc_count: 133} - {key: 1513555200000, doc_count: 0} - {key: 1513641600000, doc_count: 0} - {key: 1513728000000, doc_count: 0} - ] - } - } - status: 200 - } - } - mark: line - encoding: { - x: { - field: key - type: temporal - axis: null - } - y: { - field: doc_count - type: quantitative - axis: null - } - } - config: { - range: { - category: {scheme: "elastic"} - } - mark: {color: "#54B399"} - } - autosize: {type: "fit", contains: "padding"} -} diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_256.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_256.png deleted file mode 100644 index 8f2d146287b08..0000000000000 Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_256.png and /dev/null differ diff --git a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_512.png b/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_512.png deleted file mode 100644 index 82077a1096b99..0000000000000 Binary files a/src/legacy/core_plugins/kibana/public/__tests__/vis_type_vega/vegalite_image_512.png and /dev/null differ diff --git a/src/legacy/core_plugins/timelion/index.ts b/src/legacy/core_plugins/timelion/index.ts deleted file mode 100644 index 9c8ab156d1a79..0000000000000 --- a/src/legacy/core_plugins/timelion/index.ts +++ /dev/null @@ -1,189 +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 { resolve } from 'path'; -import { i18n } from '@kbn/i18n'; -import { Legacy } from 'kibana'; -import { LegacyPluginApi, LegacyPluginInitializer } from 'src/legacy/plugin_discovery/types'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/server'; - -const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { - defaultMessage: 'experimental', -}); - -const timelionPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => - new Plugin({ - require: ['kibana', 'elasticsearch'], - config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - ui: Joi.object({ - enabled: Joi.boolean().default(false), - }).default(), - graphiteUrls: Joi.array() - .items(Joi.string().uri({ scheme: ['http', 'https'] })) - .default([]), - }).default(); - }, - // @ts-ignore - // https://github.com/elastic/kibana/pull/44039#discussion_r326582255 - uiCapabilities() { - return { - timelion: { - save: true, - }, - }; - }, - publicDir: resolve(__dirname, 'public'), - uiExports: { - app: { - title: 'Timelion', - order: 8000, - icon: 'plugins/timelion/icon.svg', - euiIconType: 'timelionApp', - main: 'plugins/timelion/app', - category: DEFAULT_APP_CATEGORIES.kibana, - }, - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - hacks: [resolve(__dirname, 'public/legacy')], - uiSettingDefaults: { - 'timelion:showTutorial': { - name: i18n.translate('timelion.uiSettings.showTutorialLabel', { - defaultMessage: 'Show tutorial', - }), - value: false, - description: i18n.translate('timelion.uiSettings.showTutorialDescription', { - defaultMessage: 'Should I show the tutorial by default when entering the timelion app?', - }), - category: ['timelion'], - }, - 'timelion:es.timefield': { - name: i18n.translate('timelion.uiSettings.timeFieldLabel', { - defaultMessage: 'Time field', - }), - value: '@timestamp', - description: i18n.translate('timelion.uiSettings.timeFieldDescription', { - defaultMessage: 'Default field containing a timestamp when using {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - }, - 'timelion:es.default_index': { - name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { - defaultMessage: 'Default index', - }), - value: '_all', - description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { - defaultMessage: 'Default elasticsearch index to search with {esParam}', - values: { esParam: '.es()' }, - }), - category: ['timelion'], - }, - 'timelion:target_buckets': { - name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { - defaultMessage: 'Target buckets', - }), - value: 200, - description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { - defaultMessage: 'The number of buckets to shoot for when using auto intervals', - }), - category: ['timelion'], - }, - 'timelion:max_buckets': { - name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { - defaultMessage: 'Maximum buckets', - }), - value: 2000, - description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { - defaultMessage: 'The maximum number of buckets a single datasource can return', - }), - category: ['timelion'], - }, - 'timelion:default_columns': { - name: i18n.translate('timelion.uiSettings.defaultColumnsLabel', { - defaultMessage: 'Default columns', - }), - value: 2, - description: i18n.translate('timelion.uiSettings.defaultColumnsDescription', { - defaultMessage: 'Number of columns on a timelion sheet by default', - }), - category: ['timelion'], - }, - 'timelion:default_rows': { - name: i18n.translate('timelion.uiSettings.defaultRowsLabel', { - defaultMessage: 'Default rows', - }), - value: 2, - description: i18n.translate('timelion.uiSettings.defaultRowsDescription', { - defaultMessage: 'Number of rows on a timelion sheet by default', - }), - category: ['timelion'], - }, - 'timelion:min_interval': { - name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { - defaultMessage: 'Minimum interval', - }), - value: '1ms', - description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { - defaultMessage: 'The smallest interval that will be calculated when using "auto"', - description: - '"auto" is a technical value in that context, that should not be translated.', - }), - category: ['timelion'], - }, - 'timelion:graphite.url': { - name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { - defaultMessage: 'Graphite URL', - description: - 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', - }), - value: (server: Legacy.Server) => { - const urls = server.config().get('timelion.graphiteUrls') as string[]; - if (urls.length === 0) { - return null; - } else { - return urls[0]; - } - }, - description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { - defaultMessage: - '{experimentalLabel} The URL of your graphite host', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - type: 'select', - options: (server: Legacy.Server) => server.config().get('timelion.graphiteUrls'), - category: ['timelion'], - }, - 'timelion:quandl.key': { - name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { - defaultMessage: 'Quandl key', - }), - value: 'someKeyHere', - description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { - defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', - values: { experimentalLabel: `[${experimentalLabel}]` }, - }), - category: ['timelion'], - }, - }, - }, - }); - -// eslint-disable-next-line import/no-default-export -export default timelionPluginInitializer; diff --git a/src/legacy/core_plugins/timelion/package.json b/src/legacy/core_plugins/timelion/package.json deleted file mode 100644 index 8b138e3b76d1a..0000000000000 --- a/src/legacy/core_plugins/timelion/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "author": "Rashid Khan ", - "name": "timelion", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/timelion/public/app.js b/src/legacy/core_plugins/timelion/public/app.js deleted file mode 100644 index 602b221b7d14d..0000000000000 --- a/src/legacy/core_plugins/timelion/public/app.js +++ /dev/null @@ -1,517 +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'; -// required for `ngSanitize` angular module -import 'angular-sanitize'; - -import { i18n } from '@kbn/i18n'; - -import routes from 'ui/routes'; -import { capabilities } from 'ui/capabilities'; -import { docTitle } from 'ui/doc_title'; -import { fatalError, toastNotifications } from 'ui/notify'; -import { timefilter } from 'ui/timefilter'; -import { npStart } from 'ui/new_platform'; -import { getSavedSheetBreadcrumbs, getCreateBreadcrumbs } from './breadcrumbs'; -import { getTimezone } from '../../../../plugins/vis_type_timelion/public'; - -import 'uiExports/savedObjectTypes'; - -require('ui/i18n'); -require('ui/autoload/all'); - -// TODO: remove ui imports completely (move to plugins) -import 'ui/directives/input_focus'; -import './directives/saved_object_finder'; -import 'ui/directives/listen'; -import './directives/saved_object_save_as_checkbox'; -import './services/saved_sheet_register'; - -import rootTemplate from 'plugins/timelion/index.html'; - -import { loadKbnTopNavDirectives } from '../../../../plugins/kibana_legacy/public'; -loadKbnTopNavDirectives(npStart.plugins.navigation.ui); - -require('plugins/timelion/directives/cells/cells'); -require('plugins/timelion/directives/fixed_element'); -require('plugins/timelion/directives/fullscreen/fullscreen'); -require('plugins/timelion/directives/timelion_expression_input'); -require('plugins/timelion/directives/timelion_help/timelion_help'); -require('plugins/timelion/directives/timelion_interval/timelion_interval'); -require('plugins/timelion/directives/timelion_save_sheet'); -require('plugins/timelion/directives/timelion_load_sheet'); -require('plugins/timelion/directives/timelion_options_sheet'); - -document.title = 'Timelion - Kibana'; - -const app = require('ui/modules').get('apps/timelion', ['i18n', 'ngSanitize']); - -routes.enable(); - -routes.when('/:id?', { - template: rootTemplate, - reloadOnSearch: false, - k7Breadcrumbs: ($injector, $route) => - $injector.invoke($route.current.params.id ? getSavedSheetBreadcrumbs : getCreateBreadcrumbs), - badge: (uiCapabilities) => { - if (uiCapabilities.timelion.save) { - return undefined; - } - - return { - text: i18n.translate('timelion.badge.readOnly.text', { - defaultMessage: 'Read only', - }), - tooltip: i18n.translate('timelion.badge.readOnly.tooltip', { - defaultMessage: 'Unable to save Timelion sheets', - }), - iconType: 'glasses', - }; - }, - resolve: { - savedSheet: function (redirectWhenMissing, savedSheets, $route) { - return savedSheets - .get($route.current.params.id) - .then((savedSheet) => { - if ($route.current.params.id) { - npStart.core.chrome.recentlyAccessed.add( - savedSheet.getFullPath(), - savedSheet.title, - savedSheet.id - ); - } - return savedSheet; - }) - .catch( - redirectWhenMissing({ - search: '/', - }) - ); - }, - }, -}); - -const location = 'Timelion'; - -app.controller('timelion', function ( - $http, - $route, - $routeParams, - $scope, - $timeout, - AppState, - config, - kbnUrl -) { - // Keeping this at app scope allows us to keep the current page when the user - // switches to say, the timepicker. - $scope.page = config.get('timelion:showTutorial', true) ? 1 : 0; - $scope.setPage = (page) => ($scope.page = page); - - timefilter.enableAutoRefreshSelector(); - timefilter.enableTimeRangeSelector(); - - const savedVisualizations = npStart.plugins.visualizations.savedVisualizationsLoader; - const timezone = getTimezone(config); - - const defaultExpression = '.es(*)'; - const savedSheet = $route.current.locals.savedSheet; - - $scope.topNavMenu = getTopNavMenu(); - - $timeout(function () { - if (config.get('timelion:showTutorial', true)) { - $scope.toggleMenu('showHelp'); - } - }, 0); - - $scope.transient = {}; - $scope.state = new AppState(getStateDefaults()); - function getStateDefaults() { - return { - sheet: savedSheet.timelion_sheet, - selected: 0, - columns: savedSheet.timelion_columns, - rows: savedSheet.timelion_rows, - interval: savedSheet.timelion_interval, - }; - } - - function getTopNavMenu() { - const newSheetAction = { - id: 'new', - label: i18n.translate('timelion.topNavMenu.newSheetButtonLabel', { - defaultMessage: 'New', - }), - description: i18n.translate('timelion.topNavMenu.newSheetButtonAriaLabel', { - defaultMessage: 'New Sheet', - }), - run: function () { - kbnUrl.change('/'); - }, - testId: 'timelionNewButton', - }; - - const addSheetAction = { - id: 'add', - label: i18n.translate('timelion.topNavMenu.addChartButtonLabel', { - defaultMessage: 'Add', - }), - description: i18n.translate('timelion.topNavMenu.addChartButtonAriaLabel', { - defaultMessage: 'Add a chart', - }), - run: function () { - $scope.$evalAsync(() => $scope.newCell()); - }, - testId: 'timelionAddChartButton', - }; - - const saveSheetAction = { - id: 'save', - label: i18n.translate('timelion.topNavMenu.saveSheetButtonLabel', { - defaultMessage: 'Save', - }), - description: i18n.translate('timelion.topNavMenu.saveSheetButtonAriaLabel', { - defaultMessage: 'Save Sheet', - }), - run: () => { - $scope.$evalAsync(() => $scope.toggleMenu('showSave')); - }, - testId: 'timelionSaveButton', - }; - - const deleteSheetAction = { - id: 'delete', - label: i18n.translate('timelion.topNavMenu.deleteSheetButtonLabel', { - defaultMessage: 'Delete', - }), - description: i18n.translate('timelion.topNavMenu.deleteSheetButtonAriaLabel', { - defaultMessage: 'Delete current sheet', - }), - disableButton: function () { - return !savedSheet.id; - }, - run: function () { - const title = savedSheet.title; - function doDelete() { - savedSheet - .delete() - .then(() => { - toastNotifications.addSuccess( - i18n.translate('timelion.topNavMenu.delete.modal.successNotificationText', { - defaultMessage: `Deleted '{title}'`, - values: { title }, - }) - ); - kbnUrl.change('/'); - }) - .catch((error) => fatalError(error, location)); - } - - const confirmModalOptions = { - confirmButtonText: i18n.translate('timelion.topNavMenu.delete.modal.confirmButtonLabel', { - defaultMessage: 'Delete', - }), - title: i18n.translate('timelion.topNavMenu.delete.modalTitle', { - defaultMessage: `Delete Timelion sheet '{title}'?`, - values: { title }, - }), - }; - - $scope.$evalAsync(() => { - npStart.core.overlays - .openConfirm( - i18n.translate('timelion.topNavMenu.delete.modal.warningText', { - defaultMessage: `You can't recover deleted sheets.`, - }), - confirmModalOptions - ) - .then((isConfirmed) => { - if (isConfirmed) { - doDelete(); - } - }); - }); - }, - testId: 'timelionDeleteButton', - }; - - const openSheetAction = { - id: 'open', - label: i18n.translate('timelion.topNavMenu.openSheetButtonLabel', { - defaultMessage: 'Open', - }), - description: i18n.translate('timelion.topNavMenu.openSheetButtonAriaLabel', { - defaultMessage: 'Open Sheet', - }), - run: () => { - $scope.$evalAsync(() => $scope.toggleMenu('showLoad')); - }, - testId: 'timelionOpenButton', - }; - - const optionsAction = { - id: 'options', - label: i18n.translate('timelion.topNavMenu.optionsButtonLabel', { - defaultMessage: 'Options', - }), - description: i18n.translate('timelion.topNavMenu.optionsButtonAriaLabel', { - defaultMessage: 'Options', - }), - run: () => { - $scope.$evalAsync(() => $scope.toggleMenu('showOptions')); - }, - testId: 'timelionOptionsButton', - }; - - const helpAction = { - id: 'help', - label: i18n.translate('timelion.topNavMenu.helpButtonLabel', { - defaultMessage: 'Help', - }), - description: i18n.translate('timelion.topNavMenu.helpButtonAriaLabel', { - defaultMessage: 'Help', - }), - run: () => { - $scope.$evalAsync(() => $scope.toggleMenu('showHelp')); - }, - testId: 'timelionDocsButton', - }; - - if (capabilities.get().timelion.save) { - return [ - newSheetAction, - addSheetAction, - saveSheetAction, - deleteSheetAction, - openSheetAction, - optionsAction, - helpAction, - ]; - } - return [newSheetAction, addSheetAction, openSheetAction, optionsAction, helpAction]; - } - - let refresher; - const setRefreshData = function () { - if (refresher) $timeout.cancel(refresher); - const interval = timefilter.getRefreshInterval(); - if (interval.value > 0 && !interval.pause) { - function startRefresh() { - refresher = $timeout(function () { - if (!$scope.running) $scope.search(); - startRefresh(); - }, interval.value); - } - startRefresh(); - } - }; - - const init = function () { - $scope.running = false; - $scope.search(); - setRefreshData(); - - $scope.model = { - timeRange: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - }; - - $scope.$listen($scope.state, 'fetch_with_changes', $scope.search); - timefilter.getFetch$().subscribe($scope.search); - - $scope.opts = { - saveExpression: saveExpression, - saveSheet: saveSheet, - savedSheet: savedSheet, - state: $scope.state, - search: $scope.search, - dontShowHelp: function () { - config.set('timelion:showTutorial', false); - $scope.setPage(0); - $scope.closeMenus(); - }, - }; - - $scope.menus = { - showHelp: false, - showSave: false, - showLoad: false, - showOptions: false, - }; - - $scope.toggleMenu = (menuName) => { - const curState = $scope.menus[menuName]; - $scope.closeMenus(); - $scope.menus[menuName] = !curState; - }; - - $scope.closeMenus = () => { - _.forOwn($scope.menus, function (value, key) { - $scope.menus[key] = false; - }); - }; - }; - - $scope.onTimeUpdate = function ({ dateRange }) { - $scope.model.timeRange = { - ...dateRange, - }; - timefilter.setTime(dateRange); - }; - - $scope.onRefreshChange = function ({ isPaused, refreshInterval }) { - $scope.model.refreshInterval = { - pause: isPaused, - value: refreshInterval, - }; - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : $scope.refreshInterval.value, - }); - - setRefreshData(); - }; - - $scope.$watch( - function () { - return savedSheet.lastSavedTitle; - }, - function (newTitle) { - docTitle.change(savedSheet.id ? newTitle : undefined); - } - ); - - $scope.toggle = function (property) { - $scope[property] = !$scope[property]; - }; - - $scope.newSheet = function () { - kbnUrl.change('/', {}); - }; - - $scope.newCell = function () { - $scope.state.sheet.push(defaultExpression); - $scope.state.selected = $scope.state.sheet.length - 1; - $scope.safeSearch(); - }; - - $scope.setActiveCell = function (cell) { - $scope.state.selected = cell; - }; - - $scope.search = function () { - $scope.state.save(); - $scope.running = true; - - // parse the time range client side to make sure it behaves like other charts - const timeRangeBounds = timefilter.getBounds(); - - const httpResult = $http - .post('../api/timelion/run', { - sheet: $scope.state.sheet, - time: _.assignIn( - { - from: timeRangeBounds.min, - to: timeRangeBounds.max, - }, - { - interval: $scope.state.interval, - timezone: timezone, - } - ), - }) - .then((resp) => resp.data) - .catch((resp) => { - throw resp.data; - }); - - httpResult - .then(function (resp) { - $scope.stats = resp.stats; - $scope.sheet = resp.sheet; - _.each(resp.sheet, function (cell) { - if (cell.exception) { - $scope.state.selected = cell.plot; - } - }); - $scope.running = false; - }) - .catch(function (resp) { - $scope.sheet = []; - $scope.running = false; - - const err = new Error(resp.message); - err.stack = resp.stack; - toastNotifications.addError(err, { - title: i18n.translate('timelion.searchErrorTitle', { - defaultMessage: 'Timelion request error', - }), - }); - }); - }; - - $scope.safeSearch = _.debounce($scope.search, 500); - - function saveSheet() { - savedSheet.timelion_sheet = $scope.state.sheet; - savedSheet.timelion_interval = $scope.state.interval; - savedSheet.timelion_columns = $scope.state.columns; - savedSheet.timelion_rows = $scope.state.rows; - savedSheet.save().then(function (id) { - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate('timelion.saveSheet.successNotificationText', { - defaultMessage: `Saved sheet '{title}'`, - values: { title: savedSheet.title }, - }), - 'data-test-subj': 'timelionSaveSuccessToast', - }); - - if (savedSheet.id !== $routeParams.id) { - kbnUrl.change('/{{id}}', { id: savedSheet.id }); - } - } - }); - } - - function saveExpression(title) { - savedVisualizations.get({ type: 'timelion' }).then(function (savedExpression) { - savedExpression.visState.params = { - expression: $scope.state.sheet[$scope.state.selected], - interval: $scope.state.interval, - }; - savedExpression.title = title; - savedExpression.visState.title = title; - savedExpression.save().then(function (id) { - if (id) { - toastNotifications.addSuccess( - i18n.translate('timelion.saveExpression.successNotificationText', { - defaultMessage: `Saved expression '{title}'`, - values: { title: savedExpression.title }, - }) - ); - } - }); - }); - } - - init(); -}); diff --git a/src/legacy/core_plugins/timelion/public/directives/cells/cells.js b/src/legacy/core_plugins/timelion/public/directives/cells/cells.js deleted file mode 100644 index 104af3b1043d6..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/cells/cells.js +++ /dev/null @@ -1,53 +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 { move } from 'ui/utils/collection'; - -require('angular-sortable-view'); -require('plugins/timelion/directives/chart/chart'); -require('plugins/timelion/directives/timelion_grid'); - -const app = require('ui/modules').get('apps/timelion', ['angular-sortable-view']); -import html from './cells.html'; - -app.directive('timelionCells', function () { - return { - restrict: 'E', - scope: { - sheet: '=', - state: '=', - transient: '=', - onSearch: '=', - onSelect: '=', - }, - template: html, - link: function ($scope) { - $scope.removeCell = function (index) { - _.pullAt($scope.state.sheet, index); - $scope.onSearch(); - }; - - $scope.dropCell = function (item, partFrom, partTo, indexFrom, indexTo) { - $scope.onSelect(indexTo); - move($scope.sheet, indexFrom, indexTo); - }; - }, - }; -}); diff --git a/src/legacy/core_plugins/timelion/public/directives/fixed_element.js b/src/legacy/core_plugins/timelion/public/directives/fixed_element.js deleted file mode 100644 index e3a8b2184bb20..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/fixed_element.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 $ from 'jquery'; - -const app = require('ui/modules').get('apps/timelion', []); -app.directive('fixedElementRoot', function () { - return { - restrict: 'A', - link: function ($elem) { - let fixedAt; - $(window).bind('scroll', function () { - const fixed = $('[fixed-element]', $elem); - const body = $('[fixed-element-body]', $elem); - const top = fixed.offset().top; - - if ($(window).scrollTop() > top) { - // This is a gross hack, but its better than it was. I guess - fixedAt = $(window).scrollTop(); - fixed.addClass(fixed.attr('fixed-element')); - body.addClass(fixed.attr('fixed-element-body')); - body.css({ top: fixed.height() }); - } - - if ($(window).scrollTop() < fixedAt) { - fixed.removeClass(fixed.attr('fixed-element')); - body.removeClass(fixed.attr('fixed-element-body')); - body.removeAttr('style'); - } - }); - }, - }; -}); diff --git a/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js b/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js deleted file mode 100644 index ae042310fd464..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/saved_object_finder.js +++ /dev/null @@ -1,315 +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 rison from 'rison-node'; -import { uiModules } from 'ui/modules'; -import 'ui/directives/input_focus'; -import savedObjectFinderTemplate from './saved_object_finder.html'; -import { savedSheetLoader } from '../services/saved_sheets'; -import { keyMap } from 'ui/directives/key_map'; -import { - PaginateControlsDirectiveProvider, - PaginateDirectiveProvider, -} from '../../../../../plugins/kibana_legacy/public'; -import { PER_PAGE_SETTING } from '../../../../../plugins/saved_objects/common'; -import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../../plugins/visualizations/public'; - -const module = uiModules.get('kibana'); - -module - .directive('paginate', PaginateDirectiveProvider) - .directive('paginateControls', PaginateControlsDirectiveProvider) - .directive('savedObjectFinder', function ($location, kbnUrl, Private, config) { - return { - restrict: 'E', - scope: { - type: '@', - // optional make-url attr, sets the userMakeUrl in our scope - userMakeUrl: '=?makeUrl', - // optional on-choose attr, sets the userOnChoose in our scope - userOnChoose: '=?onChoose', - // optional useLocalManagement attr, removes link to management section - useLocalManagement: '=?useLocalManagement', - /** - * @type {function} - an optional function. If supplied an `Add new X` button is shown - * and this function is called when clicked. - */ - onAddNew: '=', - /** - * @{type} boolean - set this to true, if you don't want the search box above the - * table to automatically gain focus once loaded - */ - disableAutoFocus: '=', - }, - template: savedObjectFinderTemplate, - controllerAs: 'finder', - controller: function ($scope, $element) { - const self = this; - - // the text input element - const $input = $element.find('input[ng-model=filter]'); - - // The number of items to show in the list - $scope.perPage = config.get(PER_PAGE_SETTING); - - // the list that will hold the suggestions - const $list = $element.find('ul'); - - // the current filter string, used to check that returned results are still useful - let currentFilter = $scope.filter; - - // the most recently entered search/filter - let prevSearch; - - // the list of hits, used to render display - self.hits = []; - - self.service = savedSheetLoader; - self.properties = self.service.loaderProperties; - - filterResults(); - - /** - * Boolean that keeps track of whether hits are sorted ascending (true) - * or descending (false) by title - * @type {Boolean} - */ - self.isAscending = true; - - /** - * Sorts saved object finder hits either ascending or descending - * @param {Array} hits Array of saved finder object hits - * @return {Array} Array sorted either ascending or descending - */ - self.sortHits = function (hits) { - self.isAscending = !self.isAscending; - self.hits = self.isAscending - ? _.sortBy(hits, 'title') - : _.sortBy(hits, 'title').reverse(); - }; - - /** - * Passed the hit objects and will determine if the - * hit should have a url in the UI, returns it if so - * @return {string|null} - the url or nothing - */ - self.makeUrl = function (hit) { - if ($scope.userMakeUrl) { - return $scope.userMakeUrl(hit); - } - - if (!$scope.userOnChoose) { - return hit.url; - } - - return '#'; - }; - - self.preventClick = function ($event) { - $event.preventDefault(); - }; - - /** - * Called when a hit object is clicked, can override the - * url behavior if necessary. - */ - self.onChoose = function (hit, $event) { - if ($scope.userOnChoose) { - $scope.userOnChoose(hit, $event); - } - - const url = self.makeUrl(hit); - if (!url || url === '#' || url.charAt(0) !== '#') return; - - $event.preventDefault(); - - // we want the '/path', not '#/path' - kbnUrl.change(url.substr(1)); - }; - - $scope.$watch('filter', function (newFilter) { - // ensure that the currentFilter changes from undefined to '' - // which triggers - currentFilter = newFilter || ''; - filterResults(); - }); - - $scope.pageFirstItem = 0; - $scope.pageLastItem = 0; - $scope.onPageChanged = (page) => { - $scope.pageFirstItem = page.firstItem; - $scope.pageLastItem = page.lastItem; - }; - - //manages the state of the keyboard selector - self.selector = { - enabled: false, - index: -1, - }; - - self.getLabel = function () { - return _.words(self.properties.nouns).map(_.upperFirst).join(' '); - }; - - //key handler for the filter text box - self.filterKeyDown = function ($event) { - switch (keyMap[$event.keyCode]) { - case 'enter': - if (self.hitCount !== 1) return; - - const hit = self.hits[0]; - if (!hit) return; - - self.onChoose(hit, $event); - $event.preventDefault(); - break; - } - }; - - //key handler for the list items - self.hitKeyDown = function ($event, page, paginate) { - switch (keyMap[$event.keyCode]) { - case 'tab': - if (!self.selector.enabled) break; - - self.selector.index = -1; - self.selector.enabled = false; - - //if the user types shift-tab return to the textbox - //if the user types tab, set the focus to the currently selected hit. - if ($event.shiftKey) { - $input.focus(); - } else { - $list.find('li.active a').focus(); - } - - $event.preventDefault(); - break; - case 'down': - if (!self.selector.enabled) break; - - if (self.selector.index + 1 < page.length) { - self.selector.index += 1; - } - $event.preventDefault(); - break; - case 'up': - if (!self.selector.enabled) break; - - if (self.selector.index > 0) { - self.selector.index -= 1; - } - $event.preventDefault(); - break; - case 'right': - if (!self.selector.enabled) break; - - if (page.number < page.count) { - paginate.goToPage(page.number + 1); - self.selector.index = 0; - selectTopHit(); - } - $event.preventDefault(); - break; - case 'left': - if (!self.selector.enabled) break; - - if (page.number > 1) { - paginate.goToPage(page.number - 1); - self.selector.index = 0; - selectTopHit(); - } - $event.preventDefault(); - break; - case 'escape': - if (!self.selector.enabled) break; - - $input.focus(); - $event.preventDefault(); - break; - case 'enter': - if (!self.selector.enabled) break; - - const hitIndex = (page.number - 1) * paginate.perPage + self.selector.index; - const hit = self.hits[hitIndex]; - if (!hit) break; - - self.onChoose(hit, $event); - $event.preventDefault(); - break; - case 'shift': - break; - default: - $input.focus(); - break; - } - }; - - self.hitBlur = function () { - self.selector.index = -1; - self.selector.enabled = false; - }; - - self.manageObjects = function (type) { - $location.url('/management/kibana/objects?_a=' + rison.encode({ tab: type })); - }; - - self.hitCountNoun = function () { - return (self.hitCount === 1 ? self.properties.noun : self.properties.nouns).toLowerCase(); - }; - - function selectTopHit() { - setTimeout(function () { - //triggering a focus event kicks off a new angular digest cycle. - $list.find('a:first').focus(); - }, 0); - } - - function filterResults() { - if (!self.service) return; - if (!self.properties) return; - - // track the filter that we use for this search, - // but ensure that we don't search for the same - // thing twice. This is called from multiple places - // and needs to be smart about when it actually searches - const filter = currentFilter; - if (prevSearch === filter) return; - - prevSearch = filter; - - const isLabsEnabled = config.get(VISUALIZE_ENABLE_LABS_SETTING); - self.service.find(filter).then(function (hits) { - hits.hits = hits.hits.filter( - (hit) => isLabsEnabled || _.get(hit, 'type.stage') !== 'experimental' - ); - hits.total = hits.hits.length; - - // ensure that we don't display old results - // as we can't really cancel requests - if (currentFilter === filter) { - self.hitCount = hits.total; - self.hits = _.sortBy(hits.hits, 'title'); - } - }); - } - }, - }; - }); diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js b/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js deleted file mode 100644 index 8b4c28a50b732..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_expression_input.js +++ /dev/null @@ -1,281 +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. - */ - -/** - * Timelion Expression Autocompleter - * - * This directive allows users to enter multiline timelion expressions. If the user has entered - * a valid expression and then types a ".", this directive will display a list of suggestions. - * - * Users can navigate suggestions using the arrow keys. When a user selects a suggestion, it's - * inserted into the expression and the caret position is updated to be inside of the newly- - * added function's parentheses. - * - * Beneath the hood, we use a PEG grammar to validate the Timelion expression and detect if - * the caret is in a position within the expression that allows functions to be suggested. - * - * NOTE: This directive doesn't work well with contenteditable divs. Challenges include: - * - You have to replace markup with newline characters and spaces when passing the expression - * to the grammar. - * - You have to do the opposite when loading a saved expression, so that it appears correctly - * within the contenteditable (i.e. replace newlines with
markup). - * - The Range and Selection APIs ignore newlines when providing caret position, so there is - * literally no way to insert suggestions into the correct place in a multiline expression - * that has more than a single consecutive newline. - */ - -import _ from 'lodash'; -import $ from 'jquery'; -import PEG from 'pegjs'; -import grammar from 'raw-loader!../../../../../plugins/vis_type_timelion/common/chain.peg'; -import timelionExpressionInputTemplate from './timelion_expression_input.html'; -import { - SUGGESTION_TYPE, - Suggestions, - suggest, - insertAtLocation, -} from './timelion_expression_input_helpers'; -import { comboBoxKeys } from '@elastic/eui'; -import { npStart } from 'ui/new_platform'; - -const Parser = PEG.generate(grammar); - -export function TimelionExpInput($http, $timeout) { - return { - restrict: 'E', - scope: { - rows: '=', - sheet: '=', - updateChart: '&', - shouldPopoverSuggestions: '@', - }, - replace: true, - template: timelionExpressionInputTemplate, - link: function (scope, elem) { - const argValueSuggestions = npStart.plugins.visTypeTimelion.getArgValueSuggestions(); - const expressionInput = elem.find('[data-expression-input]'); - const functionReference = {}; - let suggestibleFunctionLocation = {}; - - scope.suggestions = new Suggestions(); - - function init() { - $http.get('../api/timelion/functions').then(function (resp) { - Object.assign(functionReference, { - byName: _.keyBy(resp.data, 'name'), - list: resp.data, - }); - }); - } - - function setCaretOffset(caretOffset) { - // Wait for Angular to update the input with the new expression and *then* we can set - // the caret position. - $timeout(() => { - expressionInput.focus(); - expressionInput[0].selectionStart = expressionInput[0].selectionEnd = caretOffset; - scope.$apply(); - }, 0); - } - - function insertSuggestionIntoExpression(suggestionIndex) { - if (scope.suggestions.isEmpty()) { - return; - } - - const { min, max } = suggestibleFunctionLocation; - let insertedValue; - let insertPositionMinOffset = 0; - - switch (scope.suggestions.type) { - case SUGGESTION_TYPE.FUNCTIONS: { - // Position the caret inside of the function parentheses. - insertedValue = `${scope.suggestions.list[suggestionIndex].name}()`; - - // min advanced one to not replace function '.' - insertPositionMinOffset = 1; - break; - } - case SUGGESTION_TYPE.ARGUMENTS: { - // Position the caret after the '=' - insertedValue = `${scope.suggestions.list[suggestionIndex].name}=`; - break; - } - case SUGGESTION_TYPE.ARGUMENT_VALUE: { - // Position the caret after the argument value - insertedValue = `${scope.suggestions.list[suggestionIndex].name}`; - break; - } - } - - const updatedExpression = insertAtLocation( - insertedValue, - scope.sheet, - min + insertPositionMinOffset, - max - ); - scope.sheet = updatedExpression; - - const newCaretOffset = min + insertedValue.length; - setCaretOffset(newCaretOffset); - } - - function scrollToSuggestionAt(index) { - // We don't cache these because the list changes based on user input. - const suggestionsList = $('[data-suggestions-list]'); - const suggestionListItem = $('[data-suggestion-list-item]')[index]; - // Scroll to the position of the item relative to the list, not to the window. - suggestionsList.scrollTop(suggestionListItem.offsetTop - suggestionsList[0].offsetTop); - } - - function getCursorPosition() { - if (expressionInput.length) { - return expressionInput[0].selectionStart; - } - return null; - } - - async function getSuggestions() { - const suggestions = await suggest( - scope.sheet, - functionReference.list, - Parser, - getCursorPosition(), - argValueSuggestions - ); - - // We're using ES6 Promises, not $q, so we have to wrap this in $apply. - scope.$apply(() => { - if (suggestions) { - scope.suggestions.setList(suggestions.list, suggestions.type); - scope.suggestions.show(); - suggestibleFunctionLocation = suggestions.location; - $timeout(() => { - const suggestionsList = $('[data-suggestions-list]'); - suggestionsList.scrollTop(0); - }, 0); - return; - } - - suggestibleFunctionLocation = undefined; - scope.suggestions.reset(); - }); - } - - function isNavigationalKey(key) { - const keyCodes = _.values(comboBoxKeys); - return keyCodes.includes(key); - } - - scope.onFocusInput = () => { - // Wait for the caret position of the input to update and then we can get suggestions - // (which depends on the caret position). - $timeout(getSuggestions, 0); - }; - - scope.onBlurInput = () => { - scope.suggestions.hide(); - }; - - scope.onKeyDownInput = (e) => { - // If we've pressed any non-navigational keys, then the user has typed something and we - // can exit early without doing any navigation. The keyup handler will pull up suggestions. - if (!isNavigationalKey(e.key)) { - return; - } - - switch (e.keyCode) { - case comboBoxKeys.ARROW_UP: - if (scope.suggestions.isVisible) { - // Up and down keys navigate through suggestions. - e.preventDefault(); - scope.suggestions.stepForward(); - scrollToSuggestionAt(scope.suggestions.index); - } - break; - - case comboBoxKeys.ARROW_DOWN: - if (scope.suggestions.isVisible) { - // Up and down keys navigate through suggestions. - e.preventDefault(); - scope.suggestions.stepBackward(); - scrollToSuggestionAt(scope.suggestions.index); - } - break; - - case comboBoxKeys.TAB: - // If there are no suggestions or none is selected, the user tabs to the next input. - if (scope.suggestions.isEmpty() || scope.suggestions.index < 0) { - // Before letting the tab be handled to focus the next element - // we need to hide the suggestions, otherwise it will focus these - // instead of the time interval select. - scope.suggestions.hide(); - return; - } - - // If we have suggestions, complete the selected one. - e.preventDefault(); - insertSuggestionIntoExpression(scope.suggestions.index); - break; - - case comboBoxKeys.ENTER: - if (e.metaKey || e.ctrlKey) { - // Re-render the chart when the user hits CMD+ENTER. - e.preventDefault(); - scope.updateChart(); - } else if (!scope.suggestions.isEmpty()) { - // If the suggestions are open, complete the expression with the suggestion. - e.preventDefault(); - insertSuggestionIntoExpression(scope.suggestions.index); - } - break; - - case comboBoxKeys.ESCAPE: - e.preventDefault(); - scope.suggestions.hide(); - break; - } - }; - - scope.onKeyUpInput = (e) => { - // If the user isn't navigating, then we should update the suggestions based on their input. - if (!isNavigationalKey(e.key)) { - getSuggestions(); - } - }; - - scope.onClickExpression = () => { - getSuggestions(); - }; - - scope.onClickSuggestion = (index) => { - insertSuggestionIntoExpression(index); - }; - - scope.getActiveSuggestionId = () => { - if (scope.suggestions.isVisible && scope.suggestions.index > -1) { - return `timelionSuggestion${scope.suggestions.index}`; - } - return ''; - }; - - init(); - }, - }; -} diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_grid.js b/src/legacy/core_plugins/timelion/public/directives/timelion_grid.js deleted file mode 100644 index 256c35331d016..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_grid.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 $ from 'jquery'; - -const app = require('ui/modules').get('apps/timelion', []); -app.directive('timelionGrid', function () { - return { - restrict: 'A', - scope: { - timelionGridRows: '=', - timelionGridColumns: '=', - }, - link: function ($scope, $elem) { - function init() { - setDimensions(); - } - - $scope.$on('$destroy', function () { - $(window).off('resize'); //remove the handler added earlier - }); - - $(window).resize(function () { - setDimensions(); - }); - - $scope.$watchMulti(['timelionGridColumns', 'timelionGridRows'], function () { - setDimensions(); - }); - - function setDimensions() { - const borderSize = 2; - const headerSize = 45 + 35 + 28 + 20 * 2; // chrome + subnav + buttons + (container padding) - const verticalPadding = 10; - - if ($scope.timelionGridColumns != null) { - $elem.width($elem.parent().width() / $scope.timelionGridColumns - borderSize * 2); - } - - if ($scope.timelionGridRows != null) { - $elem.height( - ($(window).height() - headerSize) / $scope.timelionGridRows - - (verticalPadding + borderSize * 2) - ); - } - } - - init(); - }, - }; -}); diff --git a/src/legacy/core_plugins/timelion/public/directives/timelion_help/timelion_help.js b/src/legacy/core_plugins/timelion/public/directives/timelion_help/timelion_help.js deleted file mode 100644 index 25f3df13153ba..0000000000000 --- a/src/legacy/core_plugins/timelion/public/directives/timelion_help/timelion_help.js +++ /dev/null @@ -1,168 +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 template from './timelion_help.html'; -import { i18n } from '@kbn/i18n'; -import { uiModules } from 'ui/modules'; -import _ from 'lodash'; -import moment from 'moment'; -import '../../components/timelionhelp_tabs_directive'; - -const app = uiModules.get('apps/timelion', []); - -app.directive('timelionHelp', function ($http) { - return { - restrict: 'E', - template, - controller: function ($scope) { - $scope.functions = { - list: [], - details: null, - }; - - $scope.activeTab = 'funcref'; - $scope.activateTab = function (tabName) { - $scope.activeTab = tabName; - }; - - function init() { - $scope.es = { - invalidCount: 0, - }; - - $scope.translations = { - nextButtonLabel: i18n.translate('timelion.help.nextPageButtonLabel', { - defaultMessage: 'Next', - }), - previousButtonLabel: i18n.translate('timelion.help.previousPageButtonLabel', { - defaultMessage: 'Previous', - }), - dontShowHelpButtonLabel: i18n.translate('timelion.help.dontShowHelpButtonLabel', { - defaultMessage: `Don't show this again`, - }), - strongNextText: i18n.translate('timelion.help.welcome.content.strongNextText', { - defaultMessage: 'Next', - }), - emphasizedEverythingText: i18n.translate( - 'timelion.help.welcome.content.emphasizedEverythingText', - { - defaultMessage: 'everything', - } - ), - notValidAdvancedSettingsPath: i18n.translate( - 'timelion.help.configuration.notValid.advancedSettingsPathText', - { - defaultMessage: 'Management / Kibana / Advanced Settings', - } - ), - validAdvancedSettingsPath: i18n.translate( - 'timelion.help.configuration.valid.advancedSettingsPathText', - { - defaultMessage: 'Management/Kibana/Advanced Settings', - } - ), - esAsteriskQueryDescription: i18n.translate( - 'timelion.help.querying.esAsteriskQueryDescriptionText', - { - defaultMessage: 'hey Elasticsearch, find everything in my default index', - } - ), - esIndexQueryDescription: i18n.translate( - 'timelion.help.querying.esIndexQueryDescriptionText', - { - defaultMessage: 'use * as the q (query) for the logstash-* index', - } - ), - strongAddText: i18n.translate('timelion.help.expressions.strongAddText', { - defaultMessage: 'Add', - }), - twoExpressionsDescriptionTitle: i18n.translate( - 'timelion.help.expressions.examples.twoExpressionsDescriptionTitle', - { - defaultMessage: 'Double the fun.', - } - ), - customStylingDescriptionTitle: i18n.translate( - 'timelion.help.expressions.examples.customStylingDescriptionTitle', - { - defaultMessage: 'Custom styling.', - } - ), - namedArgumentsDescriptionTitle: i18n.translate( - 'timelion.help.expressions.examples.namedArgumentsDescriptionTitle', - { - defaultMessage: 'Named arguments.', - } - ), - groupedExpressionsDescriptionTitle: i18n.translate( - 'timelion.help.expressions.examples.groupedExpressionsDescriptionTitle', - { - defaultMessage: 'Grouped expressions.', - } - ), - }; - - getFunctions(); - checkElasticsearch(); - } - - function getFunctions() { - return $http.get('../api/timelion/functions').then(function (resp) { - $scope.functions.list = resp.data; - }); - } - $scope.recheckElasticsearch = function () { - $scope.es.valid = null; - checkElasticsearch().then(function (valid) { - if (!valid) $scope.es.invalidCount++; - }); - }; - - function checkElasticsearch() { - return $http.get('../api/timelion/validate/es').then(function (resp) { - if (resp.data.ok) { - $scope.es.valid = true; - $scope.es.stats = { - min: moment(resp.data.min).format('LLL'), - max: moment(resp.data.max).format('LLL'), - field: resp.data.field, - }; - } else { - $scope.es.valid = false; - $scope.es.invalidReason = (function () { - try { - const esResp = JSON.parse(resp.data.resp.response); - return _.get(esResp, 'error.root_cause[0].reason'); - } catch (e) { - if (_.get(resp, 'data.resp.message')) return _.get(resp, 'data.resp.message'); - if (_.get(resp, 'data.resp.output.payload.message')) - return _.get(resp, 'data.resp.output.payload.message'); - return i18n.translate('timelion.help.unknownErrorMessage', { - defaultMessage: 'Unknown error', - }); - } - })(); - } - return $scope.es.valid; - }); - } - init(); - }, - }; -}); diff --git a/src/legacy/core_plugins/timelion/public/header.svg b/src/legacy/core_plugins/timelion/public/header.svg deleted file mode 100644 index 56f2f0dc51a6e..0000000000000 --- a/src/legacy/core_plugins/timelion/public/header.svg +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - image/svg+xml - - Kibana-Full-Logo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Kibana-Full-Logo - - - - - - - - - - - - - - - - - - - - - diff --git a/src/legacy/core_plugins/timelion/public/icon.svg b/src/legacy/core_plugins/timelion/public/icon.svg deleted file mode 100644 index ba9a704b3ade2..0000000000000 --- a/src/legacy/core_plugins/timelion/public/icon.svg +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/src/legacy/core_plugins/timelion/public/legacy.ts b/src/legacy/core_plugins/timelion/public/legacy.ts deleted file mode 100644 index 7980291e2d462..0000000000000 --- a/src/legacy/core_plugins/timelion/public/legacy.ts +++ /dev/null @@ -1,35 +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 { PluginInitializerContext } from 'kibana/public'; -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '.'; -import { TimelionPluginSetupDependencies } from './plugin'; -import { LegacyDependenciesPlugin } from './shim'; - -const setupPlugins: Readonly = { - // Temporary solution - // It will be removed when all dependent services are migrated to the new platform. - __LEGACY: new LegacyDependenciesPlugin(), -}; - -const pluginInstance = plugin({} as PluginInitializerContext); - -export const setup = pluginInstance.setup(npSetup.core, setupPlugins); -export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/timelion/public/logo.png b/src/legacy/core_plugins/timelion/public/logo.png deleted file mode 100644 index 7a62253697a06..0000000000000 Binary files a/src/legacy/core_plugins/timelion/public/logo.png and /dev/null differ diff --git a/src/legacy/core_plugins/timelion/public/plugin.ts b/src/legacy/core_plugins/timelion/public/plugin.ts deleted file mode 100644 index 1f837303a2b3d..0000000000000 --- a/src/legacy/core_plugins/timelion/public/plugin.ts +++ /dev/null @@ -1,74 +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 { - CoreSetup, - Plugin, - PluginInitializerContext, - IUiSettingsClient, - CoreStart, -} from 'kibana/public'; -import { getTimeChart } from './panels/timechart/timechart'; -import { Panel } from './panels/panel'; -import { LegacyDependenciesPlugin, LegacyDependenciesPluginSetup } from './shim'; -import { KibanaLegacyStart } from '../../../../plugins/kibana_legacy/public'; - -/** @internal */ -export interface TimelionVisualizationDependencies extends LegacyDependenciesPluginSetup { - uiSettings: IUiSettingsClient; - timelionPanels: Map; -} - -/** @internal */ -export interface TimelionPluginSetupDependencies { - // Temporary solution - __LEGACY: LegacyDependenciesPlugin; -} - -/** @internal */ -export class TimelionPlugin implements Plugin, void> { - initializerContext: PluginInitializerContext; - - constructor(initializerContext: PluginInitializerContext) { - this.initializerContext = initializerContext; - } - - public async setup(core: CoreSetup, { __LEGACY }: TimelionPluginSetupDependencies) { - const timelionPanels: Map = new Map(); - - const dependencies: TimelionVisualizationDependencies = { - uiSettings: core.uiSettings, - timelionPanels, - ...(await __LEGACY.setup(core, timelionPanels)), - }; - - this.registerPanels(dependencies); - } - - private registerPanels(dependencies: TimelionVisualizationDependencies) { - const timeChartPanel: Panel = getTimeChart(dependencies); - - dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); - } - - public start(core: CoreStart, { kibanaLegacy }: { kibanaLegacy: KibanaLegacyStart }) { - kibanaLegacy.loadFontAwesome(); - } - - public stop(): void {} -} diff --git a/src/legacy/core_plugins/timelion/public/services/saved_sheets.ts b/src/legacy/core_plugins/timelion/public/services/saved_sheets.ts deleted file mode 100644 index 1fb29de83d3d7..0000000000000 --- a/src/legacy/core_plugins/timelion/public/services/saved_sheets.ts +++ /dev/null @@ -1,52 +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 { npStart } from 'ui/new_platform'; -// @ts-ignore -import { uiModules } from 'ui/modules'; -import { SavedObjectLoader } from '../../../../../plugins/saved_objects/public'; -import { createSavedSheetClass } from './_saved_sheet'; - -const module = uiModules.get('app/sheet'); - -const savedObjectsClient = npStart.core.savedObjects.client; -const services = { - savedObjectsClient, - indexPatterns: npStart.plugins.data.indexPatterns, - search: npStart.plugins.data.search, - chrome: npStart.core.chrome, - overlays: npStart.core.overlays, -}; - -const SavedSheet = createSavedSheetClass(services, npStart.core.uiSettings); - -export const savedSheetLoader = new SavedObjectLoader( - SavedSheet, - savedObjectsClient, - npStart.core.chrome -); -savedSheetLoader.urlFor = (id) => `#/${encodeURIComponent(id)}`; -// Customize loader properties since adding an 's' on type doesn't work for type 'timelion-sheet'. -savedSheetLoader.loaderProperties = { - name: 'timelion-sheet', - noun: 'Saved Sheets', - nouns: 'saved sheets', -}; - -// This is the only thing that gets injected into controllers -module.service('savedSheets', () => savedSheetLoader); diff --git a/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts b/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.ts deleted file mode 100644 index 8122259f1c991..0000000000000 --- a/src/legacy/core_plugins/timelion/public/shim/timelion_legacy_module.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 'ngreact'; -import 'brace/mode/hjson'; -import 'brace/ext/searchbox'; -import 'ui/accessibility/kbn_ui_ace_keyboard_mode'; - -import { once } from 'lodash'; -// @ts-ignore -import { uiModules } from 'ui/modules'; -import { Panel } from '../panels/panel'; -// @ts-ignore -import { Chart } from '../directives/chart/chart'; -// @ts-ignore -import { TimelionInterval } from '../directives/timelion_interval/timelion_interval'; -// @ts-ignore -import { TimelionExpInput } from '../directives/timelion_expression_input'; -// @ts-ignore -import { TimelionExpressionSuggestions } from '../directives/timelion_expression_suggestions/timelion_expression_suggestions'; - -/** @internal */ -export const initTimelionLegacyModule = once((timelionPanels: Map): void => { - require('ui/state_management/app_state'); - - uiModules - .get('apps/timelion', []) - .controller('TimelionVisController', function ($scope: any) { - $scope.$on('timelionChartRendered', (event: any) => { - event.stopPropagation(); - $scope.renderComplete(); - }); - }) - .constant('timelionPanels', timelionPanels) - .directive('chart', Chart) - .directive('timelionInterval', TimelionInterval) - .directive('timelionExpressionSuggestions', TimelionExpressionSuggestions) - .directive('timelionExpressionInput', TimelionExpInput); -}); diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 53f5185442688..952c35df244c1 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -237,7 +237,7 @@ export default () => manifestServiceUrl: Joi.string().default('').allow(''), emsFileApiUrl: Joi.string().default('https://vector.maps.elastic.co'), emsTileApiUrl: Joi.string().default('https://tiles.maps.elastic.co'), - emsLandingPageUrl: Joi.string().default('https://maps.elastic.co/v7.8'), + emsLandingPageUrl: Joi.string().default('https://maps.elastic.co/v7.9'), emsFontLibraryUrl: Joi.string().default( 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf' ), diff --git a/src/legacy/ui/public/state_management/__tests__/state.js b/src/legacy/ui/public/state_management/__tests__/state.js index cde123e6c1d85..b6c705e814509 100644 --- a/src/legacy/ui/public/state_management/__tests__/state.js +++ b/src/legacy/ui/public/state_management/__tests__/state.js @@ -21,6 +21,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import { encode as encodeRison } from 'rison-node'; +import uiRoutes from 'ui/routes'; import '../../private'; import { toastNotifications } from '../../notify'; import * as FatalErrorNS from '../../notify/fatal_error'; @@ -38,6 +39,8 @@ describe('State Management', () => { const sandbox = sinon.createSandbox(); afterEach(() => sandbox.restore()); + uiRoutes.enable(); + describe('Enabled', () => { let $rootScope; let $location; diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 8b3347f8d88f0..35f6dd65925ba 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -18,7 +18,6 @@ */ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin } from 'kibana/public'; -import { ManagementSectionId } from '../../management/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; @@ -31,7 +30,7 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { public setup(core: CoreSetup, { management }: AdvancedSettingsPluginSetup) { - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ id: 'settings', diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index 72a1056b1a866..b33d349cede28 100644 --- a/src/plugins/console/public/application/components/editor_example.tsx +++ b/src/plugins/console/public/application/components/editor_example.tsx @@ -27,13 +27,13 @@ interface EditorExampleProps { const exampleText = ` # index a doc -PUT index/1 +PUT index/_doc/1 { "body": "here" } # and get it ... -GET index/1 +GET index/_doc/1 `; export function EditorExample(props: EditorExampleProps) { diff --git a/src/plugins/data/common/field_formats/constants/base_formatters.ts b/src/plugins/data/common/field_formats/constants/base_formatters.ts index 921c50571f727..99c24496cf220 100644 --- a/src/plugins/data/common/field_formats/constants/base_formatters.ts +++ b/src/plugins/data/common/field_formats/constants/base_formatters.ts @@ -23,7 +23,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -40,7 +39,6 @@ export const baseFormatters: FieldFormatInstanceType[] = [ BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts similarity index 99% rename from src/plugins/data/common/field_formats/converters/date_nanos.test.ts rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts index 267f023e9b69d..6843427d273ff 100644 --- a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts +++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts @@ -18,7 +18,7 @@ */ import moment from 'moment-timezone'; -import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos'; +import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos_shared'; describe('Date Nanos Format', () => { let convert: Function; diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts similarity index 93% rename from src/plugins/data/common/field_formats/converters/date_nanos.ts rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.ts index 3fa2b1c276cd7..89a63243c76f0 100644 --- a/src/plugins/data/common/field_formats/converters/date_nanos.ts +++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts @@ -18,11 +18,9 @@ */ import { i18n } from '@kbn/i18n'; -import moment, { Moment } from 'moment'; import { memoize, noop } from 'lodash'; -import { KBN_FIELD_TYPES } from '../../kbn_field_types/types'; -import { FieldFormat } from '../field_format'; -import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; +import moment, { Moment } from 'moment'; +import { FieldFormat, FIELD_FORMAT_IDS, KBN_FIELD_TYPES, TextContextTypeConvert } from '../../'; /** * Analyse the given moment.js format pattern for the fractional sec part (S,SS,SSS...) @@ -76,9 +74,9 @@ export class DateNanosFormat extends FieldFormat { }); static fieldType = KBN_FIELD_TYPES.DATE; - private memoizedConverter: Function = noop; - private memoizedPattern: string = ''; - private timeZone: string = ''; + protected memoizedConverter: Function = noop; + protected memoizedPattern: string = ''; + protected timeZone: string = ''; getParamDefaults() { return { diff --git a/src/plugins/data/common/field_formats/converters/index.ts b/src/plugins/data/common/field_formats/converters/index.ts index cc9fae7fc9965..f71ddf5f781f7 100644 --- a/src/plugins/data/common/field_formats/converters/index.ts +++ b/src/plugins/data/common/field_formats/converters/index.ts @@ -19,7 +19,6 @@ export { UrlFormat } from './url'; export { BytesFormat } from './bytes'; -export { DateNanosFormat } from './date_nanos'; export { RelativeDateFormat } from './relative_date'; export { DurationFormat } from './duration'; export { IpFormat } from './ip'; diff --git a/src/plugins/data/common/field_formats/field_formats_registry.ts b/src/plugins/data/common/field_formats/field_formats_registry.ts index 74a942b51583d..84bedd2f9dee0 100644 --- a/src/plugins/data/common/field_formats/field_formats_registry.ts +++ b/src/plugins/data/common/field_formats/field_formats_registry.ts @@ -180,10 +180,18 @@ export class FieldFormatsRegistry { * @param {ES_FIELD_TYPES[]} esTypes * @return {FieldFormat} */ - getDefaultInstancePlain(fieldType: KBN_FIELD_TYPES, esTypes?: ES_FIELD_TYPES[]): FieldFormat { + getDefaultInstancePlain( + fieldType: KBN_FIELD_TYPES, + esTypes?: ES_FIELD_TYPES[], + params: Record = {} + ): FieldFormat { const conf = this.getDefaultConfig(fieldType, esTypes); + const instanceParams = { + ...conf.params, + ...params, + }; - return this.getInstance(conf.id, conf.params); + return this.getInstance(conf.id, instanceParams); } /** * Returns a cache key built by the given variables for caching in memoized diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts index 104ff030873aa..d622af2f663a1 100644 --- a/src/plugins/data/common/field_formats/index.ts +++ b/src/plugins/data/common/field_formats/index.ts @@ -27,7 +27,6 @@ export { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, diff --git a/src/plugins/data/public/field_formats/constants.ts b/src/plugins/data/public/field_formats/constants.ts index a5c2b4e379908..d5e292c0e78e5 100644 --- a/src/plugins/data/public/field_formats/constants.ts +++ b/src/plugins/data/public/field_formats/constants.ts @@ -18,6 +18,6 @@ */ import { baseFormatters } from '../../common'; -import { DateFormat } from './converters/date'; +import { DateFormat, DateNanosFormat } from './converters'; -export const baseFormattersPublic = [DateFormat, ...baseFormatters]; +export const baseFormattersPublic = [DateFormat, DateNanosFormat, ...baseFormatters]; diff --git a/src/legacy/core_plugins/timelion/public/shim/index.ts b/src/plugins/data/public/field_formats/converters/date_nanos.ts similarity index 89% rename from src/legacy/core_plugins/timelion/public/shim/index.ts rename to src/plugins/data/public/field_formats/converters/date_nanos.ts index cfc7b62ff4f86..d83926826011a 100644 --- a/src/legacy/core_plugins/timelion/public/shim/index.ts +++ b/src/plugins/data/public/field_formats/converters/date_nanos.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './legacy_dependencies_plugin'; +export { DateNanosFormat } from '../../../common/field_formats/converters/date_nanos_shared'; diff --git a/src/plugins/data/public/field_formats/converters/index.ts b/src/plugins/data/public/field_formats/converters/index.ts index c51111092beca..f5f154084242f 100644 --- a/src/plugins/data/public/field_formats/converters/index.ts +++ b/src/plugins/data/public/field_formats/converters/index.ts @@ -18,3 +18,4 @@ */ export { DateFormat } from './date'; +export { DateNanosFormat } from './date_nanos'; diff --git a/src/plugins/data/public/field_formats/index.ts b/src/plugins/data/public/field_formats/index.ts index 015d5b39561bb..4525959fb864d 100644 --- a/src/plugins/data/public/field_formats/index.ts +++ b/src/plugins/data/public/field_formats/index.ts @@ -18,5 +18,5 @@ */ export { FieldFormatsService, FieldFormatsSetup, FieldFormatsStart } from './field_formats_service'; -export { DateFormat } from './converters'; +export { DateFormat, DateNanosFormat } from './converters'; export { baseFormattersPublic } from './constants'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index abec908b41c0f..2efd1c82aae79 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -157,7 +157,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -170,7 +169,7 @@ import { TruncateFormat, } from '../common/field_formats'; -import { DateFormat } from './field_formats'; +import { DateNanosFormat, DateFormat } from './field_formats'; export { baseFormattersPublic } from './field_formats'; // Field formats helpers namespace: diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index af7613698d7b3..6dd6e396de35b 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -248,11 +248,12 @@ export class AggParamType extends Ba makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig; } +// Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateFormat)[]; +export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateNanosFormat | typeof DateFormat)[]; // Warning: (ae-missing-release-tag) "BUCKET_TYPES" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1961,42 +1962,41 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:373:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:371:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:41:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:54:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:55:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit.test.ts b/src/plugins/data/public/search/aggs/metrics/top_hit.test.ts index cd5b4a2f724bd..c2434df3ae53c 100644 --- a/src/plugins/data/public/search/aggs/metrics/top_hit.test.ts +++ b/src/plugins/data/public/search/aggs/metrics/top_hit.test.ts @@ -111,9 +111,7 @@ describe('Top hit metric', () => { it('requests both source and docvalues_fields for non-text aggregatable fields', () => { init({ fieldName: 'bytes', readFromDocValues: true }); expect(aggDsl.top_hits._source).toBe('bytes'); - expect(aggDsl.top_hits.docvalue_fields).toEqual([ - { field: 'bytes', format: 'use_field_mapping' }, - ]); + expect(aggDsl.top_hits.docvalue_fields).toEqual([{ field: 'bytes' }]); }); it('requests both source and docvalues_fields for date aggregatable fields', () => { diff --git a/src/plugins/data/public/search/aggs/metrics/top_hit.ts b/src/plugins/data/public/search/aggs/metrics/top_hit.ts index 5ca883e60afd3..bee731dcc2e0d 100644 --- a/src/plugins/data/public/search/aggs/metrics/top_hit.ts +++ b/src/plugins/data/public/search/aggs/metrics/top_hit.ts @@ -88,12 +88,15 @@ export const getTopHitMetricAgg = () => { }; } else { if (field.readFromDocValues) { - // always format date fields as date_time to avoid - // displaying unformatted dates like epoch_millis - // or other not-accepted momentjs formats - const format = - field.type === KBN_FIELD_TYPES.DATE ? 'date_time' : 'use_field_mapping'; - output.params.docvalue_fields = [{ field: field.name, format }]; + output.params.docvalue_fields = [ + { + field: field.name, + // always format date fields as date_time to avoid + // displaying unformatted dates like epoch_millis + // or other not-accepted momentjs formats + ...(field.type === KBN_FIELD_TYPES.DATE && { format: 'date_time' }), + }, + ]; } output.params._source = field.name === '_source' ? true : field.name; } diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts new file mode 100644 index 0000000000000..ba8e128f32728 --- /dev/null +++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { DateNanosFormat } from './date_nanos_server'; +import { FieldFormatsGetConfigFn } from 'src/plugins/data/common'; + +describe('Date Nanos Format: Server side edition', () => { + let convert: Function; + let mockConfig: Record; + let getConfig: FieldFormatsGetConfigFn; + + const dateTime = '2019-05-05T14:04:56.201900001Z'; + + beforeEach(() => { + mockConfig = {}; + mockConfig.dateNanosFormat = 'MMMM Do YYYY, HH:mm:ss.SSSSSSSSS'; + mockConfig['dateFormat:tz'] = 'Browser'; + + getConfig = (key: string) => mockConfig[key]; + }); + + test('should format according to the given timezone parameter', () => { + const dateNy = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig); + convert = dateNy.convert.bind(dateNy); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`); + + const datePhx = new DateNanosFormat({ timezone: 'America/Phoenix' }, getConfig); + convert = datePhx.convert.bind(datePhx); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`); + }); + + test('should format according to UTC if no timezone parameter is given or exists in settings', () => { + const utcFormat = 'May 5th 2019, 14:04:56.201900001'; + const dateUtc = new DateNanosFormat({ timezone: 'UTC' }, getConfig); + convert = dateUtc.convert.bind(dateUtc); + expect(convert(dateTime)).toBe(utcFormat); + + const dateDefault = new DateNanosFormat({}, getConfig); + convert = dateDefault.convert.bind(dateDefault); + expect(convert(dateTime)).toBe(utcFormat); + }); + + test('should format according to dateFormat:tz if the setting is not "Browser"', () => { + mockConfig['dateFormat:tz'] = 'America/Phoenix'; + + const date = new DateNanosFormat({}, getConfig); + convert = date.convert.bind(date); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`); + }); + + test('should defer to meta params for timezone, not the UI config', () => { + mockConfig['dateFormat:tz'] = 'America/Phoenix'; + + const date = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig); + convert = date.convert.bind(date); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`); + }); +}); diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts new file mode 100644 index 0000000000000..299b2aac93d49 --- /dev/null +++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts @@ -0,0 +1,79 @@ +/* + * 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 { memoize } from 'lodash'; +import moment from 'moment-timezone'; +import { + analysePatternForFract, + DateNanosFormat, + formatWithNanos, +} from '../../../common/field_formats/converters/date_nanos_shared'; +import { TextContextTypeConvert } from '../../../common'; + +class DateNanosFormatServer extends DateNanosFormat { + textConvert: TextContextTypeConvert = (val) => { + // don't give away our ref to converter so + // we can hot-swap when config changes + const pattern = this.param('pattern'); + const timezone = this.param('timezone'); + const fractPattern = analysePatternForFract(pattern); + const fallbackPattern = this.param('patternFallback'); + + const timezoneChanged = this.timeZone !== timezone; + const datePatternChanged = this.memoizedPattern !== pattern; + if (timezoneChanged || datePatternChanged) { + this.timeZone = timezone; + this.memoizedPattern = pattern; + + this.memoizedConverter = memoize((value: any) => { + if (value === null || value === undefined) { + return '-'; + } + + /* On the server, importing moment returns a new instance. Unlike on + * the client side, it doesn't have the dateFormat:tz configuration + * baked in. + * We need to set the timezone manually here. The date is taken in as + * UTC and converted into the desired timezone. */ + let date; + if (this.timeZone === 'Browser') { + // Assume a warning has been logged that this can be unpredictable. It + // would be too verbose to log anything here. + date = moment.utc(val); + } else { + date = moment.utc(val).tz(this.timeZone); + } + + if (typeof value !== 'string' && date.isValid()) { + // fallback for max/min aggregation, where unixtime in ms is returned as a number + // aggregations in Elasticsearch generally just return ms + return date.format(fallbackPattern); + } else if (date.isValid()) { + return formatWithNanos(date, value, fractPattern); + } else { + return value; + } + }); + } + + return this.memoizedConverter(val); + }; +} + +export { DateNanosFormatServer as DateNanosFormat }; diff --git a/src/plugins/data/server/field_formats/converters/index.ts b/src/plugins/data/server/field_formats/converters/index.ts index f5c69df972869..1c6b827e2fbb5 100644 --- a/src/plugins/data/server/field_formats/converters/index.ts +++ b/src/plugins/data/server/field_formats/converters/index.ts @@ -18,3 +18,4 @@ */ export { DateFormat } from './date_server'; +export { DateNanosFormat } from './date_nanos_server'; diff --git a/src/plugins/data/server/field_formats/field_formats_service.ts b/src/plugins/data/server/field_formats/field_formats_service.ts index 70584efbee0a0..cafb88de4b893 100644 --- a/src/plugins/data/server/field_formats/field_formats_service.ts +++ b/src/plugins/data/server/field_formats/field_formats_service.ts @@ -23,10 +23,14 @@ import { baseFormatters, } from '../../common/field_formats'; import { IUiSettingsClient } from '../../../../core/server'; -import { DateFormat } from './converters'; +import { DateFormat, DateNanosFormat } from './converters'; export class FieldFormatsService { - private readonly fieldFormatClasses: FieldFormatInstanceType[] = [DateFormat, ...baseFormatters]; + private readonly fieldFormatClasses: FieldFormatInstanceType[] = [ + DateFormat, + DateNanosFormat, + ...baseFormatters, + ]; public setup() { return { diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 0dd0115add8ad..321bd913ce760 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -86,7 +86,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -105,7 +104,6 @@ export const fieldFormats = { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -166,15 +164,10 @@ import { export { ParsedInterval } from '../common'; export { - ISearch, - ISearchCancel, + ISearchStrategy, ISearchOptions, - IRequestTypesMap, - IResponseTypesMap, ISearchSetup, ISearchStart, - TStrategyTypes, - ISearchStrategy, getDefaultSearchParams, getTotalLoaded, } from './search'; diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index db08ddf920818..82f8ef21ebb38 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -17,17 +17,16 @@ * under the License. */ import { first } from 'rxjs/operators'; -import { RequestHandlerContext, SharedGlobalConfig } from 'kibana/server'; +import { SharedGlobalConfig } from 'kibana/server'; import { SearchResponse } from 'elasticsearch'; import { Observable } from 'rxjs'; -import { ES_SEARCH_STRATEGY } from '../../../common/search'; import { ISearchStrategy, getDefaultSearchParams, getTotalLoaded } from '..'; export const esSearchStrategyProvider = ( config$: Observable -): ISearchStrategy => { +): ISearchStrategy => { return { - search: async (context: RequestHandlerContext, request, options) => { + search: async (context, request, options) => { const config = await config$.pipe(first()).toPromise(); const defaultParams = getDefaultSearchParams(config); diff --git a/src/plugins/data/server/search/index.ts b/src/plugins/data/server/search/index.ts index 882f56e83d4ca..67789fcbf56b4 100644 --- a/src/plugins/data/server/search/index.ts +++ b/src/plugins/data/server/search/index.ts @@ -17,16 +17,6 @@ * under the License. */ -export { - ISearch, - ISearchCancel, - ISearchOptions, - IRequestTypesMap, - IResponseTypesMap, - ISearchSetup, - ISearchStart, - TStrategyTypes, - ISearchStrategy, -} from './types'; +export { ISearchStrategy, ISearchOptions, ISearchSetup, ISearchStart } from './types'; export { getDefaultSearchParams, getTotalLoaded } from './es_search'; diff --git a/src/plugins/data/server/search/mocks.ts b/src/plugins/data/server/search/mocks.ts index 0aab466a9a0d9..b210df3c55db9 100644 --- a/src/plugins/data/server/search/mocks.ts +++ b/src/plugins/data/server/search/mocks.ts @@ -26,5 +26,6 @@ export function createSearchSetupMock() { export function createSearchStartMock() { return { getSearchStrategy: jest.fn(), + search: jest.fn(), }; } diff --git a/src/plugins/data/server/search/routes.test.ts b/src/plugins/data/server/search/routes.test.ts index 4ef67de93e454..167bd5af5d51d 100644 --- a/src/plugins/data/server/search/routes.test.ts +++ b/src/plugins/data/server/search/routes.test.ts @@ -33,9 +33,8 @@ describe('Search service', () => { }); it('handler calls context.search.search with the given request and strategy', async () => { - const mockSearch = jest.fn().mockResolvedValue('yay'); - mockDataStart.search.getSearchStrategy.mockReturnValueOnce({ search: mockSearch }); - + const response = { id: 'yay' }; + mockDataStart.search.search.mockResolvedValue(response); const mockContext = {}; const mockBody = { params: {} }; const mockParams = { strategy: 'foo' }; @@ -51,21 +50,21 @@ describe('Search service', () => { const handler = mockRouter.post.mock.calls[0][1]; await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockDataStart.search.getSearchStrategy.mock.calls[0][0]).toBe(mockParams.strategy); - expect(mockSearch).toBeCalled(); - expect(mockSearch.mock.calls[0][1]).toStrictEqual(mockBody); + expect(mockDataStart.search.search).toBeCalled(); + expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual(mockBody); expect(mockResponse.ok).toBeCalled(); - expect(mockResponse.ok.mock.calls[0][0]).toEqual({ body: 'yay' }); + expect(mockResponse.ok.mock.calls[0][0]).toEqual({ + body: response, + }); }); it('handler throws an error if the search throws an error', async () => { - const mockSearch = jest.fn().mockRejectedValue({ + mockDataStart.search.search.mockRejectedValue({ message: 'oh no', body: { error: 'oops', }, }); - mockDataStart.search.getSearchStrategy.mockReturnValueOnce({ search: mockSearch }); const mockContext = {}; const mockBody = { params: {} }; @@ -82,9 +81,8 @@ describe('Search service', () => { const handler = mockRouter.post.mock.calls[0][1]; await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockDataStart.search.getSearchStrategy.mock.calls[0][0]).toBe(mockParams.strategy); - expect(mockSearch).toBeCalled(); - expect(mockSearch.mock.calls[0][1]).toStrictEqual(mockBody); + expect(mockDataStart.search.search).toBeCalled(); + expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual(mockBody); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; expect(error.body.message).toBe('oh no'); diff --git a/src/plugins/data/server/search/routes.ts b/src/plugins/data/server/search/routes.ts index 7b6c045b0908c..bf1982a1f7fb2 100644 --- a/src/plugins/data/server/search/routes.ts +++ b/src/plugins/data/server/search/routes.ts @@ -42,10 +42,12 @@ export function registerSearchRoute(core: CoreSetup): v const signal = getRequestAbortedSignal(request.events.aborted$); const [, , selfStart] = await core.getStartServices(); - const searchStrategy = selfStart.search.getSearchStrategy(strategy); try { - const response = await searchStrategy.search(context, searchRequest, { signal }); + const response = await selfStart.search.search(context, searchRequest, { + signal, + strategy, + }); return res.ok({ body: response }); } catch (err) { return res.customError({ diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index 389441a6aef84..5686023e9a667 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -17,14 +17,13 @@ * under the License. */ -import { Plugin, PluginInitializerContext, CoreSetup } from '../../../../core/server'; import { - ISearchSetup, - ISearchStart, - TSearchStrategiesMap, - TRegisterSearchStrategy, - TGetSearchStrategy, -} from './types'; + Plugin, + PluginInitializerContext, + CoreSetup, + RequestHandlerContext, +} from '../../../../core/server'; +import { ISearchSetup, ISearchStart, ISearchStrategy } from './types'; import { registerSearchRoute } from './routes'; import { ES_SEARCH_STRATEGY, esSearchStrategyProvider } from './es_search'; import { DataPluginStart } from '../plugin'; @@ -33,9 +32,14 @@ import { registerUsageCollector } from './collectors/register'; import { usageProvider } from './collectors/usage'; import { searchTelemetry } from '../saved_objects'; import { registerSearchUsageRoute } from './collectors/routes'; +import { IEsSearchRequest } from '../../common'; + +interface StrategyMap { + [name: string]: ISearchStrategy; +} export class SearchService implements Plugin { - private searchStrategies: TSearchStrategiesMap = {}; + private searchStrategies: StrategyMap = {}; constructor(private initializerContext: PluginInitializerContext) {} @@ -61,17 +65,28 @@ export class SearchService implements Plugin { return { registerSearchStrategy: this.registerSearchStrategy, usage }; } + private search(context: RequestHandlerContext, searchRequest: IEsSearchRequest, options: any) { + return this.getSearchStrategy(options.strategy || ES_SEARCH_STRATEGY).search( + context, + searchRequest, + { signal: options.signal } + ); + } + public start(): ISearchStart { - return { getSearchStrategy: this.getSearchStrategy }; + return { + getSearchStrategy: this.getSearchStrategy, + search: this.search, + }; } public stop() {} - private registerSearchStrategy: TRegisterSearchStrategy = (name, strategy) => { + private registerSearchStrategy = (name: string, strategy: ISearchStrategy) => { this.searchStrategies[name] = strategy; }; - private getSearchStrategy: TGetSearchStrategy = (name) => { + private getSearchStrategy = (name: string): ISearchStrategy => { const strategy = this.searchStrategies[name]; if (!strategy) { throw new Error(`Search strategy ${name} not found`); diff --git a/src/plugins/data/server/search/types.ts b/src/plugins/data/server/search/types.ts index 774cf9624556b..25dc890e0257d 100644 --- a/src/plugins/data/server/search/types.ts +++ b/src/plugins/data/server/search/types.ts @@ -19,15 +19,23 @@ import { RequestHandlerContext } from '../../../../core/server'; import { IKibanaSearchResponse, IKibanaSearchRequest } from '../../common/search'; -import { ES_SEARCH_STRATEGY, IEsSearchRequest, IEsSearchResponse } from './es_search'; import { SearchUsage } from './collectors/usage'; +import { IEsSearchRequest, IEsSearchResponse } from './es_search'; + +export interface ISearchOptions { + /** + * An `AbortSignal` that allows the caller of `search` to abort a search request. + */ + signal?: AbortSignal; + strategy?: string; +} export interface ISearchSetup { /** * Extension point exposed for other plugins to register their own search * strategies. */ - registerSearchStrategy: TRegisterSearchStrategy; + registerSearchStrategy: (name: string, strategy: ISearchStrategy) => void; /** * Used internally for telemetry @@ -40,78 +48,23 @@ export interface ISearchStart { * Get other registered search strategies. For example, if a new strategy needs to use the * already-registered ES search strategy, it can use this function to accomplish that. */ - getSearchStrategy: TGetSearchStrategy; -} - -export interface ISearchOptions { - /** - * An `AbortSignal` that allows the caller of `search` to abort a search request. - */ - signal?: AbortSignal; + getSearchStrategy: (name: string) => ISearchStrategy; + search: ( + context: RequestHandlerContext, + request: IKibanaSearchRequest, + options: ISearchOptions + ) => Promise; } -/** - * Contains all known strategy type identifiers that will be used to map to - * request and response shapes. Plugins that wish to add their own custom search - * strategies should extend this type via: - * - * const MY_STRATEGY = 'MY_STRATEGY'; - * - * declare module 'src/plugins/search/server' { - * export interface IRequestTypesMap { - * [MY_STRATEGY]: IMySearchRequest; - * } - * - * export interface IResponseTypesMap { - * [MY_STRATEGY]: IMySearchResponse - * } - * } - */ -export type TStrategyTypes = typeof ES_SEARCH_STRATEGY | string; - -/** - * The map of search strategy IDs to the corresponding request type definitions. - */ -export interface IRequestTypesMap { - [ES_SEARCH_STRATEGY]: IEsSearchRequest; - [key: string]: IKibanaSearchRequest; -} - -/** - * The map of search strategy IDs to the corresponding response type definitions. - */ -export interface IResponseTypesMap { - [ES_SEARCH_STRATEGY]: IEsSearchResponse; - [key: string]: IKibanaSearchResponse; -} - -export type ISearch = ( - context: RequestHandlerContext, - request: IRequestTypesMap[T], - options?: ISearchOptions -) => Promise; - -export type ISearchCancel = ( - context: RequestHandlerContext, - id: string -) => Promise; - /** * Search strategy interface contains a search method that takes in a request and returns a promise * that resolves to a response. */ -export interface ISearchStrategy { - search: ISearch; - cancel?: ISearchCancel; +export interface ISearchStrategy { + search: ( + context: RequestHandlerContext, + request: IEsSearchRequest, + options?: ISearchOptions + ) => Promise; + cancel?: (context: RequestHandlerContext, id: string) => Promise; } - -export type TRegisterSearchStrategy = ( - name: T, - searchStrategy: ISearchStrategy -) => void; - -export type TGetSearchStrategy = (name: T) => ISearchStrategy; - -export type TSearchStrategiesMap = { - [K in TStrategyTypes]?: ISearchStrategy; -}; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index b03147fc06098..8f52e540d9042 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -295,7 +295,6 @@ export const fieldFormats: { BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; - DateNanosFormat: typeof DateNanosFormat; DurationFormat: typeof DurationFormat; IpFormat: typeof IpFormat; NumberFormat: typeof NumberFormat; @@ -508,59 +507,20 @@ export class IndexPatternsFetcher { }): Promise; } -// Warning: (ae-missing-release-tag) "IRequestTypesMap" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export interface IRequestTypesMap { - // Warning: (ae-forgotten-export) The symbol "IKibanaSearchRequest" needs to be exported by the entry point index.d.ts - // - // (undocumented) - [key: string]: IKibanaSearchRequest; - // Warning: (ae-forgotten-export) The symbol "ES_SEARCH_STRATEGY" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "IEsSearchRequest" needs to be exported by the entry point index.d.ts - // - // (undocumented) - [ES_SEARCH_STRATEGY]: IEsSearchRequest; -} - -// Warning: (ae-missing-release-tag) "IResponseTypesMap" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export interface IResponseTypesMap { - // Warning: (ae-forgotten-export) The symbol "IKibanaSearchResponse" needs to be exported by the entry point index.d.ts - // - // (undocumented) - [key: string]: IKibanaSearchResponse; - // Warning: (ae-forgotten-export) The symbol "IEsSearchResponse" needs to be exported by the entry point index.d.ts - // - // (undocumented) - [ES_SEARCH_STRATEGY]: IEsSearchResponse; -} - -// Warning: (ae-forgotten-export) The symbol "RequestHandlerContext" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "ISearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type ISearch = (context: RequestHandlerContext, request: IRequestTypesMap[T], options?: ISearchOptions) => Promise; - -// Warning: (ae-missing-release-tag) "ISearchCancel" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type ISearchCancel = (context: RequestHandlerContext, id: string) => Promise; - // Warning: (ae-missing-release-tag) "ISearchOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ISearchOptions { signal?: AbortSignal; + // (undocumented) + strategy?: string; } // Warning: (ae-missing-release-tag) "ISearchSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) export interface ISearchSetup { - // Warning: (ae-forgotten-export) The symbol "TRegisterSearchStrategy" needs to be exported by the entry point index.d.ts - registerSearchStrategy: TRegisterSearchStrategy; + registerSearchStrategy: (name: string, strategy: ISearchStrategy) => void; // Warning: (ae-forgotten-export) The symbol "SearchUsage" needs to be exported by the entry point index.d.ts usage: SearchUsage; } @@ -569,18 +529,26 @@ export interface ISearchSetup { // // @public (undocumented) export interface ISearchStart { - // Warning: (ae-forgotten-export) The symbol "TGetSearchStrategy" needs to be exported by the entry point index.d.ts - getSearchStrategy: TGetSearchStrategy; + getSearchStrategy: (name: string) => ISearchStrategy; + // Warning: (ae-forgotten-export) The symbol "RequestHandlerContext" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "IKibanaSearchRequest" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "IKibanaSearchResponse" needs to be exported by the entry point index.d.ts + // + // (undocumented) + search: (context: RequestHandlerContext, request: IKibanaSearchRequest, options: ISearchOptions) => Promise; } // Warning: (ae-missing-release-tag) "ISearchStrategy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export interface ISearchStrategy { +export interface ISearchStrategy { // (undocumented) - cancel?: ISearchCancel; + cancel?: (context: RequestHandlerContext, id: string) => Promise; + // Warning: (ae-forgotten-export) The symbol "IEsSearchRequest" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "IEsSearchResponse" needs to be exported by the entry point index.d.ts + // // (undocumented) - search: ISearch; + search: (context: RequestHandlerContext, request: IEsSearchRequest, options?: ISearchOptions) => Promise; } // @public (undocumented) @@ -760,11 +728,6 @@ export interface TimeRange { to: string; } -// Warning: (ae-missing-release-tag) "TStrategyTypes" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export type TStrategyTypes = typeof ES_SEARCH_STRATEGY | string; - // Warning: (ae-missing-release-tag) "UI_SETTINGS" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -806,31 +769,30 @@ export const UI_SETTINGS: { // 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 // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:185:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:187:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:189:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:190:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:193:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:178:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:179:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:180:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:181:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:182:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:183:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index b7dd95ccba32c..42adb9d770e8a 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -19,7 +19,7 @@ import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; import { EmbeddableStateTransfer } from '.'; -import { ApplicationStart, ScopedHistory } from '../../../../../core/public'; +import { ApplicationStart } from '../../../../../core/public'; function mockHistoryState(state: unknown) { return scopedHistoryMock.create({ state }); @@ -46,10 +46,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing originating app state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp }, appendToExistingState: true, @@ -74,10 +71,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing embeddable package state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { state: { type: 'coolestType', id: '150' }, appendToExistingState: true, @@ -90,40 +84,28 @@ describe('embeddable state transfer', () => { it('can fetch an incoming originating app state', async () => { const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); }); it('returns undefined with originating app state is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toBeUndefined(); }); it('can fetch an incoming embeddable package state', async () => { const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' }); }); it('returns undefined when embeddable package is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toBeUndefined(); }); @@ -135,10 +117,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] }); expect(historyMock.replace).toHaveBeenCalledWith( expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } }) @@ -152,10 +131,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage(); expect(historyMock.location.state).toEqual({ type: 'skisEmbeddable', diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 6e93d23f8469c..fe680eff8657e 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -27,7 +27,7 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup, ManagementSectionId } from '../../management/public'; +import { ManagementSetup } from '../../management/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -64,7 +64,7 @@ export class IndexPatternManagementPlugin core: CoreSetup, { management, kibanaLegacy }: IndexPatternManagementSetupDependencies ) { - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; if (!kibanaSection) { throw new Error('`kibana` management section not found.'); diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index f48158e98ff3f..308e006b5aba0 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["kibanaLegacy", "home"], - "requiredBundles": ["kibanaReact"] + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/management/public/application.tsx b/src/plugins/management/public/application.tsx index 5d014504b8938..035f5d56e4cc7 100644 --- a/src/plugins/management/public/application.tsx +++ b/src/plugins/management/public/application.tsx @@ -20,21 +20,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { AppMountContext, AppMountParameters } from 'kibana/public'; +import { AppMountParameters } from 'kibana/public'; import { ManagementApp, ManagementAppDependencies } from './components/management_app'; export const renderApp = async ( - context: AppMountContext, { history, appBasePath, element }: AppMountParameters, dependencies: ManagementAppDependencies ) => { ReactDOM.render( - , + , element ); diff --git a/src/plugins/management/public/components/index.ts b/src/plugins/management/public/components/index.ts index 8979809c5245e..3a2a3eafb89e2 100644 --- a/src/plugins/management/public/components/index.ts +++ b/src/plugins/management/public/components/index.ts @@ -18,4 +18,3 @@ */ export { ManagementApp } from './management_app'; -export { managementSections } from './management_sections'; diff --git a/src/plugins/management/public/components/management_app/management_app.tsx b/src/plugins/management/public/components/management_app/management_app.tsx index fc5a8924c95d6..313884a90908f 100644 --- a/src/plugins/management/public/components/management_app/management_app.tsx +++ b/src/plugins/management/public/components/management_app/management_app.tsx @@ -17,36 +17,32 @@ * under the License. */ import React, { useState, useEffect, useCallback } from 'react'; -import { - AppMountContext, - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, -} from 'kibana/public'; +import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { EuiPage } from '@elastic/eui'; -import { ManagementStart } from '../../types'; import { ManagementSection, MANAGEMENT_BREADCRUMB } from '../../utils'; import { ManagementRouter } from './management_router'; import { ManagementSidebarNav } from '../management_sidebar_nav'; import { reactRouterNavigate } from '../../../../kibana_react/public'; +import { SectionsServiceStart } from '../../types'; import './management_app.scss'; interface ManagementAppProps { appBasePath: string; - context: AppMountContext; history: AppMountParameters['history']; dependencies: ManagementAppDependencies; } export interface ManagementAppDependencies { - management: ManagementStart; + sections: SectionsServiceStart; kibanaVersion: string; + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; } -export const ManagementApp = ({ context, dependencies, history }: ManagementAppProps) => { +export const ManagementApp = ({ dependencies, history }: ManagementAppProps) => { + const { setBreadcrumbs } = dependencies; const [selectedId, setSelectedId] = useState(''); const [sections, setSections] = useState(); @@ -55,24 +51,24 @@ export const ManagementApp = ({ context, dependencies, history }: ManagementAppP window.scrollTo(0, 0); }, []); - const setBreadcrumbs = useCallback( + const setBreadcrumbsScoped = useCallback( (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ ...item, ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), }); - context.core.chrome.setBreadcrumbs([ + setBreadcrumbs([ wrapBreadcrumb(MANAGEMENT_BREADCRUMB, history), ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || history)), ]); }, - [context.core.chrome, history] + [setBreadcrumbs, history] ); useEffect(() => { - setSections(dependencies.management.sections.getSectionsEnabled()); - }, [dependencies.management.sections]); + setSections(dependencies.sections.getSectionsEnabled()); + }, [dependencies.sections]); if (!sections) { return null; @@ -84,7 +80,7 @@ export const ManagementApp = ({ context, dependencies, history }: ManagementAppP ( - - - {text} +export const KibanaSection = { + id: ManagementSectionId.Kibana, + title: kibanaTitle, + tip: kibanaTip, + order: 4, +}; - - - - - -); +export const StackSection = { + id: ManagementSectionId.Stack, + title: stackTitle, + tip: stackTip, + order: 4, +}; export const managementSections = [ - { - id: ManagementSectionId.Ingest, - title: ( - - ), - }, - { - id: ManagementSectionId.Data, - title: , - }, - { - id: ManagementSectionId.InsightsAndAlerting, - title: ( - - ), - }, - { - id: ManagementSectionId.Security, - title: , - }, - { - id: ManagementSectionId.Kibana, - title: , - }, - { - id: ManagementSectionId.Stack, - title: , - }, + IngestSection, + DataSection, + InsightsAndAlertingSection, + SecuritySection, + KibanaSection, + StackSection, ]; diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx index 055dda5ed84a1..37d1167661d82 100644 --- a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -21,7 +21,15 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { sortBy } from 'lodash'; -import { EuiIcon, EuiSideNav, EuiScreenReaderOnly, EuiSideNavItemType } from '@elastic/eui'; +import { + EuiIcon, + EuiSideNav, + EuiScreenReaderOnly, + EuiSideNavItemType, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; import { AppMountParameters } from 'kibana/public'; import { ManagementApp, ManagementSection } from '../../utils'; @@ -79,6 +87,23 @@ export const ManagementSidebarNav = ({ }), })); + interface TooltipWrapperProps { + text: string; + tip?: string; + } + + const TooltipWrapper = ({ text, tip }: TooltipWrapperProps) => ( + + + {text} + + + + + + + ); + const createNavItem = ( item: T, customParams: Partial> = {} @@ -87,7 +112,7 @@ export const ManagementSidebarNav = ({ return { id: item.id, - name: item.title, + name: item.tip ? : item.title, isSelected: item.id === selectedId, icon: iconType ? : undefined, 'data-test-subj': item.id, diff --git a/src/plugins/management/public/index.ts b/src/plugins/management/public/index.ts index 3ba469c7831f6..f6c23ccf0143f 100644 --- a/src/plugins/management/public/index.ts +++ b/src/plugins/management/public/index.ts @@ -27,8 +27,8 @@ export function plugin(initializerContext: PluginInitializerContext) { export { RegisterManagementAppArgs, ManagementSection, ManagementApp } from './utils'; export { - ManagementSectionId, ManagementAppMountParams, ManagementSetup, ManagementStart, + DefinedSections, } from './types'; diff --git a/src/plugins/management/public/management_sections_service.test.ts b/src/plugins/management/public/management_sections_service.test.ts index fd56dd8a6ee27..3e0001e4ca550 100644 --- a/src/plugins/management/public/management_sections_service.test.ts +++ b/src/plugins/management/public/management_sections_service.test.ts @@ -17,8 +17,10 @@ * under the License. */ -import { ManagementSectionId } from './index'; -import { ManagementSectionsService } from './management_sections_service'; +import { + ManagementSectionsService, + getSectionsServiceStartPrivate, +} from './management_sections_service'; describe('ManagementService', () => { let managementService: ManagementSectionsService; @@ -35,15 +37,10 @@ describe('ManagementService', () => { test('Provides default sections', () => { managementService.setup(); - const start = managementService.start({ capabilities }); - - expect(start.getAllSections().length).toEqual(6); - expect(start.getSection(ManagementSectionId.Ingest)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Data)).toBeDefined(); - expect(start.getSection(ManagementSectionId.InsightsAndAlerting)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Security)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Kibana)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Stack)).toBeDefined(); + managementService.start({ capabilities }); + const start = getSectionsServiceStartPrivate(); + + expect(start.getSectionsEnabled().length).toEqual(6); }); test('Register section, enable and disable', () => { @@ -51,10 +48,11 @@ describe('ManagementService', () => { const setup = managementService.setup(); const testSection = setup.register({ id: 'test-section', title: 'Test Section' }); - expect(setup.getSection('test-section')).not.toBeUndefined(); + expect(testSection).not.toBeUndefined(); // Start phase: - const start = managementService.start({ capabilities }); + managementService.start({ capabilities }); + const start = getSectionsServiceStartPrivate(); expect(start.getSectionsEnabled().length).toEqual(7); @@ -71,7 +69,7 @@ describe('ManagementService', () => { testSection.registerApp({ id: 'test-app-2', title: 'Test App 2', mount: jest.fn() }); testSection.registerApp({ id: 'test-app-3', title: 'Test App 3', mount: jest.fn() }); - expect(setup.getSection('test-section')).not.toBeUndefined(); + expect(testSection).not.toBeUndefined(); // Start phase: managementService.start({ diff --git a/src/plugins/management/public/management_sections_service.ts b/src/plugins/management/public/management_sections_service.ts index d8d148a9247ff..b9dc2dd416d9a 100644 --- a/src/plugins/management/public/management_sections_service.ts +++ b/src/plugins/management/public/management_sections_service.ts @@ -17,22 +17,47 @@ * under the License. */ -import { ReactElement } from 'react'; import { ManagementSection, RegisterManagementSectionArgs } from './utils'; -import { managementSections } from './components/management_sections'; +import { + IngestSection, + DataSection, + InsightsAndAlertingSection, + SecuritySection, + KibanaSection, + StackSection, +} from './components/management_sections'; import { ManagementSectionId, SectionsServiceSetup, - SectionsServiceStart, SectionsServiceStartDeps, + DefinedSections, + ManagementSectionsStartPrivate, } from './types'; +import { createGetterSetter } from '../../kibana_utils/public'; + +const [getSectionsServiceStartPrivate, setSectionsServiceStartPrivate] = createGetterSetter< + ManagementSectionsStartPrivate +>('SectionsServiceStartPrivate'); + +export { getSectionsServiceStartPrivate }; export class ManagementSectionsService { - private sections: Map = new Map(); + definedSections: DefinedSections; - private getSection = (sectionId: ManagementSectionId | string) => - this.sections.get(sectionId) as ManagementSection; + constructor() { + // Note on adding sections - sections can be defined in a plugin and exported as a contract + // It is not necessary to define all sections here, although we've chose to do it for discovery reasons. + this.definedSections = { + ingest: this.registerSection(IngestSection), + data: this.registerSection(DataSection), + insightsAndAlerting: this.registerSection(InsightsAndAlertingSection), + security: this.registerSection(SecuritySection), + kibana: this.registerSection(KibanaSection), + stack: this.registerSection(StackSection), + }; + } + private sections: Map = new Map(); private getAllSections = () => [...this.sections.values()]; @@ -48,19 +73,15 @@ export class ManagementSectionsService { }; setup(): SectionsServiceSetup { - managementSections.forEach( - ({ id, title }: { id: ManagementSectionId; title: ReactElement }, idx: number) => { - this.registerSection({ id, title, order: idx }); - } - ); - return { register: this.registerSection, - getSection: this.getSection, + section: { + ...this.definedSections, + }, }; } - start({ capabilities }: SectionsServiceStartDeps): SectionsServiceStart { + start({ capabilities }: SectionsServiceStartDeps) { this.getAllSections().forEach((section) => { if (capabilities.management.hasOwnProperty(section.id)) { const sectionCapabilities = capabilities.management[section.id]; @@ -72,10 +93,10 @@ export class ManagementSectionsService { } }); - return { - getSection: this.getSection, - getAllSections: this.getAllSections, + setSectionsServiceStartPrivate({ getSectionsEnabled: () => this.getAllSections().filter((section) => section.enabled), - }; + }); + + return {}; } } diff --git a/src/plugins/management/public/mocks/index.ts b/src/plugins/management/public/mocks/index.ts index 123e3f28877aa..fbb37647dad90 100644 --- a/src/plugins/management/public/mocks/index.ts +++ b/src/plugins/management/public/mocks/index.ts @@ -17,10 +17,10 @@ * under the License. */ -import { ManagementSetup, ManagementStart } from '../types'; +import { ManagementSetup, ManagementStart, DefinedSections } from '../types'; import { ManagementSection } from '../index'; -const createManagementSectionMock = () => +export const createManagementSectionMock = () => (({ disable: jest.fn(), enable: jest.fn(), @@ -29,19 +29,22 @@ const createManagementSectionMock = () => getEnabledItems: jest.fn().mockReturnValue([]), } as unknown) as ManagementSection); -const createSetupContract = (): DeeplyMockedKeys => ({ +const createSetupContract = (): ManagementSetup => ({ sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(createManagementSectionMock()), + register: jest.fn(() => createManagementSectionMock()), + section: ({ + ingest: createManagementSectionMock(), + data: createManagementSectionMock(), + insightsAndAlerting: createManagementSectionMock(), + security: createManagementSectionMock(), + kibana: createManagementSectionMock(), + stack: createManagementSectionMock(), + } as unknown) as DefinedSections, }, }); -const createStartContract = (): DeeplyMockedKeys => ({ - sections: { - getSection: jest.fn(), - getAllSections: jest.fn(), - getSectionsEnabled: jest.fn(), - }, +const createStartContract = (): ManagementStart => ({ + sections: {}, }); export const managementPluginMock = { diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index dada4636e6add..17d8cb4adc701 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -26,9 +26,13 @@ import { Plugin, DEFAULT_APP_CATEGORIES, PluginInitializerContext, + AppMountParameters, } from '../../../core/public'; -import { ManagementSectionsService } from './management_sections_service'; +import { + ManagementSectionsService, + getSectionsServiceStartPrivate, +} from './management_sections_service'; interface ManagementSetupDependencies { home: HomePublicPluginSetup; @@ -64,13 +68,14 @@ export class ManagementPlugin implements Plugin ManagementSection[]; } export interface SectionsServiceStartDeps { @@ -36,12 +47,10 @@ export interface SectionsServiceStartDeps { export interface SectionsServiceSetup { register: (args: Omit) => ManagementSection; - getSection: (sectionId: ManagementSectionId | string) => ManagementSection; + section: DefinedSections; } export interface SectionsServiceStart { - getSection: (sectionId: ManagementSectionId | string) => ManagementSection; - getAllSections: () => ManagementSection[]; getSectionsEnabled: () => ManagementSection[]; } @@ -66,7 +75,8 @@ export interface ManagementAppMountParams { export interface CreateManagementItemArgs { id: string; - title: string | ReactElement; + title: string; + tip?: string; order?: number; euiIconType?: string; // takes precedence over `icon` property. icon?: string; // URL to image file; fallback if no `euiIconType` diff --git a/src/plugins/management/public/utils/management_item.ts b/src/plugins/management/public/utils/management_item.ts index ef0c8e4693895..e6e473c77bf61 100644 --- a/src/plugins/management/public/utils/management_item.ts +++ b/src/plugins/management/public/utils/management_item.ts @@ -16,21 +16,22 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactElement } from 'react'; import { CreateManagementItemArgs } from '../types'; export class ManagementItem { public readonly id: string = ''; - public readonly title: string | ReactElement = ''; + public readonly title: string; + public readonly tip?: string; public readonly order: number; public readonly euiIconType?: string; public readonly icon?: string; public enabled: boolean = true; - constructor({ id, title, order = 100, euiIconType, icon }: CreateManagementItemArgs) { + constructor({ id, title, tip, order = 100, euiIconType, icon }: CreateManagementItemArgs) { this.id = id; this.title = title; + this.tip = tip; this.order = order; this.euiIconType = euiIconType; this.icon = icon; diff --git a/src/plugins/maps_legacy/server/index.ts b/src/plugins/maps_legacy/server/index.ts index 18f58189fc607..5da3ce1a84408 100644 --- a/src/plugins/maps_legacy/server/index.ts +++ b/src/plugins/maps_legacy/server/index.ts @@ -17,8 +17,9 @@ * under the License. */ -import { PluginConfigDescriptor } from 'kibana/server'; -import { PluginInitializerContext } from 'kibana/public'; +import { Plugin, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'src/core/server'; +import { Observable } from 'rxjs'; import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { @@ -37,13 +38,27 @@ export const config: PluginConfigDescriptor = { schema: configSchema, }; -export const plugin = (initializerContext: PluginInitializerContext) => ({ - setup() { +export interface MapsLegacyPluginSetup { + config$: Observable; +} + +export class MapsLegacyPlugin implements Plugin { + readonly _initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this._initializerContext = initializerContext; + } + + public setup() { // @ts-ignore - const config$ = initializerContext.config.create(); + const config$ = this._initializerContext.config.create(); return { - config: config$, + config$, }; - }, - start() {}, -}); + } + + public start() {} +} + +export const plugin = (initializerContext: PluginInitializerContext) => + new MapsLegacyPlugin(initializerContext); diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 4f7a4ff7f196f..9140de316605c 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -36,6 +36,7 @@ export { isErrorNonFatal, } from './saved_object'; export { SavedObjectSaveOpts, SavedObjectKibanaServices, SavedObject } from './types'; +export { PER_PAGE_SETTING, LISTING_LIMIT_SETTING } from '../common'; export { SavedObjectsStart } from './plugin'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index f3d6318db89f2..47d445e63b942 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { ManagementSetup, ManagementSectionId } from '../../management/public'; +import { ManagementSetup } from '../../management/public'; import { DataPublicPluginStart } from '../../data/public'; import { DashboardStart } from '../../dashboard/public'; import { DiscoverStart } from '../../discover/public'; @@ -87,7 +87,7 @@ export class SavedObjectsManagementPlugin category: FeatureCatalogueCategory.ADMIN, }); - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ id: 'objects', title: i18n.translate('savedObjectsManagement.managementSectionLabel', { diff --git a/src/plugins/telemetry_collection_manager/server/plugin.ts b/src/plugins/telemetry_collection_manager/server/plugin.ts index c3db0ca39e6ac..051bb3a11cb16 100644 --- a/src/plugins/telemetry_collection_manager/server/plugin.ts +++ b/src/plugins/telemetry_collection_manager/server/plugin.ts @@ -37,7 +37,7 @@ import { UsageStatsPayload, StatsCollectionContext, } from './types'; - +import { isClusterOptedIn } from './util'; import { encryptTelemetry } from './encryption'; interface TelemetryCollectionPluginsDepsSetup { @@ -205,7 +205,9 @@ export class TelemetryCollectionManagerPlugin return usageData; } - return encryptTelemetry(usageData, { useProdKey: this.isDistributable }); + return encryptTelemetry(usageData.filter(isClusterOptedIn), { + useProdKey: this.isDistributable, + }); } } catch (err) { this.logger.debug( diff --git a/src/plugins/telemetry_collection_manager/server/util.test.ts b/src/plugins/telemetry_collection_manager/server/util.test.ts new file mode 100644 index 0000000000000..ba5d999c3bf9a --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/util.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { isClusterOptedIn } from './util'; + +const createMockClusterUsage = (plugins: any) => { + return { + stack_stats: { + kibana: { plugins }, + }, + }; +}; + +describe('isClusterOptedIn', () => { + it('returns true if cluster has opt_in_status: true', () => { + const mockClusterUsage = createMockClusterUsage({ telemetry: { opt_in_status: true } }); + const result = isClusterOptedIn(mockClusterUsage); + expect(result).toBe(true); + }); + it('returns false if cluster has opt_in_status: false', () => { + const mockClusterUsage = createMockClusterUsage({ telemetry: { opt_in_status: false } }); + const result = isClusterOptedIn(mockClusterUsage); + expect(result).toBe(false); + }); + it('returns false if cluster has opt_in_status: undefined', () => { + const mockClusterUsage = createMockClusterUsage({ telemetry: {} }); + const result = isClusterOptedIn(mockClusterUsage); + expect(result).toBe(false); + }); + it('returns false if cluster stats is malformed', () => { + expect(isClusterOptedIn(createMockClusterUsage({}))).toBe(false); + expect(isClusterOptedIn({})).toBe(false); + expect(isClusterOptedIn(undefined)).toBe(false); + }); +}); diff --git a/src/plugins/telemetry_collection_manager/server/util.ts b/src/plugins/telemetry_collection_manager/server/util.ts new file mode 100644 index 0000000000000..d6e1b51663688 --- /dev/null +++ b/src/plugins/telemetry_collection_manager/server/util.ts @@ -0,0 +1,22 @@ +/* + * 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 const isClusterOptedIn = (clusterUsage: any): boolean => { + return clusterUsage?.stack_stats?.kibana?.plugins?.telemetry?.opt_in_status === true; +}; diff --git a/src/plugins/tile_map/public/plugin.ts b/src/plugins/tile_map/public/plugin.ts index 1f79104b183ee..4582cd2283dc1 100644 --- a/src/plugins/tile_map/public/plugin.ts +++ b/src/plugins/tile_map/public/plugin.ts @@ -32,7 +32,7 @@ import 'angular-sanitize'; import { createTileMapFn } from './tile_map_fn'; // @ts-ignore import { createTileMapTypeDefinition } from './tile_map_type'; -import { MapsLegacyPluginSetup } from '../../maps_legacy/public'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { DataPublicPluginStart } from '../../data/public'; import { setFormatService, setQueryService } from './services'; import { setKibanaLegacy } from './services'; @@ -48,6 +48,7 @@ interface TileMapVisualizationDependencies { getZoomPrecision: any; getPrecision: any; BaseMapsVisualization: any; + serviceSettings: IServiceSettings; } /** @internal */ @@ -81,12 +82,13 @@ export class TileMapPlugin implements Plugin = { getZoomPrecision, getPrecision, BaseMapsVisualization: mapsLegacy.getBaseMapsVis(), uiSettings: core.uiSettings, + serviceSettings, }; expressions.registerFunction(() => createTileMapFn(visualizationDependencies)); diff --git a/src/plugins/timelion/kibana.json b/src/plugins/timelion/kibana.json index 55e492e8f23cd..d8c709d867a3f 100644 --- a/src/plugins/timelion/kibana.json +++ b/src/plugins/timelion/kibana.json @@ -1,8 +1,19 @@ { "id": "timelion", - "version": "0.0.1", - "kibanaVersion": "kibana", - "configPath": "timelion", - "ui": false, - "server": true + "version": "kibana", + "ui": true, + "server": true, + "requiredBundles": [ + "kibanaLegacy", + "kibanaUtils", + "savedObjects", + "visTypeTimelion" + ], + "requiredPlugins": [ + "visualizations", + "data", + "navigation", + "visTypeTimelion", + "kibanaLegacy" + ] } diff --git a/src/legacy/core_plugins/timelion/public/_app.scss b/src/plugins/timelion/public/_app.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/_app.scss rename to src/plugins/timelion/public/_app.scss diff --git a/src/plugins/timelion/public/app.js b/src/plugins/timelion/public/app.js new file mode 100644 index 0000000000000..0294e71084f98 --- /dev/null +++ b/src/plugins/timelion/public/app.js @@ -0,0 +1,661 @@ +/* + * 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'; + +import { createHashHistory } from 'history'; + +import { createKbnUrlStateStorage } from '../../kibana_utils/public'; +import { syncQueryStateWithUrl } from '../../data/public'; + +import { getSavedSheetBreadcrumbs, getCreateBreadcrumbs } from './breadcrumbs'; +import { + addFatalError, + registerListenEventListener, + watchMultiDecorator, +} from '../../kibana_legacy/public'; +import { getTimezone } from '../../vis_type_timelion/public'; +import { initCellsDirective } from './directives/cells/cells'; +import { initFullscreenDirective } from './directives/fullscreen/fullscreen'; +import { initFixedElementDirective } from './directives/fixed_element'; +import { initTimelionLoadSheetDirective } from './directives/timelion_load_sheet'; +import { initTimelionHelpDirective } from './directives/timelion_help/timelion_help'; +import { initTimelionSaveSheetDirective } from './directives/timelion_save_sheet'; +import { initTimelionOptionsSheetDirective } from './directives/timelion_options_sheet'; +import { initSavedObjectSaveAsCheckBoxDirective } from './directives/saved_object_save_as_checkbox'; +import { initSavedObjectFinderDirective } from './directives/saved_object_finder'; +import { initTimelionTabsDirective } from './components/timelionhelp_tabs_directive'; +import { initInputFocusDirective } from './directives/input_focus'; +import { Chart } from './directives/chart/chart'; +import { TimelionInterval } from './directives/timelion_interval/timelion_interval'; +import { timelionExpInput } from './directives/timelion_expression_input'; +import { TimelionExpressionSuggestions } from './directives/timelion_expression_suggestions/timelion_expression_suggestions'; +import { initSavedSheetService } from './services/saved_sheets'; +import { initTimelionAppState } from './timelion_app_state'; + +import rootTemplate from './index.html'; + +export function initTimelionApp(app, deps) { + app.run(registerListenEventListener); + + const savedSheetLoader = initSavedSheetService(app, deps); + + app.factory('history', () => createHashHistory()); + app.factory('kbnUrlStateStorage', (history) => + createKbnUrlStateStorage({ + history, + useHash: deps.core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + app.config(watchMultiDecorator); + + app + .controller('TimelionVisController', function ($scope) { + $scope.$on('timelionChartRendered', (event) => { + event.stopPropagation(); + $scope.renderComplete(); + }); + }) + .constant('timelionPanels', deps.timelionPanels) + .directive('chart', Chart) + .directive('timelionInterval', TimelionInterval) + .directive('timelionExpressionSuggestions', TimelionExpressionSuggestions) + .directive('timelionExpressionInput', timelionExpInput(deps)); + + initTimelionHelpDirective(app); + initInputFocusDirective(app); + initTimelionTabsDirective(app, deps); + initSavedObjectFinderDirective(app, savedSheetLoader, deps.core.uiSettings); + initSavedObjectSaveAsCheckBoxDirective(app); + initCellsDirective(app); + initFixedElementDirective(app); + initFullscreenDirective(app); + initTimelionSaveSheetDirective(app); + initTimelionLoadSheetDirective(app); + initTimelionOptionsSheetDirective(app); + + const location = 'Timelion'; + + app.directive('timelionApp', function () { + return { + restrict: 'E', + controllerAs: 'timelionApp', + controller: timelionController, + }; + }); + + function timelionController( + $http, + $route, + $routeParams, + $scope, + $timeout, + history, + kbnUrlStateStorage + ) { + // Keeping this at app scope allows us to keep the current page when the user + // switches to say, the timepicker. + $scope.page = deps.core.uiSettings.get('timelion:showTutorial', true) ? 1 : 0; + $scope.setPage = (page) => ($scope.page = page); + const timefilter = deps.plugins.data.query.timefilter.timefilter; + + timefilter.enableAutoRefreshSelector(); + timefilter.enableTimeRangeSelector(); + + deps.core.chrome.docTitle.change('Timelion - Kibana'); + + // starts syncing `_g` portion of url with query services + const { stop: stopSyncingQueryServiceStateWithUrl } = syncQueryStateWithUrl( + deps.plugins.data.query, + kbnUrlStateStorage + ); + + const savedSheet = $route.current.locals.savedSheet; + + function getStateDefaults() { + return { + sheet: savedSheet.timelion_sheet, + selected: 0, + columns: savedSheet.timelion_columns, + rows: savedSheet.timelion_rows, + interval: savedSheet.timelion_interval, + }; + } + + const { stateContainer, stopStateSync } = initTimelionAppState({ + stateDefaults: getStateDefaults(), + kbnUrlStateStorage, + }); + + $scope.state = _.cloneDeep(stateContainer.getState()); + $scope.expression = _.clone($scope.state.sheet[$scope.state.selected]); + $scope.updatedSheets = []; + + const savedVisualizations = deps.plugins.visualizations.savedVisualizationsLoader; + const timezone = getTimezone(deps.core.uiSettings); + + const defaultExpression = '.es(*)'; + + $scope.topNavMenu = getTopNavMenu(); + + $timeout(function () { + if (deps.core.uiSettings.get('timelion:showTutorial', true)) { + $scope.toggleMenu('showHelp'); + } + }, 0); + + $scope.transient = {}; + + function getTopNavMenu() { + const newSheetAction = { + id: 'new', + label: i18n.translate('timelion.topNavMenu.newSheetButtonLabel', { + defaultMessage: 'New', + }), + description: i18n.translate('timelion.topNavMenu.newSheetButtonAriaLabel', { + defaultMessage: 'New Sheet', + }), + run: function () { + history.push('/'); + $route.reload(); + }, + testId: 'timelionNewButton', + }; + + const addSheetAction = { + id: 'add', + label: i18n.translate('timelion.topNavMenu.addChartButtonLabel', { + defaultMessage: 'Add', + }), + description: i18n.translate('timelion.topNavMenu.addChartButtonAriaLabel', { + defaultMessage: 'Add a chart', + }), + run: function () { + $scope.$evalAsync(() => $scope.newCell()); + }, + testId: 'timelionAddChartButton', + }; + + const saveSheetAction = { + id: 'save', + label: i18n.translate('timelion.topNavMenu.saveSheetButtonLabel', { + defaultMessage: 'Save', + }), + description: i18n.translate('timelion.topNavMenu.saveSheetButtonAriaLabel', { + defaultMessage: 'Save Sheet', + }), + run: () => { + $scope.$evalAsync(() => $scope.toggleMenu('showSave')); + }, + testId: 'timelionSaveButton', + }; + + const deleteSheetAction = { + id: 'delete', + label: i18n.translate('timelion.topNavMenu.deleteSheetButtonLabel', { + defaultMessage: 'Delete', + }), + description: i18n.translate('timelion.topNavMenu.deleteSheetButtonAriaLabel', { + defaultMessage: 'Delete current sheet', + }), + disableButton: function () { + return !savedSheet.id; + }, + run: function () { + const title = savedSheet.title; + function doDelete() { + savedSheet + .delete() + .then(() => { + deps.core.notifications.toasts.addSuccess( + i18n.translate('timelion.topNavMenu.delete.modal.successNotificationText', { + defaultMessage: `Deleted '{title}'`, + values: { title }, + }) + ); + history.push('/'); + }) + .catch((error) => addFatalError(deps.core.fatalErrors, error, location)); + } + + const confirmModalOptions = { + confirmButtonText: i18n.translate( + 'timelion.topNavMenu.delete.modal.confirmButtonLabel', + { + defaultMessage: 'Delete', + } + ), + title: i18n.translate('timelion.topNavMenu.delete.modalTitle', { + defaultMessage: `Delete Timelion sheet '{title}'?`, + values: { title }, + }), + }; + + $scope.$evalAsync(() => { + deps.core.overlays + .openConfirm( + i18n.translate('timelion.topNavMenu.delete.modal.warningText', { + defaultMessage: `You can't recover deleted sheets.`, + }), + confirmModalOptions + ) + .then((isConfirmed) => { + if (isConfirmed) { + doDelete(); + } + }); + }); + }, + testId: 'timelionDeleteButton', + }; + + const openSheetAction = { + id: 'open', + label: i18n.translate('timelion.topNavMenu.openSheetButtonLabel', { + defaultMessage: 'Open', + }), + description: i18n.translate('timelion.topNavMenu.openSheetButtonAriaLabel', { + defaultMessage: 'Open Sheet', + }), + run: () => { + $scope.$evalAsync(() => $scope.toggleMenu('showLoad')); + }, + testId: 'timelionOpenButton', + }; + + const optionsAction = { + id: 'options', + label: i18n.translate('timelion.topNavMenu.optionsButtonLabel', { + defaultMessage: 'Options', + }), + description: i18n.translate('timelion.topNavMenu.optionsButtonAriaLabel', { + defaultMessage: 'Options', + }), + run: () => { + $scope.$evalAsync(() => $scope.toggleMenu('showOptions')); + }, + testId: 'timelionOptionsButton', + }; + + const helpAction = { + id: 'help', + label: i18n.translate('timelion.topNavMenu.helpButtonLabel', { + defaultMessage: 'Help', + }), + description: i18n.translate('timelion.topNavMenu.helpButtonAriaLabel', { + defaultMessage: 'Help', + }), + run: () => { + $scope.$evalAsync(() => $scope.toggleMenu('showHelp')); + }, + testId: 'timelionDocsButton', + }; + + if (deps.core.application.capabilities.timelion.save) { + return [ + newSheetAction, + addSheetAction, + saveSheetAction, + deleteSheetAction, + openSheetAction, + optionsAction, + helpAction, + ]; + } + return [newSheetAction, addSheetAction, openSheetAction, optionsAction, helpAction]; + } + + let refresher; + const setRefreshData = function () { + if (refresher) $timeout.cancel(refresher); + const interval = timefilter.getRefreshInterval(); + if (interval.value > 0 && !interval.pause) { + function startRefresh() { + refresher = $timeout(function () { + if (!$scope.running) $scope.search(); + startRefresh(); + }, interval.value); + } + startRefresh(); + } + }; + + const init = function () { + $scope.running = false; + $scope.search(); + setRefreshData(); + + $scope.model = { + timeRange: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), + }; + + const unsubscribeStateUpdates = stateContainer.subscribe((state) => { + const clonedState = _.cloneDeep(state); + $scope.updatedSheets.forEach((updatedSheet) => { + clonedState.sheet[updatedSheet.id] = updatedSheet.expression; + }); + $scope.state = clonedState; + $scope.opts.state = clonedState; + $scope.expression = _.clone($scope.state.sheet[$scope.state.selected]); + $scope.search(); + }); + + timefilter.getFetch$().subscribe($scope.search); + + $scope.opts = { + saveExpression: saveExpression, + saveSheet: saveSheet, + savedSheet: savedSheet, + state: _.cloneDeep(stateContainer.getState()), + search: $scope.search, + dontShowHelp: function () { + deps.core.uiSettings.set('timelion:showTutorial', false); + $scope.setPage(0); + $scope.closeMenus(); + }, + }; + + $scope.$watch('opts.state.rows', function (newRow) { + const state = stateContainer.getState(); + if (state.rows !== newRow) { + stateContainer.transitions.set('rows', newRow); + } + }); + + $scope.$watch('opts.state.columns', function (newColumn) { + const state = stateContainer.getState(); + if (state.columns !== newColumn) { + stateContainer.transitions.set('columns', newColumn); + } + }); + + $scope.menus = { + showHelp: false, + showSave: false, + showLoad: false, + showOptions: false, + }; + + $scope.toggleMenu = (menuName) => { + const curState = $scope.menus[menuName]; + $scope.closeMenus(); + $scope.menus[menuName] = !curState; + }; + + $scope.closeMenus = () => { + _.forOwn($scope.menus, function (value, key) { + $scope.menus[key] = false; + }); + }; + + $scope.$on('$destroy', () => { + stopSyncingQueryServiceStateWithUrl(); + unsubscribeStateUpdates(); + stopStateSync(); + }); + }; + + $scope.onTimeUpdate = function ({ dateRange }) { + $scope.model.timeRange = { + ...dateRange, + }; + timefilter.setTime(dateRange); + if (!$scope.running) $scope.search(); + }; + + $scope.onRefreshChange = function ({ isPaused, refreshInterval }) { + $scope.model.refreshInterval = { + pause: isPaused, + value: refreshInterval, + }; + timefilter.setRefreshInterval({ + pause: isPaused, + value: refreshInterval ? refreshInterval : $scope.refreshInterval.value, + }); + + setRefreshData(); + }; + + $scope.$watch( + function () { + return savedSheet.lastSavedTitle; + }, + function (newTitle) { + if (savedSheet.id && newTitle) { + deps.core.chrome.docTitle.change(newTitle); + } + } + ); + + $scope.$watch('expression', function (newExpression) { + const state = stateContainer.getState(); + if (state.sheet[state.selected] !== newExpression) { + const updatedSheet = $scope.updatedSheets.find( + (updatedSheet) => updatedSheet.id === state.selected + ); + if (updatedSheet) { + updatedSheet.expression = newExpression; + } else { + $scope.updatedSheets.push({ + id: state.selected, + expression: newExpression, + }); + } + } + }); + + $scope.toggle = function (property) { + $scope[property] = !$scope[property]; + }; + + $scope.changeInterval = function (interval) { + $scope.currentInterval = interval; + }; + + $scope.updateChart = function () { + const state = stateContainer.getState(); + const newSheet = _.clone(state.sheet); + if ($scope.updatedSheets.length) { + $scope.updatedSheets.forEach((updatedSheet) => { + newSheet[updatedSheet.id] = updatedSheet.expression; + }); + $scope.updatedSheets = []; + } + stateContainer.transitions.updateState({ + interval: $scope.currentInterval ? $scope.currentInterval : state.interval, + sheet: newSheet, + }); + }; + + $scope.newSheet = function () { + history.push('/'); + }; + + $scope.removeSheet = function (removedIndex) { + const state = stateContainer.getState(); + const newSheet = state.sheet.filter((el, index) => index !== removedIndex); + $scope.updatedSheets = $scope.updatedSheets.filter((el) => el.id !== removedIndex); + stateContainer.transitions.updateState({ + sheet: newSheet, + selected: removedIndex ? removedIndex - 1 : removedIndex, + }); + }; + + $scope.newCell = function () { + const state = stateContainer.getState(); + const newSheet = [...state.sheet, defaultExpression]; + stateContainer.transitions.updateState({ sheet: newSheet, selected: newSheet.length - 1 }); + }; + + $scope.setActiveCell = function (cell) { + const state = stateContainer.getState(); + if (state.selected !== cell) { + stateContainer.transitions.updateState({ sheet: $scope.state.sheet, selected: cell }); + } + }; + + $scope.search = function () { + $scope.running = true; + const state = stateContainer.getState(); + + // parse the time range client side to make sure it behaves like other charts + const timeRangeBounds = timefilter.getBounds(); + + const httpResult = $http + .post('../api/timelion/run', { + sheet: state.sheet, + time: _.assignIn( + { + from: timeRangeBounds.min, + to: timeRangeBounds.max, + }, + { + interval: state.interval, + timezone: timezone, + } + ), + }) + .then((resp) => resp.data) + .catch((resp) => { + throw resp.data; + }); + + httpResult + .then(function (resp) { + $scope.stats = resp.stats; + $scope.sheet = resp.sheet; + _.forEach(resp.sheet, function (cell) { + if (cell.exception && cell.plot !== state.selected) { + stateContainer.transitions.set('selected', cell.plot); + } + }); + $scope.running = false; + }) + .catch(function (resp) { + $scope.sheet = []; + $scope.running = false; + + const err = new Error(resp.message); + err.stack = resp.stack; + deps.core.notifications.toasts.addError(err, { + title: i18n.translate('timelion.searchErrorTitle', { + defaultMessage: 'Timelion request error', + }), + }); + }); + }; + + $scope.safeSearch = _.debounce($scope.search, 500); + + function saveSheet() { + const state = stateContainer.getState(); + savedSheet.timelion_sheet = state.sheet; + savedSheet.timelion_interval = state.interval; + savedSheet.timelion_columns = state.columns; + savedSheet.timelion_rows = state.rows; + savedSheet.save().then(function (id) { + if (id) { + deps.core.notifications.toasts.addSuccess({ + title: i18n.translate('timelion.saveSheet.successNotificationText', { + defaultMessage: `Saved sheet '{title}'`, + values: { title: savedSheet.title }, + }), + 'data-test-subj': 'timelionSaveSuccessToast', + }); + + if (savedSheet.id !== $routeParams.id) { + history.push(`/${savedSheet.id}`); + } + } + }); + } + + async function saveExpression(title) { + const vis = await deps.plugins.visualizations.createVis('timelion', { + title, + params: { + expression: $scope.state.sheet[$scope.state.selected], + interval: $scope.state.interval, + }, + }); + const state = deps.plugins.visualizations.convertFromSerializedVis(vis.serialize()); + const visSavedObject = await savedVisualizations.get(); + Object.assign(visSavedObject, state); + const id = await visSavedObject.save(); + if (id) { + deps.core.notifications.toasts.addSuccess( + i18n.translate('timelion.saveExpression.successNotificationText', { + defaultMessage: `Saved expression '{title}'`, + values: { title: state.title }, + }) + ); + } + } + + init(); + } + + app.config(function ($routeProvider) { + $routeProvider + .when('/:id?', { + template: rootTemplate, + reloadOnSearch: false, + k7Breadcrumbs: ($injector, $route) => + $injector.invoke( + $route.current.params.id ? getSavedSheetBreadcrumbs : getCreateBreadcrumbs + ), + badge: () => { + if (deps.core.application.capabilities.timelion.save) { + return undefined; + } + + return { + text: i18n.translate('timelion.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('timelion.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save Timelion sheets', + }), + iconType: 'glasses', + }; + }, + resolve: { + savedSheet: function (savedSheets, $route) { + return savedSheets + .get($route.current.params.id) + .then((savedSheet) => { + if ($route.current.params.id) { + deps.core.chrome.recentlyAccessed.add( + savedSheet.getFullPath(), + savedSheet.title, + savedSheet.id + ); + } + return savedSheet; + }) + .catch(); + }, + }, + }) + .otherwise('/'); + }); +} diff --git a/src/plugins/timelion/public/application.ts b/src/plugins/timelion/public/application.ts new file mode 100644 index 0000000000000..a398106d56f58 --- /dev/null +++ b/src/plugins/timelion/public/application.ts @@ -0,0 +1,153 @@ +/* + * 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 './index.scss'; + +import { EuiIcon } from '@elastic/eui'; +import angular, { IModule } from 'angular'; +// required for `ngSanitize` angular module +import 'angular-sanitize'; +// required for ngRoute +import 'angular-route'; +import 'angular-sortable-view'; +import { i18nDirective, i18nFilter, I18nProvider } from '@kbn/i18n/angular'; +import { + IUiSettingsClient, + CoreStart, + PluginInitializerContext, + AppMountParameters, +} from 'kibana/public'; +import { getTimeChart } from './panels/timechart/timechart'; +import { Panel } from './panels/panel'; + +import { + configureAppAngularModule, + createTopNavDirective, + createTopNavHelper, +} from '../../kibana_legacy/public'; +import { TimelionPluginDependencies } from './plugin'; +import { DataPublicPluginStart } from '../../data/public'; +// @ts-ignore +import { initTimelionApp } from './app'; + +export interface RenderDeps { + pluginInitializerContext: PluginInitializerContext; + mountParams: AppMountParameters; + core: CoreStart; + plugins: TimelionPluginDependencies; + timelionPanels: Map; +} + +export interface TimelionVisualizationDependencies { + uiSettings: IUiSettingsClient; + timelionPanels: Map; + data: DataPublicPluginStart; + $rootScope: any; + $compile: any; +} + +let angularModuleInstance: IModule | null = null; + +export const renderApp = (deps: RenderDeps) => { + if (!angularModuleInstance) { + angularModuleInstance = createLocalAngularModule(deps); + // global routing stuff + configureAppAngularModule( + angularModuleInstance, + { core: deps.core, env: deps.pluginInitializerContext.env }, + true + ); + initTimelionApp(angularModuleInstance, deps); + } + + const $injector = mountTimelionApp(deps.mountParams.appBasePath, deps.mountParams.element, deps); + + return () => { + $injector.get('$rootScope').$destroy(); + }; +}; + +function registerPanels(dependencies: TimelionVisualizationDependencies) { + const timeChartPanel: Panel = getTimeChart(dependencies); + + dependencies.timelionPanels.set(timeChartPanel.name, timeChartPanel); +} + +const mainTemplate = (basePath: string) => `
+ +
`; + +const moduleName = 'app/timelion'; + +const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react', 'angular-sortable-view']; + +function mountTimelionApp(appBasePath: string, element: HTMLElement, deps: RenderDeps) { + const mountpoint = document.createElement('div'); + mountpoint.setAttribute('class', 'timelionAppContainer'); + // eslint-disable-next-line + mountpoint.innerHTML = mainTemplate(appBasePath); + // bootstrap angular into detached element and attach it later to + // make angular-within-angular possible + const $injector = angular.bootstrap(mountpoint, [moduleName]); + + registerPanels({ + uiSettings: deps.core.uiSettings, + timelionPanels: deps.timelionPanels, + data: deps.plugins.data, + $rootScope: $injector.get('$rootScope'), + $compile: $injector.get('$compile'), + }); + element.appendChild(mountpoint); + return $injector; +} + +function createLocalAngularModule(deps: RenderDeps) { + createLocalI18nModule(); + createLocalIconModule(); + createLocalTopNavModule(deps.plugins.navigation); + + const dashboardAngularModule = angular.module(moduleName, [ + ...thirdPartyAngularDependencies, + 'app/timelion/TopNav', + 'app/timelion/I18n', + 'app/timelion/icon', + ]); + return dashboardAngularModule; +} + +function createLocalIconModule() { + angular + .module('app/timelion/icon', ['react']) + .directive('icon', (reactDirective) => reactDirective(EuiIcon)); +} + +function createLocalTopNavModule(navigation: TimelionPluginDependencies['navigation']) { + angular + .module('app/timelion/TopNav', ['react']) + .directive('kbnTopNav', createTopNavDirective) + .directive('kbnTopNavHelper', createTopNavHelper(navigation.ui)); +} + +function createLocalI18nModule() { + angular + .module('app/timelion/I18n', []) + .provider('i18n', I18nProvider) + .filter('i18n', i18nFilter) + .directive('i18nId', i18nDirective); +} diff --git a/src/legacy/core_plugins/timelion/public/breadcrumbs.js b/src/plugins/timelion/public/breadcrumbs.js similarity index 100% rename from src/legacy/core_plugins/timelion/public/breadcrumbs.js rename to src/plugins/timelion/public/breadcrumbs.js diff --git a/src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs.js b/src/plugins/timelion/public/components/timelionhelp_tabs.js similarity index 95% rename from src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs.js rename to src/plugins/timelion/public/components/timelionhelp_tabs.js index 639bd7d65a19e..7939afce412e1 100644 --- a/src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs.js +++ b/src/plugins/timelion/public/components/timelionhelp_tabs.js @@ -54,6 +54,6 @@ export function TimelionHelpTabs(props) { } TimelionHelpTabs.propTypes = { - activeTab: PropTypes.string.isRequired, - activateTab: PropTypes.func.isRequired, + activeTab: PropTypes.string, + activateTab: PropTypes.func, }; diff --git a/src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.js b/src/plugins/timelion/public/components/timelionhelp_tabs_directive.js similarity index 56% rename from src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.js rename to src/plugins/timelion/public/components/timelionhelp_tabs_directive.js index 5c4bd72ceb708..67e0d595314f6 100644 --- a/src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.js +++ b/src/plugins/timelion/public/components/timelionhelp_tabs_directive.js @@ -17,23 +17,27 @@ * under the License. */ -require('angular-sortable-view'); -require('plugins/timelion/directives/chart/chart'); -require('plugins/timelion/directives/timelion_grid'); +import React from 'react'; +import { TimelionHelpTabs } from './timelionhelp_tabs'; -const app = require('ui/modules').get('apps/timelion', ['angular-sortable-view']); -import html from './fullscreen.html'; - -app.directive('timelionFullscreen', function () { - return { - restrict: 'E', - scope: { - expression: '=', - series: '=', - state: '=', - transient: '=', - onSearch: '=', - }, - template: html, - }; -}); +export function initTimelionTabsDirective(app, deps) { + app.directive('timelionHelpTabs', function (reactDirective) { + return reactDirective( + (props) => { + return ( + + + + ); + }, + [['activeTab'], ['activateTab', { watchDepth: 'reference' }]], + { + restrict: 'E', + scope: { + activeTab: '=', + activateTab: '=', + }, + } + ); + }); +} diff --git a/src/legacy/core_plugins/timelion/public/directives/_index.scss b/src/plugins/timelion/public/directives/_index.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/_index.scss rename to src/plugins/timelion/public/directives/_index.scss diff --git a/src/legacy/core_plugins/timelion/public/directives/_timelion_expression_input.scss b/src/plugins/timelion/public/directives/_timelion_expression_input.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/_timelion_expression_input.scss rename to src/plugins/timelion/public/directives/_timelion_expression_input.scss diff --git a/src/legacy/core_plugins/timelion/public/directives/cells/_cells.scss b/src/plugins/timelion/public/directives/cells/_cells.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/cells/_cells.scss rename to src/plugins/timelion/public/directives/cells/_cells.scss diff --git a/src/legacy/core_plugins/timelion/public/directives/cells/_index.scss b/src/plugins/timelion/public/directives/cells/_index.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/cells/_index.scss rename to src/plugins/timelion/public/directives/cells/_index.scss diff --git a/src/legacy/core_plugins/timelion/public/directives/cells/cells.html b/src/plugins/timelion/public/directives/cells/cells.html similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/cells/cells.html rename to src/plugins/timelion/public/directives/cells/cells.html diff --git a/src/legacy/core_plugins/timelion/public/shim/legacy_dependencies_plugin.ts b/src/plugins/timelion/public/directives/cells/cells.js similarity index 50% rename from src/legacy/core_plugins/timelion/public/shim/legacy_dependencies_plugin.ts rename to src/plugins/timelion/public/directives/cells/cells.js index f6c329d417f2b..36a1e80dd470e 100644 --- a/src/legacy/core_plugins/timelion/public/shim/legacy_dependencies_plugin.ts +++ b/src/plugins/timelion/public/directives/cells/cells.js @@ -17,31 +17,36 @@ * under the License. */ -import chrome from 'ui/chrome'; -import { CoreSetup, Plugin } from 'kibana/public'; -import { initTimelionLegacyModule } from './timelion_legacy_module'; -import { Panel } from '../panels/panel'; +import { move } from './collection'; +import { initTimelionGridDirective } from '../timelion_grid'; -/** @internal */ -export interface LegacyDependenciesPluginSetup { - $rootScope: any; - $compile: any; -} - -export class LegacyDependenciesPlugin - implements Plugin, void> { - public async setup(core: CoreSetup, timelionPanels: Map) { - initTimelionLegacyModule(timelionPanels); +import html from './cells.html'; - const $injector = await chrome.dangerouslyGetActiveInjector(); +export function initCellsDirective(app) { + initTimelionGridDirective(app); + app.directive('timelionCells', function () { return { - $rootScope: $injector.get('$rootScope'), - $compile: $injector.get('$compile'), - } as LegacyDependenciesPluginSetup; - } + restrict: 'E', + scope: { + sheet: '=', + state: '=', + transient: '=', + onSearch: '=', + onSelect: '=', + onRemoveSheet: '=', + }, + template: html, + link: function ($scope) { + $scope.removeCell = function (index) { + $scope.onRemoveSheet(index); + }; - public start() { - // nothing to do here yet - } + $scope.dropCell = function (item, partFrom, partTo, indexFrom, indexTo) { + move($scope.sheet, indexFrom, indexTo); + $scope.onSelect(indexTo); + }; + }, + }; + }); } diff --git a/src/plugins/timelion/public/directives/cells/collection.ts b/src/plugins/timelion/public/directives/cells/collection.ts new file mode 100644 index 0000000000000..b882a2bbe6e5b --- /dev/null +++ b/src/plugins/timelion/public/directives/cells/collection.ts @@ -0,0 +1,76 @@ +/* + * 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'; + +/** + * move an obj either up or down in the collection by + * injecting it either before/after the prev/next obj that + * satisfied the qualifier + * + * or, just from one index to another... + * + * @param {array} objs - the list to move the object within + * @param {number|any} obj - the object that should be moved, or the index that the object is currently at + * @param {number|boolean} below - the index to move the object to, or whether it should be moved up or down + * @param {function} qualifier - a lodash-y callback, object = _.where, string = _.pluck + * @return {array} - the objs argument + */ +export function move( + objs: any[], + obj: object | number, + below: number | boolean, + qualifier?: ((object: object, index: number) => any) | Record | string +): object[] { + const origI = _.isNumber(obj) ? obj : objs.indexOf(obj); + if (origI === -1) { + return objs; + } + + if (_.isNumber(below)) { + // move to a specific index + objs.splice(below, 0, objs.splice(origI, 1)[0]); + return objs; + } + + below = !!below; + qualifier = qualifier && _.iteratee(qualifier); + + const above = !below; + const finder = below ? _.findIndex : _.findLastIndex; + + // find the index of the next/previous obj that meets the qualifications + const targetI = finder(objs, (otherAgg, otherI) => { + if (below && otherI <= origI) { + return; + } + if (above && otherI >= origI) { + return; + } + return Boolean(_.isFunction(qualifier) && qualifier(otherAgg, otherI)); + }); + + if (targetI === -1) { + return objs; + } + + // place the obj at it's new index + objs.splice(targetI, 0, objs.splice(origI, 1)[0]); + return objs; +} diff --git a/src/legacy/core_plugins/timelion/public/directives/chart/chart.js b/src/plugins/timelion/public/directives/chart/chart.js similarity index 100% rename from src/legacy/core_plugins/timelion/public/directives/chart/chart.js rename to src/plugins/timelion/public/directives/chart/chart.js diff --git a/src/plugins/timelion/public/directives/fixed_element.js b/src/plugins/timelion/public/directives/fixed_element.js new file mode 100644 index 0000000000000..f57c391e7fcda --- /dev/null +++ b/src/plugins/timelion/public/directives/fixed_element.js @@ -0,0 +1,50 @@ +/* + * 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 'jquery'; + +export function initFixedElementDirective(app) { + app.directive('fixedElementRoot', function () { + return { + restrict: 'A', + link: function ($elem) { + let fixedAt; + $(window).bind('scroll', function () { + const fixed = $('[fixed-element]', $elem); + const body = $('[fixed-element-body]', $elem); + const top = fixed.offset().top; + + if ($(window).scrollTop() > top) { + // This is a gross hack, but its better than it was. I guess + fixedAt = $(window).scrollTop(); + fixed.addClass(fixed.attr('fixed-element')); + body.addClass(fixed.attr('fixed-element-body')); + body.css({ top: fixed.height() }); + } + + if ($(window).scrollTop() < fixedAt) { + fixed.removeClass(fixed.attr('fixed-element')); + body.removeClass(fixed.attr('fixed-element-body')); + body.removeAttr('style'); + } + }); + }, + }; + }); +} diff --git a/src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.html b/src/plugins/timelion/public/directives/fullscreen/fullscreen.html similarity index 85% rename from src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.html rename to src/plugins/timelion/public/directives/fullscreen/fullscreen.html index 325c7eabb2b03..194596ba79d0e 100644 --- a/src/legacy/core_plugins/timelion/public/directives/fullscreen/fullscreen.html +++ b/src/plugins/timelion/public/directives/fullscreen/fullscreen.html @@ -1,5 +1,5 @@
-
+
diff --git a/src/legacy/core_plugins/timelion/public/index.scss b/src/plugins/timelion/public/index.scss similarity index 100% rename from src/legacy/core_plugins/timelion/public/index.scss rename to src/plugins/timelion/public/index.scss diff --git a/src/legacy/core_plugins/timelion/public/index.ts b/src/plugins/timelion/public/index.ts similarity index 100% rename from src/legacy/core_plugins/timelion/public/index.ts rename to src/plugins/timelion/public/index.ts diff --git a/src/legacy/core_plugins/timelion/public/lib/observe_resize.js b/src/plugins/timelion/public/lib/observe_resize.js similarity index 100% rename from src/legacy/core_plugins/timelion/public/lib/observe_resize.js rename to src/plugins/timelion/public/lib/observe_resize.js diff --git a/src/legacy/core_plugins/timelion/public/panels/panel.ts b/src/plugins/timelion/public/panels/panel.ts similarity index 100% rename from src/legacy/core_plugins/timelion/public/panels/panel.ts rename to src/plugins/timelion/public/panels/panel.ts diff --git a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts b/src/plugins/timelion/public/panels/timechart/schema.ts similarity index 93% rename from src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts rename to src/plugins/timelion/public/panels/timechart/schema.ts index 087e166925327..b56d8a66110c2 100644 --- a/src/legacy/core_plugins/timelion/public/panels/timechart/schema.ts +++ b/src/plugins/timelion/public/panels/timechart/schema.ts @@ -17,31 +17,32 @@ * under the License. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import '../../../../../../plugins/vis_type_timelion/public/flot'; +import '../../flot'; import _ from 'lodash'; import $ from 'jquery'; import moment from 'moment-timezone'; -import { timefilter } from 'ui/timefilter'; // @ts-ignore import observeResize from '../../lib/observe_resize'; import { calculateInterval, DEFAULT_TIME_FORMAT, - // @ts-ignore -} from '../../../../../../plugins/vis_type_timelion/common/lib'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { tickFormatters } from '../../../../../../plugins/vis_type_timelion/public/helpers/tick_formatters'; -import { TimelionVisualizationDependencies } from '../../plugin'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { xaxisFormatterProvider } from '../../../../../../plugins/vis_type_timelion/public/helpers/xaxis_formatter'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { generateTicksProvider } from '../../../../../../plugins/vis_type_timelion/public/helpers/tick_generator'; + tickFormatters, + xaxisFormatterProvider, + generateTicksProvider, +} from '../../../../vis_type_timelion/public'; +import { TimelionVisualizationDependencies } from '../../application'; const DEBOUNCE_DELAY = 50; export function timechartFn(dependencies: TimelionVisualizationDependencies) { - const { $rootScope, $compile, uiSettings } = dependencies; + const { + $rootScope, + $compile, + uiSettings, + data: { + query: { timefilter }, + }, + } = dependencies; return function () { return { @@ -199,7 +200,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { }); $elem.on('plotselected', function (event: any, ranges: any) { - timefilter.setTime({ + timefilter.timefilter.setTime({ from: moment(ranges.xaxis.from), to: moment(ranges.xaxis.to), }); @@ -299,7 +300,7 @@ export function timechartFn(dependencies: TimelionVisualizationDependencies) { const options = _.cloneDeep(defaultOptions) as any; // Get the X-axis tick format - const time = timefilter.getBounds() as any; + const time = timefilter.timefilter.getBounds() as any; const interval = calculateInterval( time.min.valueOf(), time.max.valueOf(), diff --git a/src/legacy/core_plugins/timelion/public/panels/timechart/timechart.ts b/src/plugins/timelion/public/panels/timechart/timechart.ts similarity index 94% rename from src/legacy/core_plugins/timelion/public/panels/timechart/timechart.ts rename to src/plugins/timelion/public/panels/timechart/timechart.ts index 4173bfeb331e2..525a994e3121d 100644 --- a/src/legacy/core_plugins/timelion/public/panels/timechart/timechart.ts +++ b/src/plugins/timelion/public/panels/timechart/timechart.ts @@ -19,7 +19,7 @@ import { timechartFn } from './schema'; import { Panel } from '../panel'; -import { TimelionVisualizationDependencies } from '../../plugin'; +import { TimelionVisualizationDependencies } from '../../application'; export function getTimeChart(dependencies: TimelionVisualizationDependencies) { // Schema is broken out so that it may be extended for use in other plugins diff --git a/src/legacy/core_plugins/timelion/public/partials/load_sheet.html b/src/plugins/timelion/public/partials/load_sheet.html similarity index 100% rename from src/legacy/core_plugins/timelion/public/partials/load_sheet.html rename to src/plugins/timelion/public/partials/load_sheet.html diff --git a/src/legacy/core_plugins/timelion/public/partials/save_sheet.html b/src/plugins/timelion/public/partials/save_sheet.html similarity index 100% rename from src/legacy/core_plugins/timelion/public/partials/save_sheet.html rename to src/plugins/timelion/public/partials/save_sheet.html diff --git a/src/legacy/core_plugins/timelion/public/partials/sheet_options.html b/src/plugins/timelion/public/partials/sheet_options.html similarity index 100% rename from src/legacy/core_plugins/timelion/public/partials/sheet_options.html rename to src/plugins/timelion/public/partials/sheet_options.html diff --git a/src/plugins/timelion/public/plugin.ts b/src/plugins/timelion/public/plugin.ts new file mode 100644 index 0000000000000..a92ced20cb6d1 --- /dev/null +++ b/src/plugins/timelion/public/plugin.ts @@ -0,0 +1,134 @@ +/* + * 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 { filter, map } from 'rxjs/operators'; +import { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, + DEFAULT_APP_CATEGORIES, + AppMountParameters, + AppUpdater, + ScopedHistory, +} from '../../../core/public'; +import { Panel } from './panels/panel'; +import { initAngularBootstrap, KibanaLegacyStart } from '../../kibana_legacy/public'; +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'; + +export interface TimelionPluginDependencies { + data: DataPublicPluginStart; + navigation: NavigationPublicPluginStart; + visualizations: VisualizationsStart; + visTypeTimelion: VisTypeTimelionPluginStart; +} + +/** @internal */ +export class TimelionPlugin implements Plugin { + initializerContext: PluginInitializerContext; + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking: (() => void) | undefined = undefined; + private currentHistory: ScopedHistory | undefined = undefined; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup(core: CoreSetup, { data }: { data: DataPublicPluginSetup }) { + const timelionPanels: Map = new Map(); + + const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ + baseUrl: core.http.basePath.prepend('/app/timelion'), + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:timelion`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + kbnUrlKey: '_g', + stateUpdate$: data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(esFilters.isFilterPinned), + })) + ), + }, + ], + getHistory: () => this.currentHistory!, + }); + + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + initAngularBootstrap(); + core.application.register({ + id: 'timelion', + title: 'Timelion', + order: 8000, + defaultPath: '#/', + euiIconType: 'timelionApp', + category: DEFAULT_APP_CATEGORIES.kibana, + updater$: this.appStateUpdater.asObservable(), + mount: async (params: AppMountParameters) => { + const [coreStart, pluginsStart] = await core.getStartServices(); + this.currentHistory = params.history; + + appMounted(); + + const unlistenParentHistory = params.history.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + const { renderApp } = await import('./application'); + params.element.classList.add('timelionAppContainer'); + const unmount = renderApp({ + mountParams: params, + pluginInitializerContext: this.initializerContext, + timelionPanels, + core: coreStart, + plugins: pluginsStart as TimelionPluginDependencies, + }); + return () => { + unlistenParentHistory(); + unmount(); + appUnMounted(); + }; + }, + }); + } + + public start(core: CoreStart, { kibanaLegacy }: { kibanaLegacy: KibanaLegacyStart }) { + kibanaLegacy.loadFontAwesome(); + } + + public stop(): void { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } +} diff --git a/src/legacy/core_plugins/timelion/public/services/_saved_sheet.ts b/src/plugins/timelion/public/services/_saved_sheet.ts similarity index 95% rename from src/legacy/core_plugins/timelion/public/services/_saved_sheet.ts rename to src/plugins/timelion/public/services/_saved_sheet.ts index 4e5aa8d445e7d..0958cce860126 100644 --- a/src/legacy/core_plugins/timelion/public/services/_saved_sheet.ts +++ b/src/plugins/timelion/public/services/_saved_sheet.ts @@ -18,10 +18,7 @@ */ import { IUiSettingsClient } from 'kibana/public'; -import { - createSavedObjectClass, - SavedObjectKibanaServices, -} from '../../../../../plugins/saved_objects/public'; +import { createSavedObjectClass, SavedObjectKibanaServices } from '../../../saved_objects/public'; // Used only by the savedSheets service, usually no reason to change this export function createSavedSheetClass( diff --git a/src/plugins/timelion/public/services/saved_sheets.ts b/src/plugins/timelion/public/services/saved_sheets.ts new file mode 100644 index 0000000000000..a3e7f66d9ee47 --- /dev/null +++ b/src/plugins/timelion/public/services/saved_sheets.ts @@ -0,0 +1,50 @@ +/* + * 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 { SavedObjectLoader } from '../../../saved_objects/public'; +import { createSavedSheetClass } from './_saved_sheet'; +import { RenderDeps } from '../application'; + +export function initSavedSheetService(app: angular.IModule, deps: RenderDeps) { + const savedObjectsClient = deps.core.savedObjects.client; + const services = { + savedObjectsClient, + indexPatterns: deps.plugins.data.indexPatterns, + search: deps.plugins.data.search, + chrome: deps.core.chrome, + overlays: deps.core.overlays, + }; + + const SavedSheet = createSavedSheetClass(services, deps.core.uiSettings); + + const savedSheetLoader = new SavedObjectLoader(SavedSheet, savedObjectsClient, deps.core.chrome); + savedSheetLoader.urlFor = (id) => `#/${encodeURIComponent(id)}`; + // Customize loader properties since adding an 's' on type doesn't work for type 'timelion-sheet'. + savedSheetLoader.loaderProperties = { + name: 'timelion-sheet', + noun: 'Saved Sheets', + nouns: 'saved sheets', + }; + // This is the only thing that gets injected into controllers + app.service('savedSheets', function () { + return savedSheetLoader; + }); + + return savedSheetLoader; +} diff --git a/src/plugins/timelion/public/timelion_app_state.ts b/src/plugins/timelion/public/timelion_app_state.ts new file mode 100644 index 0000000000000..43382adbf8f80 --- /dev/null +++ b/src/plugins/timelion/public/timelion_app_state.ts @@ -0,0 +1,73 @@ +/* + * 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 { createStateContainer, syncState, IKbnUrlStateStorage } from '../../kibana_utils/public'; + +import { TimelionAppState, TimelionAppStateTransitions } from './types'; + +const STATE_STORAGE_KEY = '_a'; + +interface Arguments { + kbnUrlStateStorage: IKbnUrlStateStorage; + stateDefaults: TimelionAppState; +} + +export function initTimelionAppState({ stateDefaults, kbnUrlStateStorage }: Arguments) { + const urlState = kbnUrlStateStorage.get(STATE_STORAGE_KEY); + const initialState = { + ...stateDefaults, + ...urlState, + }; + + /* + make sure url ('_a') matches initial state + Initializing appState does two things - first it translates the defaults into AppState, + second it updates appState based on the url (the url trumps the defaults). This means if + we update the state format at all and want to handle BWC, we must not only migrate the + data stored with saved vis, but also any old state in the url. + */ + kbnUrlStateStorage.set(STATE_STORAGE_KEY, initialState, { replace: true }); + + const stateContainer = createStateContainer( + initialState, + { + set: (state) => (prop, value) => ({ ...state, [prop]: value }), + updateState: (state) => (newValues) => ({ ...state, ...newValues }), + } + ); + + const { start: startStateSync, stop: stopStateSync } = syncState({ + storageKey: STATE_STORAGE_KEY, + stateContainer: { + ...stateContainer, + set: (state) => { + if (state) { + // syncState utils requires to handle incoming "null" value + stateContainer.set(state); + } + }, + }, + stateStorage: kbnUrlStateStorage, + }); + + // start syncing the appState with the ('_a') url + startStateSync(); + + return { stateContainer, stopStateSync }; +} diff --git a/src/plugins/timelion/public/types.ts b/src/plugins/timelion/public/types.ts new file mode 100644 index 0000000000000..700485064e41b --- /dev/null +++ b/src/plugins/timelion/public/types.ts @@ -0,0 +1,35 @@ +/* + * 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 interface TimelionAppState { + sheet: string[]; + selected: number; + columns: number; + rows: number; + interval: string; +} + +export interface TimelionAppStateTransitions { + set: ( + state: TimelionAppState + ) => (prop: T, value: TimelionAppState[T]) => TimelionAppState; + updateState: ( + state: TimelionAppState + ) => (newValues: Partial) => TimelionAppState; +} diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.axislabels.js b/src/plugins/timelion/public/webpackShims/jquery.flot.axislabels.js new file mode 100644 index 0000000000000..cda8038953c76 --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.axislabels.js @@ -0,0 +1,462 @@ +/* +Axis Labels Plugin for flot. +http://github.com/markrcote/flot-axislabels +Original code is Copyright (c) 2010 Xuan Luo. +Original code was released under the GPLv3 license by Xuan Luo, September 2010. +Original code was rereleased under the MIT license by Xuan Luo, April 2012. +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +(function ($) { + var options = { + axisLabels: { + show: true + } + }; + + function canvasSupported() { + return !!document.createElement('canvas').getContext; + } + + function canvasTextSupported() { + if (!canvasSupported()) { + return false; + } + var dummy_canvas = document.createElement('canvas'); + var context = dummy_canvas.getContext('2d'); + return typeof context.fillText == 'function'; + } + + function css3TransitionSupported() { + var div = document.createElement('div'); + return typeof div.style.MozTransition != 'undefined' // Gecko + || typeof div.style.OTransition != 'undefined' // Opera + || typeof div.style.webkitTransition != 'undefined' // WebKit + || typeof div.style.transition != 'undefined'; + } + + + function AxisLabel(axisName, position, padding, plot, opts) { + this.axisName = axisName; + this.position = position; + this.padding = padding; + this.plot = plot; + this.opts = opts; + this.width = 0; + this.height = 0; + } + + AxisLabel.prototype.cleanup = function() { + }; + + + CanvasAxisLabel.prototype = new AxisLabel(); + CanvasAxisLabel.prototype.constructor = CanvasAxisLabel; + function CanvasAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, padding, + plot, opts); + } + + CanvasAxisLabel.prototype.calculateSize = function() { + if (!this.opts.axisLabelFontSizePixels) + this.opts.axisLabelFontSizePixels = 14; + if (!this.opts.axisLabelFontFamily) + this.opts.axisLabelFontFamily = 'sans-serif'; + + var textWidth = this.opts.axisLabelFontSizePixels + this.padding; + var textHeight = this.opts.axisLabelFontSizePixels + this.padding; + if (this.position == 'left' || this.position == 'right') { + this.width = this.opts.axisLabelFontSizePixels + this.padding; + this.height = 0; + } else { + this.width = 0; + this.height = this.opts.axisLabelFontSizePixels + this.padding; + } + }; + + CanvasAxisLabel.prototype.draw = function(box) { + if (!this.opts.axisLabelColour) + this.opts.axisLabelColour = 'black'; + var ctx = this.plot.getCanvas().getContext('2d'); + ctx.save(); + ctx.font = this.opts.axisLabelFontSizePixels + 'px ' + + this.opts.axisLabelFontFamily; + ctx.fillStyle = this.opts.axisLabelColour; + var width = ctx.measureText(this.opts.axisLabel).width; + var height = this.opts.axisLabelFontSizePixels; + var x, y, angle = 0; + if (this.position == 'top') { + x = box.left + box.width/2 - width/2; + y = box.top + height*0.72; + } else if (this.position == 'bottom') { + x = box.left + box.width/2 - width/2; + y = box.top + box.height - height*0.72; + } else if (this.position == 'left') { + x = box.left + height*0.72; + y = box.height/2 + box.top + width/2; + angle = -Math.PI/2; + } else if (this.position == 'right') { + x = box.left + box.width - height*0.72; + y = box.height/2 + box.top - width/2; + angle = Math.PI/2; + } + ctx.translate(x, y); + ctx.rotate(angle); + ctx.fillText(this.opts.axisLabel, 0, 0); + ctx.restore(); + }; + + + HtmlAxisLabel.prototype = new AxisLabel(); + HtmlAxisLabel.prototype.constructor = HtmlAxisLabel; + function HtmlAxisLabel(axisName, position, padding, plot, opts) { + AxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + this.elem = null; + } + + HtmlAxisLabel.prototype.calculateSize = function() { + var elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(elem); + // store height and width of label itself, for use in draw() + this.labelWidth = elem.outerWidth(true); + this.labelHeight = elem.outerHeight(true); + elem.remove(); + + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelWidth + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + HtmlAxisLabel.prototype.cleanup = function() { + if (this.elem) { + this.elem.remove(); + } + }; + + HtmlAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find('#' + this.axisName + 'Label').remove(); + this.elem = $('
' + + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + if (this.position == 'top') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + 'px'); + } else if (this.position == 'bottom') { + this.elem.css('left', box.left + box.width/2 - this.labelWidth/2 + + 'px'); + this.elem.css('top', box.top + box.height - this.labelHeight + + 'px'); + } else if (this.position == 'left') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + 'px'); + } else if (this.position == 'right') { + this.elem.css('top', box.top + box.height/2 - this.labelHeight/2 + + 'px'); + this.elem.css('left', box.left + box.width - this.labelWidth + + 'px'); + } + }; + + + CssTransformAxisLabel.prototype = new HtmlAxisLabel(); + CssTransformAxisLabel.prototype.constructor = CssTransformAxisLabel; + function CssTransformAxisLabel(axisName, position, padding, plot, opts) { + HtmlAxisLabel.prototype.constructor.call(this, axisName, position, + padding, plot, opts); + } + + CssTransformAxisLabel.prototype.calculateSize = function() { + HtmlAxisLabel.prototype.calculateSize.call(this); + this.width = this.height = 0; + if (this.position == 'left' || this.position == 'right') { + this.width = this.labelHeight + this.padding; + } else { + this.height = this.labelHeight + this.padding; + } + }; + + CssTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + var stransforms = { + '-moz-transform': '', + '-webkit-transform': '', + '-o-transform': '', + '-ms-transform': '' + }; + if (x != 0 || y != 0) { + var stdTranslate = ' translate(' + x + 'px, ' + y + 'px)'; + stransforms['-moz-transform'] += stdTranslate; + stransforms['-webkit-transform'] += stdTranslate; + stransforms['-o-transform'] += stdTranslate; + stransforms['-ms-transform'] += stdTranslate; + } + if (degrees != 0) { + var rotation = degrees / 90; + var stdRotate = ' rotate(' + degrees + 'deg)'; + stransforms['-moz-transform'] += stdRotate; + stransforms['-webkit-transform'] += stdRotate; + stransforms['-o-transform'] += stdRotate; + stransforms['-ms-transform'] += stdRotate; + } + var s = 'top: 0; left: 0; '; + for (var prop in stransforms) { + if (stransforms[prop]) { + s += prop + ':' + stransforms[prop] + ';'; + } + } + s += ';'; + return s; + }; + + CssTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = { x: 0, y: 0, degrees: 0 }; + if (this.position == 'bottom') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top + box.height - this.labelHeight; + } else if (this.position == 'top') { + offsets.x = box.left + box.width/2 - this.labelWidth/2; + offsets.y = box.top; + } else if (this.position == 'left') { + offsets.degrees = -90; + offsets.x = box.left - this.labelWidth/2 + this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } else if (this.position == 'right') { + offsets.degrees = 90; + offsets.x = box.left + box.width - this.labelWidth/2 + - this.labelHeight/2; + offsets.y = box.height/2 + box.top; + } + offsets.x = Math.round(offsets.x); + offsets.y = Math.round(offsets.y); + + return offsets; + }; + + CssTransformAxisLabel.prototype.draw = function(box) { + this.plot.getPlaceholder().find("." + this.axisName + "Label").remove(); + var offsets = this.calculateOffsets(box); + this.elem = $('
' + this.opts.axisLabel + '
'); + this.plot.getPlaceholder().append(this.elem); + }; + + + IeTransformAxisLabel.prototype = new CssTransformAxisLabel(); + IeTransformAxisLabel.prototype.constructor = IeTransformAxisLabel; + function IeTransformAxisLabel(axisName, position, padding, plot, opts) { + CssTransformAxisLabel.prototype.constructor.call(this, axisName, + position, padding, + plot, opts); + this.requiresResize = false; + } + + IeTransformAxisLabel.prototype.transforms = function(degrees, x, y) { + // I didn't feel like learning the crazy Matrix stuff, so this uses + // a combination of the rotation transform and CSS positioning. + var s = ''; + if (degrees != 0) { + var rotation = degrees/90; + while (rotation < 0) { + rotation += 4; + } + s += ' filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=' + rotation + '); '; + // see below + this.requiresResize = (this.position == 'right'); + } + if (x != 0) { + s += 'left: ' + x + 'px; '; + } + if (y != 0) { + s += 'top: ' + y + 'px; '; + } + return s; + }; + + IeTransformAxisLabel.prototype.calculateOffsets = function(box) { + var offsets = CssTransformAxisLabel.prototype.calculateOffsets.call( + this, box); + // adjust some values to take into account differences between + // CSS and IE rotations. + if (this.position == 'top') { + // FIXME: not sure why, but placing this exactly at the top causes + // the top axis label to flip to the bottom... + offsets.y = box.top + 1; + } else if (this.position == 'left') { + offsets.x = box.left; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } else if (this.position == 'right') { + offsets.x = box.left + box.width - this.labelHeight; + offsets.y = box.height/2 + box.top - this.labelWidth/2; + } + return offsets; + }; + + IeTransformAxisLabel.prototype.draw = function(box) { + CssTransformAxisLabel.prototype.draw.call(this, box); + if (this.requiresResize) { + this.elem = this.plot.getPlaceholder().find("." + this.axisName + + "Label"); + // Since we used CSS positioning instead of transforms for + // translating the element, and since the positioning is done + // before any rotations, we have to reset the width and height + // in case the browser wrapped the text (specifically for the + // y2axis). + this.elem.css('width', this.labelWidth); + this.elem.css('height', this.labelHeight); + } + }; + + + function init(plot) { + plot.hooks.processOptions.push(function (plot, options) { + + if (!options.axisLabels.show) + return; + + // This is kind of a hack. There are no hooks in Flot between + // the creation and measuring of the ticks (setTicks, measureTickLabels + // in setupGrid() ) and the drawing of the ticks and plot box + // (insertAxisLabels in setupGrid() ). + // + // Therefore, we use a trick where we run the draw routine twice: + // the first time to get the tick measurements, so that we can change + // them, and then have it draw it again. + var secondPass = false; + + var axisLabels = {}; + var axisOffsetCounts = { left: 0, right: 0, top: 0, bottom: 0 }; + + var defaultPadding = 2; // padding between axis and tick labels + plot.hooks.draw.push(function (plot, ctx) { + var hasAxisLabels = false; + if (!secondPass) { + // MEASURE AND SET OPTIONS + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + + // Handle redraws initiated outside of this plug-in. + if (axisName in axisLabels) { + axis.labelHeight = axis.labelHeight - + axisLabels[axisName].height; + axis.labelWidth = axis.labelWidth - + axisLabels[axisName].width; + opts.labelHeight = axis.labelHeight; + opts.labelWidth = axis.labelWidth; + axisLabels[axisName].cleanup(); + delete axisLabels[axisName]; + } + + if (!opts || !opts.axisLabel || !axis.show) + return; + + hasAxisLabels = true; + var renderer = null; + + if (!opts.axisLabelUseHtml && + navigator.appName == 'Microsoft Internet Explorer') { + var ua = navigator.userAgent; + var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})"); + if (re.exec(ua) != null) { + rv = parseFloat(RegExp.$1); + } + if (rv >= 9 && !opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = CssTransformAxisLabel; + } else if (!opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) { + renderer = IeTransformAxisLabel; + } else if (opts.axisLabelUseCanvas) { + renderer = CanvasAxisLabel; + } else { + renderer = HtmlAxisLabel; + } + } else { + if (opts.axisLabelUseHtml || (!css3TransitionSupported() && !canvasTextSupported()) && !opts.axisLabelUseCanvas) { + renderer = HtmlAxisLabel; + } else if (opts.axisLabelUseCanvas || !css3TransitionSupported()) { + renderer = CanvasAxisLabel; + } else { + renderer = CssTransformAxisLabel; + } + } + + var padding = opts.axisLabelPadding === undefined ? + defaultPadding : opts.axisLabelPadding; + + axisLabels[axisName] = new renderer(axisName, + axis.position, padding, + plot, opts); + + // flot interprets axis.labelHeight and .labelWidth as + // the height and width of the tick labels. We increase + // these values to make room for the axis label and + // padding. + + axisLabels[axisName].calculateSize(); + + // AxisLabel.height and .width are the size of the + // axis label and padding. + // Just set opts here because axis will be sorted out on + // the redraw. + + opts.labelHeight = axis.labelHeight + + axisLabels[axisName].height; + opts.labelWidth = axis.labelWidth + + axisLabels[axisName].width; + }); + + // If there are axis labels, re-draw with new label widths and + // heights. + + if (hasAxisLabels) { + secondPass = true; + plot.setupGrid(); + plot.draw(); + } + } else { + secondPass = false; + // DRAW + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options // Flot 0.7 + || plot.getOptions()[axisName]; // Flot 0.6 + if (!opts || !opts.axisLabel || !axis.show) + return; + + axisLabels[axisName].draw(axis.box); + }); + } + }); + }); + } + + + $.plot.plugins.push({ + init: init, + options: options, + name: 'axisLabels', + version: '2.0' + }); +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.crosshair.js b/src/plugins/timelion/public/webpackShims/jquery.flot.crosshair.js new file mode 100644 index 0000000000000..5111695e3d12c --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.crosshair.js @@ -0,0 +1,176 @@ +/* Flot plugin for showing crosshairs when the mouse hovers over the plot. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin supports these options: + + crosshair: { + mode: null or "x" or "y" or "xy" + color: color + lineWidth: number + } + +Set the mode to one of "x", "y" or "xy". The "x" mode enables a vertical +crosshair that lets you trace the values on the x axis, "y" enables a +horizontal crosshair and "xy" enables them both. "color" is the color of the +crosshair (default is "rgba(170, 0, 0, 0.80)"), "lineWidth" is the width of +the drawn lines (default is 1). + +The plugin also adds four public methods: + + - setCrosshair( pos ) + + Set the position of the crosshair. Note that this is cleared if the user + moves the mouse. "pos" is in coordinates of the plot and should be on the + form { x: xpos, y: ypos } (you can use x2/x3/... if you're using multiple + axes), which is coincidentally the same format as what you get from a + "plothover" event. If "pos" is null, the crosshair is cleared. + + - clearCrosshair() + + Clear the crosshair. + + - lockCrosshair(pos) + + Cause the crosshair to lock to the current location, no longer updating if + the user moves the mouse. Optionally supply a position (passed on to + setCrosshair()) to move it to. + + Example usage: + + var myFlot = $.plot( $("#graph"), ..., { crosshair: { mode: "x" } } }; + $("#graph").bind( "plothover", function ( evt, position, item ) { + if ( item ) { + // Lock the crosshair to the data point being hovered + myFlot.lockCrosshair({ + x: item.datapoint[ 0 ], + y: item.datapoint[ 1 ] + }); + } else { + // Return normal crosshair operation + myFlot.unlockCrosshair(); + } + }); + + - unlockCrosshair() + + Free the crosshair to move again after locking it. +*/ + +(function ($) { + var options = { + crosshair: { + mode: null, // one of null, "x", "y" or "xy", + color: "rgba(170, 0, 0, 0.80)", + lineWidth: 1 + } + }; + + function init(plot) { + // position of crosshair in pixels + var crosshair = { x: -1, y: -1, locked: false }; + + plot.setCrosshair = function setCrosshair(pos) { + if (!pos) + crosshair.x = -1; + else { + var o = plot.p2c(pos); + crosshair.x = Math.max(0, Math.min(o.left, plot.width())); + crosshair.y = Math.max(0, Math.min(o.top, plot.height())); + } + + plot.triggerRedrawOverlay(); + }; + + plot.clearCrosshair = plot.setCrosshair; // passes null for pos + + plot.lockCrosshair = function lockCrosshair(pos) { + if (pos) + plot.setCrosshair(pos); + crosshair.locked = true; + }; + + plot.unlockCrosshair = function unlockCrosshair() { + crosshair.locked = false; + }; + + function onMouseOut(e) { + if (crosshair.locked) + return; + + if (crosshair.x != -1) { + crosshair.x = -1; + plot.triggerRedrawOverlay(); + } + } + + function onMouseMove(e) { + if (crosshair.locked) + return; + + if (plot.getSelection && plot.getSelection()) { + crosshair.x = -1; // hide the crosshair while selecting + return; + } + + var offset = plot.offset(); + crosshair.x = Math.max(0, Math.min(e.pageX - offset.left, plot.width())); + crosshair.y = Math.max(0, Math.min(e.pageY - offset.top, plot.height())); + plot.triggerRedrawOverlay(); + } + + plot.hooks.bindEvents.push(function (plot, eventHolder) { + if (!plot.getOptions().crosshair.mode) + return; + + eventHolder.mouseout(onMouseOut); + eventHolder.mousemove(onMouseMove); + }); + + plot.hooks.drawOverlay.push(function (plot, ctx) { + var c = plot.getOptions().crosshair; + if (!c.mode) + return; + + var plotOffset = plot.getPlotOffset(); + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + if (crosshair.x != -1) { + var adj = plot.getOptions().crosshair.lineWidth % 2 ? 0.5 : 0; + + ctx.strokeStyle = c.color; + ctx.lineWidth = c.lineWidth; + ctx.lineJoin = "round"; + + ctx.beginPath(); + if (c.mode.indexOf("x") != -1) { + var drawX = Math.floor(crosshair.x) + adj; + ctx.moveTo(drawX, 0); + ctx.lineTo(drawX, plot.height()); + } + if (c.mode.indexOf("y") != -1) { + var drawY = Math.floor(crosshair.y) + adj; + ctx.moveTo(0, drawY); + ctx.lineTo(plot.width(), drawY); + } + ctx.stroke(); + } + ctx.restore(); + }); + + plot.hooks.shutdown.push(function (plot, eventHolder) { + eventHolder.unbind("mouseout", onMouseOut); + eventHolder.unbind("mousemove", onMouseMove); + }); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'crosshair', + version: '1.0' + }); +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.js b/src/plugins/timelion/public/webpackShims/jquery.flot.js new file mode 100644 index 0000000000000..5d613037cf234 --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.js @@ -0,0 +1,3168 @@ +/* JavaScript plotting library for jQuery, version 0.8.3. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +*/ + +// first an inline dependency, jquery.colorhelpers.js, we inline it here +// for convenience + +/* Plugin for jQuery for working with colors. + * + * Version 1.1. + * + * Inspiration from jQuery color animation plugin by John Resig. + * + * Released under the MIT license by Ole Laursen, October 2009. + * + * Examples: + * + * $.color.parse("#fff").scale('rgb', 0.25).add('a', -0.5).toString() + * var c = $.color.extract($("#mydiv"), 'background-color'); + * console.log(c.r, c.g, c.b, c.a); + * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" + * + * Note that .scale() and .add() return the same modified object + * instead of making a new one. + * + * V. 1.1: Fix error handling so e.g. parsing an empty string does + * produce a color rather than just crashing. + */ +(function($){$.color={};$.color.make=function(r,g,b,a){var o={};o.r=r||0;o.g=g||0;o.b=b||0;o.a=a!=null?a:1;o.add=function(c,d){for(var i=0;i=1){return"rgb("+[o.r,o.g,o.b].join(",")+")"}else{return"rgba("+[o.r,o.g,o.b,o.a].join(",")+")"}};o.normalize=function(){function clamp(min,value,max){return valuemax?max:value}o.r=clamp(0,parseInt(o.r),255);o.g=clamp(0,parseInt(o.g),255);o.b=clamp(0,parseInt(o.b),255);o.a=clamp(0,o.a,1);return o};o.clone=function(){return $.color.make(o.r,o.b,o.g,o.a)};return o.normalize()};$.color.extract=function(elem,css){var c;do{c=elem.css(css).toLowerCase();if(c!=""&&c!="transparent")break;elem=elem.parent()}while(elem.length&&!$.nodeName(elem.get(0),"body"));if(c=="rgba(0, 0, 0, 0)")c="transparent";return $.color.parse(c)};$.color.parse=function(str){var res,m=$.color.make;if(res=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10));if(res=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseInt(res[1],10),parseInt(res[2],10),parseInt(res[3],10),parseFloat(res[4]));if(res=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55);if(res=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(str))return m(parseFloat(res[1])*2.55,parseFloat(res[2])*2.55,parseFloat(res[3])*2.55,parseFloat(res[4]));if(res=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(str))return m(parseInt(res[1],16),parseInt(res[2],16),parseInt(res[3],16));if(res=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(str))return m(parseInt(res[1]+res[1],16),parseInt(res[2]+res[2],16),parseInt(res[3]+res[3],16));var name=$.trim(str).toLowerCase();if(name=="transparent")return m(255,255,255,0);else{res=lookupColors[name]||[0,0,0];return m(res[0],res[1],res[2])}};var lookupColors={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery); + +// the actual Flot code +(function($) { + + // Cache the prototype hasOwnProperty for faster access + + var hasOwnProperty = Object.prototype.hasOwnProperty; + + // A shim to provide 'detach' to jQuery versions prior to 1.4. Using a DOM + // operation produces the same effect as detach, i.e. removing the element + // without touching its jQuery data. + + // Do not merge this into Flot 0.9, since it requires jQuery 1.4.4+. + + if (!$.fn.detach) { + $.fn.detach = function() { + return this.each(function() { + if (this.parentNode) { + this.parentNode.removeChild( this ); + } + }); + }; + } + + /////////////////////////////////////////////////////////////////////////// + // The Canvas object is a wrapper around an HTML5 tag. + // + // @constructor + // @param {string} cls List of classes to apply to the canvas. + // @param {element} container Element onto which to append the canvas. + // + // Requiring a container is a little iffy, but unfortunately canvas + // operations don't work unless the canvas is attached to the DOM. + + function Canvas(cls, container) { + + var element = container.children("." + cls)[0]; + + if (element == null) { + + element = document.createElement("canvas"); + element.className = cls; + + $(element).css({ direction: "ltr", position: "absolute", left: 0, top: 0 }) + .appendTo(container); + + // If HTML5 Canvas isn't available, fall back to [Ex|Flash]canvas + + if (!element.getContext) { + if (window.G_vmlCanvasManager) { + element = window.G_vmlCanvasManager.initElement(element); + } else { + throw new Error("Canvas is not available. If you're using IE with a fall-back such as Excanvas, then there's either a mistake in your conditional include, or the page has no DOCTYPE and is rendering in Quirks Mode."); + } + } + } + + this.element = element; + + var context = this.context = element.getContext("2d"); + + // Determine the screen's ratio of physical to device-independent + // pixels. This is the ratio between the canvas width that the browser + // advertises and the number of pixels actually present in that space. + + // The iPhone 4, for example, has a device-independent width of 320px, + // but its screen is actually 640px wide. It therefore has a pixel + // ratio of 2, while most normal devices have a ratio of 1. + + var devicePixelRatio = window.devicePixelRatio || 1, + backingStoreRatio = + context.webkitBackingStorePixelRatio || + context.mozBackingStorePixelRatio || + context.msBackingStorePixelRatio || + context.oBackingStorePixelRatio || + context.backingStorePixelRatio || 1; + + this.pixelRatio = devicePixelRatio / backingStoreRatio; + + // Size the canvas to match the internal dimensions of its container + + this.resize(container.width(), container.height()); + + // Collection of HTML div layers for text overlaid onto the canvas + + this.textContainer = null; + this.text = {}; + + // Cache of text fragments and metrics, so we can avoid expensively + // re-calculating them when the plot is re-rendered in a loop. + + this._textCache = {}; + } + + // Resizes the canvas to the given dimensions. + // + // @param {number} width New width of the canvas, in pixels. + // @param {number} width New height of the canvas, in pixels. + + Canvas.prototype.resize = function(width, height) { + + if (width <= 0 || height <= 0) { + throw new Error("Invalid dimensions for plot, width = " + width + ", height = " + height); + } + + var element = this.element, + context = this.context, + pixelRatio = this.pixelRatio; + + // Resize the canvas, increasing its density based on the display's + // pixel ratio; basically giving it more pixels without increasing the + // size of its element, to take advantage of the fact that retina + // displays have that many more pixels in the same advertised space. + + // Resizing should reset the state (excanvas seems to be buggy though) + + if (this.width != width) { + element.width = width * pixelRatio; + element.style.width = width + "px"; + this.width = width; + } + + if (this.height != height) { + element.height = height * pixelRatio; + element.style.height = height + "px"; + this.height = height; + } + + // Save the context, so we can reset in case we get replotted. The + // restore ensure that we're really back at the initial state, and + // should be safe even if we haven't saved the initial state yet. + + context.restore(); + context.save(); + + // Scale the coordinate space to match the display density; so even though we + // may have twice as many pixels, we still want lines and other drawing to + // appear at the same size; the extra pixels will just make them crisper. + + context.scale(pixelRatio, pixelRatio); + }; + + // Clears the entire canvas area, not including any overlaid HTML text + + Canvas.prototype.clear = function() { + this.context.clearRect(0, 0, this.width, this.height); + }; + + // Finishes rendering the canvas, including managing the text overlay. + + Canvas.prototype.render = function() { + + var cache = this._textCache; + + // For each text layer, add elements marked as active that haven't + // already been rendered, and remove those that are no longer active. + + for (var layerKey in cache) { + if (hasOwnProperty.call(cache, layerKey)) { + + var layer = this.getTextLayer(layerKey), + layerCache = cache[layerKey]; + + layer.hide(); + + for (var styleKey in layerCache) { + if (hasOwnProperty.call(layerCache, styleKey)) { + var styleCache = layerCache[styleKey]; + for (var key in styleCache) { + if (hasOwnProperty.call(styleCache, key)) { + + var positions = styleCache[key].positions; + + for (var i = 0, position; position = positions[i]; i++) { + if (position.active) { + if (!position.rendered) { + layer.append(position.element); + position.rendered = true; + } + } else { + positions.splice(i--, 1); + if (position.rendered) { + position.element.detach(); + } + } + } + + if (positions.length == 0) { + delete styleCache[key]; + } + } + } + } + } + + layer.show(); + } + } + }; + + // Creates (if necessary) and returns the text overlay container. + // + // @param {string} classes String of space-separated CSS classes used to + // uniquely identify the text layer. + // @return {object} The jQuery-wrapped text-layer div. + + Canvas.prototype.getTextLayer = function(classes) { + + var layer = this.text[classes]; + + // Create the text layer if it doesn't exist + + if (layer == null) { + + // Create the text layer container, if it doesn't exist + + if (this.textContainer == null) { + this.textContainer = $("
") + .css({ + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + 'font-size': "smaller", + color: "#545454" + }) + .insertAfter(this.element); + } + + layer = this.text[classes] = $("
") + .addClass(classes) + .css({ + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0 + }) + .appendTo(this.textContainer); + } + + return layer; + }; + + // Creates (if necessary) and returns a text info object. + // + // The object looks like this: + // + // { + // width: Width of the text's wrapper div. + // height: Height of the text's wrapper div. + // element: The jQuery-wrapped HTML div containing the text. + // positions: Array of positions at which this text is drawn. + // } + // + // The positions array contains objects that look like this: + // + // { + // active: Flag indicating whether the text should be visible. + // rendered: Flag indicating whether the text is currently visible. + // element: The jQuery-wrapped HTML div containing the text. + // x: X coordinate at which to draw the text. + // y: Y coordinate at which to draw the text. + // } + // + // Each position after the first receives a clone of the original element. + // + // The idea is that that the width, height, and general 'identity' of the + // text is constant no matter where it is placed; the placements are a + // secondary property. + // + // Canvas maintains a cache of recently-used text info objects; getTextInfo + // either returns the cached element or creates a new entry. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {string} text Text string to retrieve info for. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which to rotate the text, in degrees. + // Angle is currently unused, it will be implemented in the future. + // @param {number=} width Maximum width of the text before it wraps. + // @return {object} a text info object. + + Canvas.prototype.getTextInfo = function(layer, text, font, angle, width) { + + var textStyle, layerCache, styleCache, info; + + // Cast the value to a string, in case we were given a number or such + + text = "" + text; + + // If the font is a font-spec object, generate a CSS font definition + + if (typeof font === "object") { + textStyle = font.style + " " + font.variant + " " + font.weight + " " + font.size + "px/" + font.lineHeight + "px " + font.family; + } else { + textStyle = font; + } + + // Retrieve (or create) the cache for the text's layer and styles + + layerCache = this._textCache[layer]; + + if (layerCache == null) { + layerCache = this._textCache[layer] = {}; + } + + styleCache = layerCache[textStyle]; + + if (styleCache == null) { + styleCache = layerCache[textStyle] = {}; + } + + info = styleCache[text]; + + // If we can't find a matching element in our cache, create a new one + + if (info == null) { + + var element = $("
").html(text) + .css({ + position: "absolute", + 'max-width': width, + top: -9999 + }) + .appendTo(this.getTextLayer(layer)); + + if (typeof font === "object") { + element.css({ + font: textStyle, + color: font.color + }); + } else if (typeof font === "string") { + element.addClass(font); + } + + info = styleCache[text] = { + width: element.outerWidth(true), + height: element.outerHeight(true), + element: element, + positions: [] + }; + + element.detach(); + } + + return info; + }; + + // Adds a text string to the canvas text overlay. + // + // The text isn't drawn immediately; it is marked as rendering, which will + // result in its addition to the canvas on the next render pass. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {number} x X coordinate at which to draw the text. + // @param {number} y Y coordinate at which to draw the text. + // @param {string} text Text string to draw. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which to rotate the text, in degrees. + // Angle is currently unused, it will be implemented in the future. + // @param {number=} width Maximum width of the text before it wraps. + // @param {string=} halign Horizontal alignment of the text; either "left", + // "center" or "right". + // @param {string=} valign Vertical alignment of the text; either "top", + // "middle" or "bottom". + + Canvas.prototype.addText = function(layer, x, y, text, font, angle, width, halign, valign) { + + var info = this.getTextInfo(layer, text, font, angle, width), + positions = info.positions; + + // Tweak the div's position to match the text's alignment + + if (halign == "center") { + x -= info.width / 2; + } else if (halign == "right") { + x -= info.width; + } + + if (valign == "middle") { + y -= info.height / 2; + } else if (valign == "bottom") { + y -= info.height; + } + + // Determine whether this text already exists at this position. + // If so, mark it for inclusion in the next render pass. + + for (var i = 0, position; position = positions[i]; i++) { + if (position.x == x && position.y == y) { + position.active = true; + return; + } + } + + // If the text doesn't exist at this position, create a new entry + + // For the very first position we'll re-use the original element, + // while for subsequent ones we'll clone it. + + position = { + active: true, + rendered: false, + element: positions.length ? info.element.clone() : info.element, + x: x, + y: y + }; + + positions.push(position); + + // Move the element to its final position within the container + + position.element.css({ + top: Math.round(y), + left: Math.round(x), + 'text-align': halign // In case the text wraps + }); + }; + + // Removes one or more text strings from the canvas text overlay. + // + // If no parameters are given, all text within the layer is removed. + // + // Note that the text is not immediately removed; it is simply marked as + // inactive, which will result in its removal on the next render pass. + // This avoids the performance penalty for 'clear and redraw' behavior, + // where we potentially get rid of all text on a layer, but will likely + // add back most or all of it later, as when redrawing axes, for example. + // + // @param {string} layer A string of space-separated CSS classes uniquely + // identifying the layer containing this text. + // @param {number=} x X coordinate of the text. + // @param {number=} y Y coordinate of the text. + // @param {string=} text Text string to remove. + // @param {(string|object)=} font Either a string of space-separated CSS + // classes or a font-spec object, defining the text's font and style. + // @param {number=} angle Angle at which the text is rotated, in degrees. + // Angle is currently unused, it will be implemented in the future. + + Canvas.prototype.removeText = function(layer, x, y, text, font, angle) { + if (text == null) { + var layerCache = this._textCache[layer]; + if (layerCache != null) { + for (var styleKey in layerCache) { + if (hasOwnProperty.call(layerCache, styleKey)) { + var styleCache = layerCache[styleKey]; + for (var key in styleCache) { + if (hasOwnProperty.call(styleCache, key)) { + var positions = styleCache[key].positions; + for (var i = 0, position; position = positions[i]; i++) { + position.active = false; + } + } + } + } + } + } + } else { + var positions = this.getTextInfo(layer, text, font, angle).positions; + for (var i = 0, position; position = positions[i]; i++) { + if (position.x == x && position.y == y) { + position.active = false; + } + } + } + }; + + /////////////////////////////////////////////////////////////////////////// + // The top-level container for the entire plot. + + function Plot(placeholder, data_, options_, plugins) { + // data is on the form: + // [ series1, series2 ... ] + // where series is either just the data as [ [x1, y1], [x2, y2], ... ] + // or { data: [ [x1, y1], [x2, y2], ... ], label: "some label", ... } + + var series = [], + options = { + // the color theme used for graphs + colors: ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"], + legend: { + show: true, + noColumns: 1, // number of columns in legend table + labelFormatter: null, // fn: string -> string + labelBoxBorderColor: "#ccc", // border color for the little label boxes + container: null, // container (as jQuery object) to put legend in, null means default on top of graph + position: "ne", // position of default legend container within plot + margin: 5, // distance from grid edge to default legend container within plot + backgroundColor: null, // null means auto-detect + backgroundOpacity: 0.85, // set to 0 to avoid background + sorted: null // default to no legend sorting + }, + xaxis: { + show: null, // null = auto-detect, true = always, false = never + position: "bottom", // or "top" + mode: null, // null or "time" + font: null, // null (derived from CSS in placeholder) or object like { size: 11, lineHeight: 13, style: "italic", weight: "bold", family: "sans-serif", variant: "small-caps" } + color: null, // base color, labels, ticks + tickColor: null, // possibly different color of ticks, e.g. "rgba(0,0,0,0.15)" + transform: null, // null or f: number -> number to transform axis + inverseTransform: null, // if transform is set, this should be the inverse function + min: null, // min. value to show, null means set automatically + max: null, // max. value to show, null means set automatically + autoscaleMargin: null, // margin in % to add if auto-setting min/max + ticks: null, // either [1, 3] or [[1, "a"], 3] or (fn: axis info -> ticks) or app. number of ticks for auto-ticks + tickFormatter: null, // fn: number -> string + labelWidth: null, // size of tick labels in pixels + labelHeight: null, + reserveSpace: null, // whether to reserve space even if axis isn't shown + tickLength: null, // size in pixels of ticks, or "full" for whole line + alignTicksWithAxis: null, // axis number or null for no sync + tickDecimals: null, // no. of decimals, null means auto + tickSize: null, // number or [number, "unit"] + minTickSize: null // number or [number, "unit"] + }, + yaxis: { + autoscaleMargin: 0.02, + position: "left" // or "right" + }, + xaxes: [], + yaxes: [], + series: { + points: { + show: false, + radius: 3, + lineWidth: 2, // in pixels + fill: true, + fillColor: "#ffffff", + symbol: "circle" // or callback + }, + lines: { + // we don't put in show: false so we can see + // whether lines were actively disabled + lineWidth: 2, // in pixels + fill: false, + fillColor: null, + steps: false + // Omit 'zero', so we can later default its value to + // match that of the 'fill' option. + }, + bars: { + show: false, + lineWidth: 2, // in pixels + barWidth: 1, // in units of the x axis + fill: true, + fillColor: null, + align: "left", // "left", "right", or "center" + horizontal: false, + zero: true + }, + shadowSize: 3, + highlightColor: null + }, + grid: { + show: true, + aboveData: false, + color: "#545454", // primary color used for outline and labels + backgroundColor: null, // null for transparent, else color + borderColor: null, // set if different from the grid color + tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)" + margin: 0, // distance from the canvas edge to the grid + labelMargin: 5, // in pixels + axisMargin: 8, // in pixels + borderWidth: 2, // in pixels + minBorderMargin: null, // in pixels, null means taken from points radius + markings: null, // array of ranges or fn: axes -> array of ranges + markingsColor: "#f4f4f4", + markingsLineWidth: 2, + // interactive stuff + clickable: false, + hoverable: false, + autoHighlight: true, // highlight in case mouse is near + mouseActiveRadius: 10 // how far the mouse can be away to activate an item + }, + interaction: { + redrawOverlayInterval: 1000/60 // time between updates, -1 means in same flow + }, + hooks: {} + }, + surface = null, // the canvas for the plot itself + overlay = null, // canvas for interactive stuff on top of plot + eventHolder = null, // jQuery object that events should be bound to + ctx = null, octx = null, + xaxes = [], yaxes = [], + plotOffset = { left: 0, right: 0, top: 0, bottom: 0}, + plotWidth = 0, plotHeight = 0, + hooks = { + processOptions: [], + processRawData: [], + processDatapoints: [], + processOffset: [], + drawBackground: [], + drawSeries: [], + draw: [], + bindEvents: [], + drawOverlay: [], + shutdown: [] + }, + plot = this; + + // public functions + plot.setData = setData; + plot.setupGrid = setupGrid; + plot.draw = draw; + plot.getPlaceholder = function() { return placeholder; }; + plot.getCanvas = function() { return surface.element; }; + plot.getPlotOffset = function() { return plotOffset; }; + plot.width = function () { return plotWidth; }; + plot.height = function () { return plotHeight; }; + plot.offset = function () { + var o = eventHolder.offset(); + o.left += plotOffset.left; + o.top += plotOffset.top; + return o; + }; + plot.getData = function () { return series; }; + plot.getAxes = function () { + var res = {}, i; + $.each(xaxes.concat(yaxes), function (_, axis) { + if (axis) + res[axis.direction + (axis.n != 1 ? axis.n : "") + "axis"] = axis; + }); + return res; + }; + plot.getXAxes = function () { return xaxes; }; + plot.getYAxes = function () { return yaxes; }; + plot.c2p = canvasToAxisCoords; + plot.p2c = axisToCanvasCoords; + plot.getOptions = function () { return options; }; + plot.highlight = highlight; + plot.unhighlight = unhighlight; + plot.triggerRedrawOverlay = triggerRedrawOverlay; + plot.pointOffset = function(point) { + return { + left: parseInt(xaxes[axisNumber(point, "x") - 1].p2c(+point.x) + plotOffset.left, 10), + top: parseInt(yaxes[axisNumber(point, "y") - 1].p2c(+point.y) + plotOffset.top, 10) + }; + }; + plot.shutdown = shutdown; + plot.destroy = function () { + shutdown(); + placeholder.removeData("plot").empty(); + + series = []; + options = null; + surface = null; + overlay = null; + eventHolder = null; + ctx = null; + octx = null; + xaxes = []; + yaxes = []; + hooks = null; + highlights = []; + plot = null; + }; + plot.resize = function () { + var width = placeholder.width(), + height = placeholder.height(); + surface.resize(width, height); + overlay.resize(width, height); + }; + + // public attributes + plot.hooks = hooks; + + // initialize + initPlugins(plot); + parseOptions(options_); + setupCanvases(); + setData(data_); + setupGrid(); + draw(); + bindEvents(); + + + function executeHooks(hook, args) { + args = [plot].concat(args); + for (var i = 0; i < hook.length; ++i) + hook[i].apply(this, args); + } + + function initPlugins() { + + // References to key classes, allowing plugins to modify them + + var classes = { + Canvas: Canvas + }; + + for (var i = 0; i < plugins.length; ++i) { + var p = plugins[i]; + p.init(plot, classes); + if (p.options) + $.extend(true, options, p.options); + } + } + + function parseOptions(opts) { + + $.extend(true, options, opts); + + // $.extend merges arrays, rather than replacing them. When less + // colors are provided than the size of the default palette, we + // end up with those colors plus the remaining defaults, which is + // not expected behavior; avoid it by replacing them here. + + if (opts && opts.colors) { + options.colors = opts.colors; + } + + if (options.xaxis.color == null) + options.xaxis.color = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + if (options.yaxis.color == null) + options.yaxis.color = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + + if (options.xaxis.tickColor == null) // grid.tickColor for back-compatibility + options.xaxis.tickColor = options.grid.tickColor || options.xaxis.color; + if (options.yaxis.tickColor == null) // grid.tickColor for back-compatibility + options.yaxis.tickColor = options.grid.tickColor || options.yaxis.color; + + if (options.grid.borderColor == null) + options.grid.borderColor = options.grid.color; + if (options.grid.tickColor == null) + options.grid.tickColor = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + + // Fill in defaults for axis options, including any unspecified + // font-spec fields, if a font-spec was provided. + + // If no x/y axis options were provided, create one of each anyway, + // since the rest of the code assumes that they exist. + + var i, axisOptions, axisCount, + fontSize = placeholder.css("font-size"), + fontSizeDefault = fontSize ? +fontSize.replace("px", "") : 13, + fontDefaults = { + style: placeholder.css("font-style"), + size: Math.round(0.8 * fontSizeDefault), + variant: placeholder.css("font-variant"), + weight: placeholder.css("font-weight"), + family: placeholder.css("font-family") + }; + + axisCount = options.xaxes.length || 1; + for (i = 0; i < axisCount; ++i) { + + axisOptions = options.xaxes[i]; + if (axisOptions && !axisOptions.tickColor) { + axisOptions.tickColor = axisOptions.color; + } + + axisOptions = $.extend(true, {}, options.xaxis, axisOptions); + options.xaxes[i] = axisOptions; + + if (axisOptions.font) { + axisOptions.font = $.extend({}, fontDefaults, axisOptions.font); + if (!axisOptions.font.color) { + axisOptions.font.color = axisOptions.color; + } + if (!axisOptions.font.lineHeight) { + axisOptions.font.lineHeight = Math.round(axisOptions.font.size * 1.15); + } + } + } + + axisCount = options.yaxes.length || 1; + for (i = 0; i < axisCount; ++i) { + + axisOptions = options.yaxes[i]; + if (axisOptions && !axisOptions.tickColor) { + axisOptions.tickColor = axisOptions.color; + } + + axisOptions = $.extend(true, {}, options.yaxis, axisOptions); + options.yaxes[i] = axisOptions; + + if (axisOptions.font) { + axisOptions.font = $.extend({}, fontDefaults, axisOptions.font); + if (!axisOptions.font.color) { + axisOptions.font.color = axisOptions.color; + } + if (!axisOptions.font.lineHeight) { + axisOptions.font.lineHeight = Math.round(axisOptions.font.size * 1.15); + } + } + } + + // backwards compatibility, to be removed in future + if (options.xaxis.noTicks && options.xaxis.ticks == null) + options.xaxis.ticks = options.xaxis.noTicks; + if (options.yaxis.noTicks && options.yaxis.ticks == null) + options.yaxis.ticks = options.yaxis.noTicks; + if (options.x2axis) { + options.xaxes[1] = $.extend(true, {}, options.xaxis, options.x2axis); + options.xaxes[1].position = "top"; + // Override the inherit to allow the axis to auto-scale + if (options.x2axis.min == null) { + options.xaxes[1].min = null; + } + if (options.x2axis.max == null) { + options.xaxes[1].max = null; + } + } + if (options.y2axis) { + options.yaxes[1] = $.extend(true, {}, options.yaxis, options.y2axis); + options.yaxes[1].position = "right"; + // Override the inherit to allow the axis to auto-scale + if (options.y2axis.min == null) { + options.yaxes[1].min = null; + } + if (options.y2axis.max == null) { + options.yaxes[1].max = null; + } + } + if (options.grid.coloredAreas) + options.grid.markings = options.grid.coloredAreas; + if (options.grid.coloredAreasColor) + options.grid.markingsColor = options.grid.coloredAreasColor; + if (options.lines) + $.extend(true, options.series.lines, options.lines); + if (options.points) + $.extend(true, options.series.points, options.points); + if (options.bars) + $.extend(true, options.series.bars, options.bars); + if (options.shadowSize != null) + options.series.shadowSize = options.shadowSize; + if (options.highlightColor != null) + options.series.highlightColor = options.highlightColor; + + // save options on axes for future reference + for (i = 0; i < options.xaxes.length; ++i) + getOrCreateAxis(xaxes, i + 1).options = options.xaxes[i]; + for (i = 0; i < options.yaxes.length; ++i) + getOrCreateAxis(yaxes, i + 1).options = options.yaxes[i]; + + // add hooks from options + for (var n in hooks) + if (options.hooks[n] && options.hooks[n].length) + hooks[n] = hooks[n].concat(options.hooks[n]); + + executeHooks(hooks.processOptions, [options]); + } + + function setData(d) { + series = parseData(d); + fillInSeriesOptions(); + processData(); + } + + function parseData(d) { + var res = []; + for (var i = 0; i < d.length; ++i) { + var s = $.extend(true, {}, options.series); + + if (d[i].data != null) { + s.data = d[i].data; // move the data instead of deep-copy + delete d[i].data; + + $.extend(true, s, d[i]); + + d[i].data = s.data; + } + else + s.data = d[i]; + res.push(s); + } + + return res; + } + + function axisNumber(obj, coord) { + var a = obj[coord + "axis"]; + if (typeof a == "object") // if we got a real axis, extract number + a = a.n; + if (typeof a != "number") + a = 1; // default to first axis + return a; + } + + function allAxes() { + // return flat array without annoying null entries + return $.grep(xaxes.concat(yaxes), function (a) { return a; }); + } + + function canvasToAxisCoords(pos) { + // return an object with x/y corresponding to all used axes + var res = {}, i, axis; + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) + res["x" + axis.n] = axis.c2p(pos.left); + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) + res["y" + axis.n] = axis.c2p(pos.top); + } + + if (res.x1 !== undefined) + res.x = res.x1; + if (res.y1 !== undefined) + res.y = res.y1; + + return res; + } + + function axisToCanvasCoords(pos) { + // get canvas coords from the first pair of x/y found in pos + var res = {}, i, axis, key; + + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) { + key = "x" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "x"; + + if (pos[key] != null) { + res.left = axis.p2c(pos[key]); + break; + } + } + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) { + key = "y" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "y"; + + if (pos[key] != null) { + res.top = axis.p2c(pos[key]); + break; + } + } + } + + return res; + } + + function getOrCreateAxis(axes, number) { + if (!axes[number - 1]) + axes[number - 1] = { + n: number, // save the number for future reference + direction: axes == xaxes ? "x" : "y", + options: $.extend(true, {}, axes == xaxes ? options.xaxis : options.yaxis) + }; + + return axes[number - 1]; + } + + function fillInSeriesOptions() { + + var neededColors = series.length, maxIndex = -1, i; + + // Subtract the number of series that already have fixed colors or + // color indexes from the number that we still need to generate. + + for (i = 0; i < series.length; ++i) { + var sc = series[i].color; + if (sc != null) { + neededColors--; + if (typeof sc == "number" && sc > maxIndex) { + maxIndex = sc; + } + } + } + + // If any of the series have fixed color indexes, then we need to + // generate at least as many colors as the highest index. + + if (neededColors <= maxIndex) { + neededColors = maxIndex + 1; + } + + // Generate all the colors, using first the option colors and then + // variations on those colors once they're exhausted. + + var c, colors = [], colorPool = options.colors, + colorPoolSize = colorPool.length, variation = 0; + + for (i = 0; i < neededColors; i++) { + + c = $.color.parse(colorPool[i % colorPoolSize] || "#666"); + + // Each time we exhaust the colors in the pool we adjust + // a scaling factor used to produce more variations on + // those colors. The factor alternates negative/positive + // to produce lighter/darker colors. + + // Reset the variation after every few cycles, or else + // it will end up producing only white or black colors. + + if (i % colorPoolSize == 0 && i) { + if (variation >= 0) { + if (variation < 0.5) { + variation = -variation - 0.2; + } else variation = 0; + } else variation = -variation; + } + + colors[i] = c.scale('rgb', 1 + variation); + } + + // Finalize the series options, filling in their colors + + var colori = 0, s; + for (i = 0; i < series.length; ++i) { + s = series[i]; + + // assign colors + if (s.color == null) { + s.color = colors[colori].toString(); + ++colori; + } + else if (typeof s.color == "number") + s.color = colors[s.color].toString(); + + // turn on lines automatically in case nothing is set + if (s.lines.show == null) { + var v, show = true; + for (v in s) + if (s[v] && s[v].show) { + show = false; + break; + } + if (show) + s.lines.show = true; + } + + // If nothing was provided for lines.zero, default it to match + // lines.fill, since areas by default should extend to zero. + + if (s.lines.zero == null) { + s.lines.zero = !!s.lines.fill; + } + + // setup axes + s.xaxis = getOrCreateAxis(xaxes, axisNumber(s, "x")); + s.yaxis = getOrCreateAxis(yaxes, axisNumber(s, "y")); + } + } + + function processData() { + var topSentry = Number.POSITIVE_INFINITY, + bottomSentry = Number.NEGATIVE_INFINITY, + fakeInfinity = Number.MAX_VALUE, + i, j, k, m, length, + s, points, ps, x, y, axis, val, f, p, + data, format; + + function updateAxis(axis, min, max) { + if (min < axis.datamin && min != -fakeInfinity) + axis.datamin = min; + if (max > axis.datamax && max != fakeInfinity) + axis.datamax = max; + } + + $.each(allAxes(), function (_, axis) { + // init axis + axis.datamin = topSentry; + axis.datamax = bottomSentry; + axis.used = false; + }); + + for (i = 0; i < series.length; ++i) { + s = series[i]; + s.datapoints = { points: [] }; + + executeHooks(hooks.processRawData, [ s, s.data, s.datapoints ]); + } + + // first pass: clean and copy data + for (i = 0; i < series.length; ++i) { + s = series[i]; + + data = s.data; + format = s.datapoints.format; + + if (!format) { + format = []; + // find out how to copy + format.push({ x: true, number: true, required: true }); + format.push({ y: true, number: true, required: true }); + + if (s.bars.show || (s.lines.show && s.lines.fill)) { + var autoscale = !!((s.bars.show && s.bars.zero) || (s.lines.show && s.lines.zero)); + format.push({ y: true, number: true, required: false, defaultValue: 0, autoscale: autoscale }); + if (s.bars.horizontal) { + delete format[format.length - 1].y; + format[format.length - 1].x = true; + } + } + + s.datapoints.format = format; + } + + if (s.datapoints.pointsize != null) + continue; // already filled in + + s.datapoints.pointsize = format.length; + + ps = s.datapoints.pointsize; + points = s.datapoints.points; + + var insertSteps = s.lines.show && s.lines.steps; + s.xaxis.used = s.yaxis.used = true; + + for (j = k = 0; j < data.length; ++j, k += ps) { + p = data[j]; + + var nullify = p == null; + if (!nullify) { + for (m = 0; m < ps; ++m) { + val = p[m]; + f = format[m]; + + if (f) { + if (f.number && val != null) { + val = +val; // convert to number + if (isNaN(val)) + val = null; + else if (val == Infinity) + val = fakeInfinity; + else if (val == -Infinity) + val = -fakeInfinity; + } + + if (val == null) { + if (f.required) + nullify = true; + + if (f.defaultValue != null) + val = f.defaultValue; + } + } + + points[k + m] = val; + } + } + + if (nullify) { + for (m = 0; m < ps; ++m) { + val = points[k + m]; + if (val != null) { + f = format[m]; + // extract min/max info + if (f.autoscale !== false) { + if (f.x) { + updateAxis(s.xaxis, val, val); + } + if (f.y) { + updateAxis(s.yaxis, val, val); + } + } + } + points[k + m] = null; + } + } + else { + // a little bit of line specific stuff that + // perhaps shouldn't be here, but lacking + // better means... + if (insertSteps && k > 0 + && points[k - ps] != null + && points[k - ps] != points[k] + && points[k - ps + 1] != points[k + 1]) { + // copy the point to make room for a middle point + for (m = 0; m < ps; ++m) + points[k + ps + m] = points[k + m]; + + // middle point has same y + points[k + 1] = points[k - ps + 1]; + + // we've added a point, better reflect that + k += ps; + } + } + } + } + + // give the hooks a chance to run + for (i = 0; i < series.length; ++i) { + s = series[i]; + + executeHooks(hooks.processDatapoints, [ s, s.datapoints]); + } + + // second pass: find datamax/datamin for auto-scaling + for (i = 0; i < series.length; ++i) { + s = series[i]; + points = s.datapoints.points; + ps = s.datapoints.pointsize; + format = s.datapoints.format; + + var xmin = topSentry, ymin = topSentry, + xmax = bottomSentry, ymax = bottomSentry; + + for (j = 0; j < points.length; j += ps) { + if (points[j] == null) + continue; + + for (m = 0; m < ps; ++m) { + val = points[j + m]; + f = format[m]; + if (!f || f.autoscale === false || val == fakeInfinity || val == -fakeInfinity) + continue; + + if (f.x) { + if (val < xmin) + xmin = val; + if (val > xmax) + xmax = val; + } + if (f.y) { + if (val < ymin) + ymin = val; + if (val > ymax) + ymax = val; + } + } + } + + if (s.bars.show) { + // make sure we got room for the bar on the dancing floor + var delta; + + switch (s.bars.align) { + case "left": + delta = 0; + break; + case "right": + delta = -s.bars.barWidth; + break; + default: + delta = -s.bars.barWidth / 2; + } + + if (s.bars.horizontal) { + ymin += delta; + ymax += delta + s.bars.barWidth; + } + else { + xmin += delta; + xmax += delta + s.bars.barWidth; + } + } + + updateAxis(s.xaxis, xmin, xmax); + updateAxis(s.yaxis, ymin, ymax); + } + + $.each(allAxes(), function (_, axis) { + if (axis.datamin == topSentry) + axis.datamin = null; + if (axis.datamax == bottomSentry) + axis.datamax = null; + }); + } + + function setupCanvases() { + + // Make sure the placeholder is clear of everything except canvases + // from a previous plot in this container that we'll try to re-use. + + placeholder.css("padding", 0) // padding messes up the positioning + .children().filter(function(){ + return !$(this).hasClass("flot-overlay") && !$(this).hasClass('flot-base'); + }).remove(); + + if (placeholder.css("position") == 'static') + placeholder.css("position", "relative"); // for positioning labels and overlay + + surface = new Canvas("flot-base", placeholder); + overlay = new Canvas("flot-overlay", placeholder); // overlay canvas for interactive features + + ctx = surface.context; + octx = overlay.context; + + // define which element we're listening for events on + eventHolder = $(overlay.element).unbind(); + + // If we're re-using a plot object, shut down the old one + + var existing = placeholder.data("plot"); + + if (existing) { + existing.shutdown(); + overlay.clear(); + } + + // save in case we get replotted + placeholder.data("plot", plot); + } + + function bindEvents() { + // bind events + if (options.grid.hoverable) { + eventHolder.mousemove(onMouseMove); + + // Use bind, rather than .mouseleave, because we officially + // still support jQuery 1.2.6, which doesn't define a shortcut + // for mouseenter or mouseleave. This was a bug/oversight that + // was fixed somewhere around 1.3.x. We can return to using + // .mouseleave when we drop support for 1.2.6. + + eventHolder.bind("mouseleave", onMouseLeave); + } + + if (options.grid.clickable) + eventHolder.click(onClick); + + executeHooks(hooks.bindEvents, [eventHolder]); + } + + function shutdown() { + if (redrawTimeout) + clearTimeout(redrawTimeout); + + eventHolder.unbind("mousemove", onMouseMove); + eventHolder.unbind("mouseleave", onMouseLeave); + eventHolder.unbind("click", onClick); + + executeHooks(hooks.shutdown, [eventHolder]); + } + + function setTransformationHelpers(axis) { + // set helper functions on the axis, assumes plot area + // has been computed already + + function identity(x) { return x; } + + var s, m, t = axis.options.transform || identity, + it = axis.options.inverseTransform; + + // precompute how much the axis is scaling a point + // in canvas space + if (axis.direction == "x") { + s = axis.scale = plotWidth / Math.abs(t(axis.max) - t(axis.min)); + m = Math.min(t(axis.max), t(axis.min)); + } + else { + s = axis.scale = plotHeight / Math.abs(t(axis.max) - t(axis.min)); + s = -s; + m = Math.max(t(axis.max), t(axis.min)); + } + + // data point to canvas coordinate + if (t == identity) // slight optimization + axis.p2c = function (p) { return (p - m) * s; }; + else + axis.p2c = function (p) { return (t(p) - m) * s; }; + // canvas coordinate to data point + if (!it) + axis.c2p = function (c) { return m + c / s; }; + else + axis.c2p = function (c) { return it(m + c / s); }; + } + + function measureTickLabels(axis) { + + var opts = axis.options, + ticks = axis.ticks || [], + labelWidth = opts.labelWidth || 0, + labelHeight = opts.labelHeight || 0, + maxWidth = labelWidth || (axis.direction == "x" ? Math.floor(surface.width / (ticks.length || 1)) : null), + legacyStyles = axis.direction + "Axis " + axis.direction + axis.n + "Axis", + layer = "flot-" + axis.direction + "-axis flot-" + axis.direction + axis.n + "-axis " + legacyStyles, + font = opts.font || "flot-tick-label tickLabel"; + + for (var i = 0; i < ticks.length; ++i) { + + var t = ticks[i]; + + if (!t.label) + continue; + + var info = surface.getTextInfo(layer, t.label, font, null, maxWidth); + + labelWidth = Math.max(labelWidth, info.width); + labelHeight = Math.max(labelHeight, info.height); + } + + axis.labelWidth = opts.labelWidth || labelWidth; + axis.labelHeight = opts.labelHeight || labelHeight; + } + + function allocateAxisBoxFirstPhase(axis) { + // find the bounding box of the axis by looking at label + // widths/heights and ticks, make room by diminishing the + // plotOffset; this first phase only looks at one + // dimension per axis, the other dimension depends on the + // other axes so will have to wait + + var lw = axis.labelWidth, + lh = axis.labelHeight, + pos = axis.options.position, + isXAxis = axis.direction === "x", + tickLength = axis.options.tickLength, + axisMargin = options.grid.axisMargin, + padding = options.grid.labelMargin, + innermost = true, + outermost = true, + first = true, + found = false; + + // Determine the axis's position in its direction and on its side + + $.each(isXAxis ? xaxes : yaxes, function(i, a) { + if (a && (a.show || a.reserveSpace)) { + if (a === axis) { + found = true; + } else if (a.options.position === pos) { + if (found) { + outermost = false; + } else { + innermost = false; + } + } + if (!found) { + first = false; + } + } + }); + + // The outermost axis on each side has no margin + + if (outermost) { + axisMargin = 0; + } + + // The ticks for the first axis in each direction stretch across + + if (tickLength == null) { + tickLength = first ? "full" : 5; + } + + if (!isNaN(+tickLength)) + padding += +tickLength; + + if (isXAxis) { + lh += padding; + + if (pos == "bottom") { + plotOffset.bottom += lh + axisMargin; + axis.box = { top: surface.height - plotOffset.bottom, height: lh }; + } + else { + axis.box = { top: plotOffset.top + axisMargin, height: lh }; + plotOffset.top += lh + axisMargin; + } + } + else { + lw += padding; + + if (pos == "left") { + axis.box = { left: plotOffset.left + axisMargin, width: lw }; + plotOffset.left += lw + axisMargin; + } + else { + plotOffset.right += lw + axisMargin; + axis.box = { left: surface.width - plotOffset.right, width: lw }; + } + } + + // save for future reference + axis.position = pos; + axis.tickLength = tickLength; + axis.box.padding = padding; + axis.innermost = innermost; + } + + function allocateAxisBoxSecondPhase(axis) { + // now that all axis boxes have been placed in one + // dimension, we can set the remaining dimension coordinates + if (axis.direction == "x") { + axis.box.left = plotOffset.left - axis.labelWidth / 2; + axis.box.width = surface.width - plotOffset.left - plotOffset.right + axis.labelWidth; + } + else { + axis.box.top = plotOffset.top - axis.labelHeight / 2; + axis.box.height = surface.height - plotOffset.bottom - plotOffset.top + axis.labelHeight; + } + } + + function adjustLayoutForThingsStickingOut() { + // possibly adjust plot offset to ensure everything stays + // inside the canvas and isn't clipped off + + var minMargin = options.grid.minBorderMargin, + axis, i; + + // check stuff from the plot (FIXME: this should just read + // a value from the series, otherwise it's impossible to + // customize) + if (minMargin == null) { + minMargin = 0; + for (i = 0; i < series.length; ++i) + minMargin = Math.max(minMargin, 2 * (series[i].points.radius + series[i].points.lineWidth/2)); + } + + var margins = { + left: minMargin, + right: minMargin, + top: minMargin, + bottom: minMargin + }; + + // check axis labels, note we don't check the actual + // labels but instead use the overall width/height to not + // jump as much around with replots + $.each(allAxes(), function (_, axis) { + if (axis.reserveSpace && axis.ticks && axis.ticks.length) { + if (axis.direction === "x") { + margins.left = Math.max(margins.left, axis.labelWidth / 2); + margins.right = Math.max(margins.right, axis.labelWidth / 2); + } else { + margins.bottom = Math.max(margins.bottom, axis.labelHeight / 2); + margins.top = Math.max(margins.top, axis.labelHeight / 2); + } + } + }); + + plotOffset.left = Math.ceil(Math.max(margins.left, plotOffset.left)); + plotOffset.right = Math.ceil(Math.max(margins.right, plotOffset.right)); + plotOffset.top = Math.ceil(Math.max(margins.top, plotOffset.top)); + plotOffset.bottom = Math.ceil(Math.max(margins.bottom, plotOffset.bottom)); + } + + function setupGrid() { + var i, axes = allAxes(), showGrid = options.grid.show; + + // Initialize the plot's offset from the edge of the canvas + + for (var a in plotOffset) { + var margin = options.grid.margin || 0; + plotOffset[a] = typeof margin == "number" ? margin : margin[a] || 0; + } + + executeHooks(hooks.processOffset, [plotOffset]); + + // If the grid is visible, add its border width to the offset + + for (var a in plotOffset) { + if(typeof(options.grid.borderWidth) == "object") { + plotOffset[a] += showGrid ? options.grid.borderWidth[a] : 0; + } + else { + plotOffset[a] += showGrid ? options.grid.borderWidth : 0; + } + } + + $.each(axes, function (_, axis) { + var axisOpts = axis.options; + axis.show = axisOpts.show == null ? axis.used : axisOpts.show; + axis.reserveSpace = axisOpts.reserveSpace == null ? axis.show : axisOpts.reserveSpace; + setRange(axis); + }); + + if (showGrid) { + + var allocatedAxes = $.grep(axes, function (axis) { + return axis.show || axis.reserveSpace; + }); + + $.each(allocatedAxes, function (_, axis) { + // make the ticks + setupTickGeneration(axis); + setTicks(axis); + snapRangeToTicks(axis, axis.ticks); + // find labelWidth/Height for axis + measureTickLabels(axis); + }); + + // with all dimensions calculated, we can compute the + // axis bounding boxes, start from the outside + // (reverse order) + for (i = allocatedAxes.length - 1; i >= 0; --i) + allocateAxisBoxFirstPhase(allocatedAxes[i]); + + // make sure we've got enough space for things that + // might stick out + adjustLayoutForThingsStickingOut(); + + $.each(allocatedAxes, function (_, axis) { + allocateAxisBoxSecondPhase(axis); + }); + } + + plotWidth = surface.width - plotOffset.left - plotOffset.right; + plotHeight = surface.height - plotOffset.bottom - plotOffset.top; + + // now we got the proper plot dimensions, we can compute the scaling + $.each(axes, function (_, axis) { + setTransformationHelpers(axis); + }); + + if (showGrid) { + drawAxisLabels(); + } + + insertLegend(); + } + + function setRange(axis) { + var opts = axis.options, + min = +(opts.min != null ? opts.min : axis.datamin), + max = +(opts.max != null ? opts.max : axis.datamax), + delta = max - min; + + if (delta == 0.0) { + // degenerate case + var widen = max == 0 ? 1 : 0.01; + + if (opts.min == null) + min -= widen; + // always widen max if we couldn't widen min to ensure we + // don't fall into min == max which doesn't work + if (opts.max == null || opts.min != null) + max += widen; + } + else { + // consider autoscaling + var margin = opts.autoscaleMargin; + if (margin != null) { + if (opts.min == null) { + min -= delta * margin; + // make sure we don't go below zero if all values + // are positive + if (min < 0 && axis.datamin != null && axis.datamin >= 0) + min = 0; + } + if (opts.max == null) { + max += delta * margin; + if (max > 0 && axis.datamax != null && axis.datamax <= 0) + max = 0; + } + } + } + axis.min = min; + axis.max = max; + } + + function setupTickGeneration(axis) { + var opts = axis.options; + + // estimate number of ticks + var noTicks; + if (typeof opts.ticks == "number" && opts.ticks > 0) + noTicks = opts.ticks; + else + // heuristic based on the model a*sqrt(x) fitted to + // some data points that seemed reasonable + noTicks = 0.3 * Math.sqrt(axis.direction == "x" ? surface.width : surface.height); + + var delta = (axis.max - axis.min) / noTicks, + dec = -Math.floor(Math.log(delta) / Math.LN10), + maxDec = opts.tickDecimals; + + if (maxDec != null && dec > maxDec) { + dec = maxDec; + } + + var magn = Math.pow(10, -dec), + norm = delta / magn, // norm is between 1.0 and 10.0 + size; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + // special case for 2.5, requires an extra decimal + if (norm > 2.25 && (maxDec == null || dec + 1 <= maxDec)) { + size = 2.5; + ++dec; + } + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + + if (opts.minTickSize != null && size < opts.minTickSize) { + size = opts.minTickSize; + } + + axis.delta = delta; + axis.tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); + axis.tickSize = opts.tickSize || size; + + // Time mode was moved to a plug-in in 0.8, and since so many people use it + // we'll add an especially friendly reminder to make sure they included it. + + if (opts.mode == "time" && !axis.tickGenerator) { + throw new Error("Time mode requires the flot.time plugin."); + } + + // Flot supports base-10 axes; any other mode else is handled by a plug-in, + // like flot.time.js. + + if (!axis.tickGenerator) { + + axis.tickGenerator = function (axis) { + + var ticks = [], + start = floorInBase(axis.min, axis.tickSize), + i = 0, + v = Number.NaN, + prev; + + do { + prev = v; + v = start + i * axis.tickSize; + ticks.push(v); + ++i; + } while (v < axis.max && v != prev); + return ticks; + }; + + axis.tickFormatter = function (value, axis) { + + var factor = axis.tickDecimals ? Math.pow(10, axis.tickDecimals) : 1; + var formatted = "" + Math.round(value * factor) / factor; + + // If tickDecimals was specified, ensure that we have exactly that + // much precision; otherwise default to the value's own precision. + + if (axis.tickDecimals != null) { + var decimal = formatted.indexOf("."); + var precision = decimal == -1 ? 0 : formatted.length - decimal - 1; + if (precision < axis.tickDecimals) { + return (precision ? formatted : formatted + ".") + ("" + factor).substr(1, axis.tickDecimals - precision); + } + } + + return formatted; + }; + } + + if ($.isFunction(opts.tickFormatter)) + axis.tickFormatter = function (v, axis) { return "" + opts.tickFormatter(v, axis); }; + + if (opts.alignTicksWithAxis != null) { + var otherAxis = (axis.direction == "x" ? xaxes : yaxes)[opts.alignTicksWithAxis - 1]; + if (otherAxis && otherAxis.used && otherAxis != axis) { + // consider snapping min/max to outermost nice ticks + var niceTicks = axis.tickGenerator(axis); + if (niceTicks.length > 0) { + if (opts.min == null) + axis.min = Math.min(axis.min, niceTicks[0]); + if (opts.max == null && niceTicks.length > 1) + axis.max = Math.max(axis.max, niceTicks[niceTicks.length - 1]); + } + + axis.tickGenerator = function (axis) { + // copy ticks, scaled to this axis + var ticks = [], v, i; + for (i = 0; i < otherAxis.ticks.length; ++i) { + v = (otherAxis.ticks[i].v - otherAxis.min) / (otherAxis.max - otherAxis.min); + v = axis.min + v * (axis.max - axis.min); + ticks.push(v); + } + return ticks; + }; + + // we might need an extra decimal since forced + // ticks don't necessarily fit naturally + if (!axis.mode && opts.tickDecimals == null) { + var extraDec = Math.max(0, -Math.floor(Math.log(axis.delta) / Math.LN10) + 1), + ts = axis.tickGenerator(axis); + + // only proceed if the tick interval rounded + // with an extra decimal doesn't give us a + // zero at end + if (!(ts.length > 1 && /\..*0$/.test((ts[1] - ts[0]).toFixed(extraDec)))) + axis.tickDecimals = extraDec; + } + } + } + } + + function setTicks(axis) { + var oticks = axis.options.ticks, ticks = []; + if (oticks == null || (typeof oticks == "number" && oticks > 0)) + ticks = axis.tickGenerator(axis); + else if (oticks) { + if ($.isFunction(oticks)) + // generate the ticks + ticks = oticks(axis); + else + ticks = oticks; + } + + // clean up/labelify the supplied ticks, copy them over + var i, v; + axis.ticks = []; + for (i = 0; i < ticks.length; ++i) { + var label = null; + var t = ticks[i]; + if (typeof t == "object") { + v = +t[0]; + if (t.length > 1) + label = t[1]; + } + else + v = +t; + if (label == null) + label = axis.tickFormatter(v, axis); + if (!isNaN(v)) + axis.ticks.push({ v: v, label: label }); + } + } + + function snapRangeToTicks(axis, ticks) { + if (axis.options.autoscaleMargin && ticks.length > 0) { + // snap to ticks + if (axis.options.min == null) + axis.min = Math.min(axis.min, ticks[0].v); + if (axis.options.max == null && ticks.length > 1) + axis.max = Math.max(axis.max, ticks[ticks.length - 1].v); + } + } + + function draw() { + + surface.clear(); + + executeHooks(hooks.drawBackground, [ctx]); + + var grid = options.grid; + + // draw background, if any + if (grid.show && grid.backgroundColor) + drawBackground(); + + if (grid.show && !grid.aboveData) { + drawGrid(); + } + + for (var i = 0; i < series.length; ++i) { + executeHooks(hooks.drawSeries, [ctx, series[i]]); + drawSeries(series[i]); + } + + executeHooks(hooks.draw, [ctx]); + + if (grid.show && grid.aboveData) { + drawGrid(); + } + + surface.render(); + + // A draw implies that either the axes or data have changed, so we + // should probably update the overlay highlights as well. + + triggerRedrawOverlay(); + } + + function extractRange(ranges, coord) { + var axis, from, to, key, axes = allAxes(); + + for (var i = 0; i < axes.length; ++i) { + axis = axes[i]; + if (axis.direction == coord) { + key = coord + axis.n + "axis"; + if (!ranges[key] && axis.n == 1) + key = coord + "axis"; // support x1axis as xaxis + if (ranges[key]) { + from = ranges[key].from; + to = ranges[key].to; + break; + } + } + } + + // backwards-compat stuff - to be removed in future + if (!ranges[key]) { + axis = coord == "x" ? xaxes[0] : yaxes[0]; + from = ranges[coord + "1"]; + to = ranges[coord + "2"]; + } + + // auto-reverse as an added bonus + if (from != null && to != null && from > to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; + } + + function drawBackground() { + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)"); + ctx.fillRect(0, 0, plotWidth, plotHeight); + ctx.restore(); + } + + function drawGrid() { + var i, axes, bw, bc; + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + // draw markings + var markings = options.grid.markings; + if (markings) { + if ($.isFunction(markings)) { + axes = plot.getAxes(); + // xmin etc. is backwards compatibility, to be + // removed in the future + axes.xmin = axes.xaxis.min; + axes.xmax = axes.xaxis.max; + axes.ymin = axes.yaxis.min; + axes.ymax = axes.yaxis.max; + + markings = markings(axes); + } + + for (i = 0; i < markings.length; ++i) { + var m = markings[i], + xrange = extractRange(m, "x"), + yrange = extractRange(m, "y"); + + // fill in missing + if (xrange.from == null) + xrange.from = xrange.axis.min; + if (xrange.to == null) + xrange.to = xrange.axis.max; + if (yrange.from == null) + yrange.from = yrange.axis.min; + if (yrange.to == null) + yrange.to = yrange.axis.max; + + // clip + if (xrange.to < xrange.axis.min || xrange.from > xrange.axis.max || + yrange.to < yrange.axis.min || yrange.from > yrange.axis.max) + continue; + + xrange.from = Math.max(xrange.from, xrange.axis.min); + xrange.to = Math.min(xrange.to, xrange.axis.max); + yrange.from = Math.max(yrange.from, yrange.axis.min); + yrange.to = Math.min(yrange.to, yrange.axis.max); + + var xequal = xrange.from === xrange.to, + yequal = yrange.from === yrange.to; + + if (xequal && yequal) { + continue; + } + + // then draw + xrange.from = Math.floor(xrange.axis.p2c(xrange.from)); + xrange.to = Math.floor(xrange.axis.p2c(xrange.to)); + yrange.from = Math.floor(yrange.axis.p2c(yrange.from)); + yrange.to = Math.floor(yrange.axis.p2c(yrange.to)); + + if (xequal || yequal) { + var lineWidth = m.lineWidth || options.grid.markingsLineWidth, + subPixel = lineWidth % 2 ? 0.5 : 0; + ctx.beginPath(); + ctx.strokeStyle = m.color || options.grid.markingsColor; + ctx.lineWidth = lineWidth; + if (xequal) { + ctx.moveTo(xrange.to + subPixel, yrange.from); + ctx.lineTo(xrange.to + subPixel, yrange.to); + } else { + ctx.moveTo(xrange.from, yrange.to + subPixel); + ctx.lineTo(xrange.to, yrange.to + subPixel); + } + ctx.stroke(); + } else { + ctx.fillStyle = m.color || options.grid.markingsColor; + ctx.fillRect(xrange.from, yrange.to, + xrange.to - xrange.from, + yrange.from - yrange.to); + } + } + } + + // draw the ticks + axes = allAxes(); + bw = options.grid.borderWidth; + + for (var j = 0; j < axes.length; ++j) { + var axis = axes[j], box = axis.box, + t = axis.tickLength, x, y, xoff, yoff; + if (!axis.show || axis.ticks.length == 0) + continue; + + ctx.lineWidth = 1; + + // find the edges + if (axis.direction == "x") { + x = 0; + if (t == "full") + y = (axis.position == "top" ? 0 : plotHeight); + else + y = box.top - plotOffset.top + (axis.position == "top" ? box.height : 0); + } + else { + y = 0; + if (t == "full") + x = (axis.position == "left" ? 0 : plotWidth); + else + x = box.left - plotOffset.left + (axis.position == "left" ? box.width : 0); + } + + // draw tick bar + if (!axis.innermost) { + ctx.strokeStyle = axis.options.color; + ctx.beginPath(); + xoff = yoff = 0; + if (axis.direction == "x") + xoff = plotWidth + 1; + else + yoff = plotHeight + 1; + + if (ctx.lineWidth == 1) { + if (axis.direction == "x") { + y = Math.floor(y) + 0.5; + } else { + x = Math.floor(x) + 0.5; + } + } + + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + ctx.stroke(); + } + + // draw ticks + + ctx.strokeStyle = axis.options.tickColor; + + ctx.beginPath(); + for (i = 0; i < axis.ticks.length; ++i) { + var v = axis.ticks[i].v; + + xoff = yoff = 0; + + if (isNaN(v) || v < axis.min || v > axis.max + // skip those lying on the axes if we got a border + || (t == "full" + && ((typeof bw == "object" && bw[axis.position] > 0) || bw > 0) + && (v == axis.min || v == axis.max))) + continue; + + if (axis.direction == "x") { + x = axis.p2c(v); + yoff = t == "full" ? -plotHeight : t; + + if (axis.position == "top") + yoff = -yoff; + } + else { + y = axis.p2c(v); + xoff = t == "full" ? -plotWidth : t; + + if (axis.position == "left") + xoff = -xoff; + } + + if (ctx.lineWidth == 1) { + if (axis.direction == "x") + x = Math.floor(x) + 0.5; + else + y = Math.floor(y) + 0.5; + } + + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + } + + ctx.stroke(); + } + + + // draw border + if (bw) { + // If either borderWidth or borderColor is an object, then draw the border + // line by line instead of as one rectangle + bc = options.grid.borderColor; + if(typeof bw == "object" || typeof bc == "object") { + if (typeof bw !== "object") { + bw = {top: bw, right: bw, bottom: bw, left: bw}; + } + if (typeof bc !== "object") { + bc = {top: bc, right: bc, bottom: bc, left: bc}; + } + + if (bw.top > 0) { + ctx.strokeStyle = bc.top; + ctx.lineWidth = bw.top; + ctx.beginPath(); + ctx.moveTo(0 - bw.left, 0 - bw.top/2); + ctx.lineTo(plotWidth, 0 - bw.top/2); + ctx.stroke(); + } + + if (bw.right > 0) { + ctx.strokeStyle = bc.right; + ctx.lineWidth = bw.right; + ctx.beginPath(); + ctx.moveTo(plotWidth + bw.right / 2, 0 - bw.top); + ctx.lineTo(plotWidth + bw.right / 2, plotHeight); + ctx.stroke(); + } + + if (bw.bottom > 0) { + ctx.strokeStyle = bc.bottom; + ctx.lineWidth = bw.bottom; + ctx.beginPath(); + ctx.moveTo(plotWidth + bw.right, plotHeight + bw.bottom / 2); + ctx.lineTo(0, plotHeight + bw.bottom / 2); + ctx.stroke(); + } + + if (bw.left > 0) { + ctx.strokeStyle = bc.left; + ctx.lineWidth = bw.left; + ctx.beginPath(); + ctx.moveTo(0 - bw.left/2, plotHeight + bw.bottom); + ctx.lineTo(0- bw.left/2, 0); + ctx.stroke(); + } + } + else { + ctx.lineWidth = bw; + ctx.strokeStyle = options.grid.borderColor; + ctx.strokeRect(-bw/2, -bw/2, plotWidth + bw, plotHeight + bw); + } + } + + ctx.restore(); + } + + function drawAxisLabels() { + + $.each(allAxes(), function (_, axis) { + var box = axis.box, + legacyStyles = axis.direction + "Axis " + axis.direction + axis.n + "Axis", + layer = "flot-" + axis.direction + "-axis flot-" + axis.direction + axis.n + "-axis " + legacyStyles, + font = axis.options.font || "flot-tick-label tickLabel", + tick, x, y, halign, valign; + + // Remove text before checking for axis.show and ticks.length; + // otherwise plugins, like flot-tickrotor, that draw their own + // tick labels will end up with both theirs and the defaults. + + surface.removeText(layer); + + if (!axis.show || axis.ticks.length == 0) + return; + + for (var i = 0; i < axis.ticks.length; ++i) { + + tick = axis.ticks[i]; + if (!tick.label || tick.v < axis.min || tick.v > axis.max) + continue; + + if (axis.direction == "x") { + halign = "center"; + x = plotOffset.left + axis.p2c(tick.v); + if (axis.position == "bottom") { + y = box.top + box.padding; + } else { + y = box.top + box.height - box.padding; + valign = "bottom"; + } + } else { + valign = "middle"; + y = plotOffset.top + axis.p2c(tick.v); + if (axis.position == "left") { + x = box.left + box.width - box.padding; + halign = "right"; + } else { + x = box.left + box.padding; + } + } + + surface.addText(layer, x, y, tick.label, font, null, null, halign, valign); + } + }); + } + + function drawSeries(series) { + if (series.lines.show) + drawSeriesLines(series); + if (series.bars.show) + drawSeriesBars(series); + if (series.points.show) + drawSeriesPoints(series); + } + + function drawSeriesLines(series) { + function plotLine(datapoints, xoffset, yoffset, axisx, axisy) { + var points = datapoints.points, + ps = datapoints.pointsize, + prevx = null, prevy = null; + + ctx.beginPath(); + for (var i = ps; i < points.length; i += ps) { + var x1 = points[i - ps], y1 = points[i - ps + 1], + x2 = points[i], y2 = points[i + 1]; + + if (x1 == null || x2 == null) + continue; + + // clip with ymin + if (y1 <= y2 && y1 < axisy.min) { + if (y2 < axisy.min) + continue; // line segment is outside + // compute new intersection point + x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.min; + } + else if (y2 <= y1 && y2 < axisy.min) { + if (y1 < axisy.min) + continue; + x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.min; + } + + // clip with ymax + if (y1 >= y2 && y1 > axisy.max) { + if (y2 > axisy.max) + continue; + x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.max; + } + else if (y2 >= y1 && y2 > axisy.max) { + if (y1 > axisy.max) + continue; + x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.max; + } + + // clip with xmin + if (x1 <= x2 && x1 < axisx.min) { + if (x2 < axisx.min) + continue; + y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.min; + } + else if (x2 <= x1 && x2 < axisx.min) { + if (x1 < axisx.min) + continue; + y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.min; + } + + // clip with xmax + if (x1 >= x2 && x1 > axisx.max) { + if (x2 > axisx.max) + continue; + y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.max; + } + else if (x2 >= x1 && x2 > axisx.max) { + if (x1 > axisx.max) + continue; + y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.max; + } + + if (x1 != prevx || y1 != prevy) + ctx.moveTo(axisx.p2c(x1) + xoffset, axisy.p2c(y1) + yoffset); + + prevx = x2; + prevy = y2; + ctx.lineTo(axisx.p2c(x2) + xoffset, axisy.p2c(y2) + yoffset); + } + ctx.stroke(); + } + + function plotLineArea(datapoints, axisx, axisy) { + var points = datapoints.points, + ps = datapoints.pointsize, + bottom = Math.min(Math.max(0, axisy.min), axisy.max), + i = 0, top, areaOpen = false, + ypos = 1, segmentStart = 0, segmentEnd = 0; + + // we process each segment in two turns, first forward + // direction to sketch out top, then once we hit the + // end we go backwards to sketch the bottom + while (true) { + if (ps > 0 && i > points.length + ps) + break; + + i += ps; // ps is negative if going backwards + + var x1 = points[i - ps], + y1 = points[i - ps + ypos], + x2 = points[i], y2 = points[i + ypos]; + + if (areaOpen) { + if (ps > 0 && x1 != null && x2 == null) { + // at turning point + segmentEnd = i; + ps = -ps; + ypos = 2; + continue; + } + + if (ps < 0 && i == segmentStart + ps) { + // done with the reverse sweep + ctx.fill(); + areaOpen = false; + ps = -ps; + ypos = 1; + i = segmentStart = segmentEnd + ps; + continue; + } + } + + if (x1 == null || x2 == null) + continue; + + // clip x values + + // clip with xmin + if (x1 <= x2 && x1 < axisx.min) { + if (x2 < axisx.min) + continue; + y1 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.min; + } + else if (x2 <= x1 && x2 < axisx.min) { + if (x1 < axisx.min) + continue; + y2 = (axisx.min - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.min; + } + + // clip with xmax + if (x1 >= x2 && x1 > axisx.max) { + if (x2 > axisx.max) + continue; + y1 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x1 = axisx.max; + } + else if (x2 >= x1 && x2 > axisx.max) { + if (x1 > axisx.max) + continue; + y2 = (axisx.max - x1) / (x2 - x1) * (y2 - y1) + y1; + x2 = axisx.max; + } + + if (!areaOpen) { + // open area + ctx.beginPath(); + ctx.moveTo(axisx.p2c(x1), axisy.p2c(bottom)); + areaOpen = true; + } + + // now first check the case where both is outside + if (y1 >= axisy.max && y2 >= axisy.max) { + ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max)); + continue; + } + else if (y1 <= axisy.min && y2 <= axisy.min) { + ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min)); + continue; + } + + // else it's a bit more complicated, there might + // be a flat maxed out rectangle first, then a + // triangular cutout or reverse; to find these + // keep track of the current x values + var x1old = x1, x2old = x2; + + // clip the y values, without shortcutting, we + // go through all cases in turn + + // clip with ymin + if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) { + x1 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.min; + } + else if (y2 <= y1 && y2 < axisy.min && y1 >= axisy.min) { + x2 = (axisy.min - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.min; + } + + // clip with ymax + if (y1 >= y2 && y1 > axisy.max && y2 <= axisy.max) { + x1 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y1 = axisy.max; + } + else if (y2 >= y1 && y2 > axisy.max && y1 <= axisy.max) { + x2 = (axisy.max - y1) / (y2 - y1) * (x2 - x1) + x1; + y2 = axisy.max; + } + + // if the x value was changed we got a rectangle + // to fill + if (x1 != x1old) { + ctx.lineTo(axisx.p2c(x1old), axisy.p2c(y1)); + // it goes to (x1, y1), but we fill that below + } + + // fill triangular section, this sometimes result + // in redundant points if (x1, y1) hasn't changed + // from previous line to, but we just ignore that + ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + + // fill the other rectangle if it's there + if (x2 != x2old) { + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + ctx.lineTo(axisx.p2c(x2old), axisy.p2c(y2)); + } + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + ctx.lineJoin = "round"; + + var lw = series.lines.lineWidth, + sw = series.shadowSize; + // FIXME: consider another form of shadow when filling is turned on + if (lw > 0 && sw > 0) { + // draw shadow as a thick and thin line with transparency + ctx.lineWidth = sw; + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + // position shadow at angle from the mid of line + var angle = Math.PI/18; + plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/2), Math.cos(angle) * (lw/2 + sw/2), series.xaxis, series.yaxis); + ctx.lineWidth = sw/2; + plotLine(series.datapoints, Math.sin(angle) * (lw/2 + sw/4), Math.cos(angle) * (lw/2 + sw/4), series.xaxis, series.yaxis); + } + + ctx.lineWidth = lw; + ctx.strokeStyle = series.color; + var fillStyle = getFillStyle(series.lines, series.color, 0, plotHeight); + if (fillStyle) { + ctx.fillStyle = fillStyle; + plotLineArea(series.datapoints, series.xaxis, series.yaxis); + } + + if (lw > 0) + plotLine(series.datapoints, 0, 0, series.xaxis, series.yaxis); + ctx.restore(); + } + + function drawSeriesPoints(series) { + function plotPoints(datapoints, radius, fillStyle, offset, shadow, axisx, axisy, symbol) { + var points = datapoints.points, ps = datapoints.pointsize; + + for (var i = 0; i < points.length; i += ps) { + var x = points[i], y = points[i + 1]; + if (x == null || x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) + continue; + + ctx.beginPath(); + x = axisx.p2c(x); + y = axisy.p2c(y) + offset; + if (symbol == "circle") + ctx.arc(x, y, radius, 0, shadow ? Math.PI : Math.PI * 2, false); + else + symbol(ctx, x, y, radius, shadow); + ctx.closePath(); + + if (fillStyle) { + ctx.fillStyle = fillStyle; + ctx.fill(); + } + ctx.stroke(); + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + var lw = series.points.lineWidth, + sw = series.shadowSize, + radius = series.points.radius, + symbol = series.points.symbol; + + // If the user sets the line width to 0, we change it to a very + // small value. A line width of 0 seems to force the default of 1. + // Doing the conditional here allows the shadow setting to still be + // optional even with a lineWidth of 0. + + if( lw == 0 ) + lw = 0.0001; + + if (lw > 0 && sw > 0) { + // draw shadow in two steps + var w = sw / 2; + ctx.lineWidth = w; + ctx.strokeStyle = "rgba(0,0,0,0.1)"; + plotPoints(series.datapoints, radius, null, w + w/2, true, + series.xaxis, series.yaxis, symbol); + + ctx.strokeStyle = "rgba(0,0,0,0.2)"; + plotPoints(series.datapoints, radius, null, w/2, true, + series.xaxis, series.yaxis, symbol); + } + + ctx.lineWidth = lw; + ctx.strokeStyle = series.color; + plotPoints(series.datapoints, radius, + getFillStyle(series.points, series.color), 0, false, + series.xaxis, series.yaxis, symbol); + ctx.restore(); + } + + function drawBar(x, y, b, barLeft, barRight, fillStyleCallback, axisx, axisy, c, horizontal, lineWidth) { + var left, right, bottom, top, + drawLeft, drawRight, drawTop, drawBottom, + tmp; + + // in horizontal mode, we start the bar from the left + // instead of from the bottom so it appears to be + // horizontal rather than vertical + if (horizontal) { + drawBottom = drawRight = drawTop = true; + drawLeft = false; + left = b; + right = x; + top = y + barLeft; + bottom = y + barRight; + + // account for negative bars + if (right < left) { + tmp = right; + right = left; + left = tmp; + drawLeft = true; + drawRight = false; + } + } + else { + drawLeft = drawRight = drawTop = true; + drawBottom = false; + left = x + barLeft; + right = x + barRight; + bottom = b; + top = y; + + // account for negative bars + if (top < bottom) { + tmp = top; + top = bottom; + bottom = tmp; + drawBottom = true; + drawTop = false; + } + } + + // clip + if (right < axisx.min || left > axisx.max || + top < axisy.min || bottom > axisy.max) + return; + + if (left < axisx.min) { + left = axisx.min; + drawLeft = false; + } + + if (right > axisx.max) { + right = axisx.max; + drawRight = false; + } + + if (bottom < axisy.min) { + bottom = axisy.min; + drawBottom = false; + } + + if (top > axisy.max) { + top = axisy.max; + drawTop = false; + } + + left = axisx.p2c(left); + bottom = axisy.p2c(bottom); + right = axisx.p2c(right); + top = axisy.p2c(top); + + // fill the bar + if (fillStyleCallback) { + c.fillStyle = fillStyleCallback(bottom, top); + c.fillRect(left, top, right - left, bottom - top) + } + + // draw outline + if (lineWidth > 0 && (drawLeft || drawRight || drawTop || drawBottom)) { + c.beginPath(); + + // FIXME: inline moveTo is buggy with excanvas + c.moveTo(left, bottom); + if (drawLeft) + c.lineTo(left, top); + else + c.moveTo(left, top); + if (drawTop) + c.lineTo(right, top); + else + c.moveTo(right, top); + if (drawRight) + c.lineTo(right, bottom); + else + c.moveTo(right, bottom); + if (drawBottom) + c.lineTo(left, bottom); + else + c.moveTo(left, bottom); + c.stroke(); + } + } + + function drawSeriesBars(series) { + function plotBars(datapoints, barLeft, barRight, fillStyleCallback, axisx, axisy) { + var points = datapoints.points, ps = datapoints.pointsize; + + for (var i = 0; i < points.length; i += ps) { + if (points[i] == null) + continue; + drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal, series.bars.lineWidth); + } + } + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + // FIXME: figure out a way to add shadows (for instance along the right edge) + ctx.lineWidth = series.bars.lineWidth; + ctx.strokeStyle = series.color; + + var barLeft; + + switch (series.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -series.bars.barWidth; + break; + default: + barLeft = -series.bars.barWidth / 2; + } + + var fillStyleCallback = series.bars.fill ? function (bottom, top) { return getFillStyle(series.bars, series.color, bottom, top); } : null; + plotBars(series.datapoints, barLeft, barLeft + series.bars.barWidth, fillStyleCallback, series.xaxis, series.yaxis); + ctx.restore(); + } + + function getFillStyle(filloptions, seriesColor, bottom, top) { + var fill = filloptions.fill; + if (!fill) + return null; + + if (filloptions.fillColor) + return getColorOrGradient(filloptions.fillColor, bottom, top, seriesColor); + + var c = $.color.parse(seriesColor); + c.a = typeof fill == "number" ? fill : 0.4; + c.normalize(); + return c.toString(); + } + + function insertLegend() { + + if (options.legend.container != null) { + $(options.legend.container).html(""); + } else { + placeholder.find(".legend").remove(); + } + + if (!options.legend.show) { + return; + } + + var fragments = [], entries = [], rowStarted = false, + lf = options.legend.labelFormatter, s, label; + + // Build a list of legend entries, with each having a label and a color + + for (var i = 0; i < series.length; ++i) { + s = series[i]; + if (s.label) { + label = lf ? lf(s.label, s) : s.label; + if (label) { + entries.push({ + label: label, + color: s.color + }); + } + } + } + + // Sort the legend using either the default or a custom comparator + + if (options.legend.sorted) { + if ($.isFunction(options.legend.sorted)) { + entries.sort(options.legend.sorted); + } else if (options.legend.sorted == "reverse") { + entries.reverse(); + } else { + var ascending = options.legend.sorted != "descending"; + entries.sort(function(a, b) { + return a.label == b.label ? 0 : ( + (a.label < b.label) != ascending ? 1 : -1 // Logical XOR + ); + }); + } + } + + // Generate markup for the list of entries, in their final order + + for (var i = 0; i < entries.length; ++i) { + + var entry = entries[i]; + + if (i % options.legend.noColumns == 0) { + if (rowStarted) + fragments.push(''); + fragments.push(''); + rowStarted = true; + } + + fragments.push( + '
' + + '' + entry.label + '' + ); + } + + if (rowStarted) + fragments.push(''); + + if (fragments.length == 0) + return; + + var table = '' + fragments.join("") + '
'; + if (options.legend.container != null) + $(options.legend.container).html(table); + else { + var pos = "", + p = options.legend.position, + m = options.legend.margin; + if (m[0] == null) + m = [m, m]; + if (p.charAt(0) == "n") + pos += 'top:' + (m[1] + plotOffset.top) + 'px;'; + else if (p.charAt(0) == "s") + pos += 'bottom:' + (m[1] + plotOffset.bottom) + 'px;'; + if (p.charAt(1) == "e") + pos += 'right:' + (m[0] + plotOffset.right) + 'px;'; + else if (p.charAt(1) == "w") + pos += 'left:' + (m[0] + plotOffset.left) + 'px;'; + var legend = $('
' + table.replace('style="', 'style="position:absolute;' + pos +';') + '
').appendTo(placeholder); + if (options.legend.backgroundOpacity != 0.0) { + // put in the transparent background + // separately to avoid blended labels and + // label boxes + var c = options.legend.backgroundColor; + if (c == null) { + c = options.grid.backgroundColor; + if (c && typeof c == "string") + c = $.color.parse(c); + else + c = $.color.extract(legend, 'background-color'); + c.a = 1; + c = c.toString(); + } + var div = legend.children(); + $('
').prependTo(legend).css('opacity', options.legend.backgroundOpacity); + } + } + } + + + // interactive features + + var highlights = [], + redrawTimeout = null; + + // returns the data item the mouse is over, or null if none is found + function findNearbyItem(mouseX, mouseY, seriesFilter) { + var maxDistance = options.grid.mouseActiveRadius, + smallestDistance = maxDistance * maxDistance + 1, + item = null, foundPoint = false, i, j, ps; + + for (i = series.length - 1; i >= 0; --i) { + if (!seriesFilter(series[i])) + continue; + + var s = series[i], + axisx = s.xaxis, + axisy = s.yaxis, + points = s.datapoints.points, + mx = axisx.c2p(mouseX), // precompute some stuff to make the loop faster + my = axisy.c2p(mouseY), + maxx = maxDistance / axisx.scale, + maxy = maxDistance / axisy.scale; + + ps = s.datapoints.pointsize; + // with inverse transforms, we can't use the maxx/maxy + // optimization, sadly + if (axisx.options.inverseTransform) + maxx = Number.MAX_VALUE; + if (axisy.options.inverseTransform) + maxy = Number.MAX_VALUE; + + if (s.lines.show || s.points.show) { + for (j = 0; j < points.length; j += ps) { + var x = points[j], y = points[j + 1]; + if (x == null) + continue; + + // For points and lines, the cursor must be within a + // certain distance to the data point + if (x - mx > maxx || x - mx < -maxx || + y - my > maxy || y - my < -maxy) + continue; + + // We have to calculate distances in pixels, not in + // data units, because the scales of the axes may be different + var dx = Math.abs(axisx.p2c(x) - mouseX), + dy = Math.abs(axisy.p2c(y) - mouseY), + dist = dx * dx + dy * dy; // we save the sqrt + + // use <= to ensure last point takes precedence + // (last generally means on top of) + if (dist < smallestDistance) { + smallestDistance = dist; + item = [i, j / ps]; + } + } + } + + if (s.bars.show && !item) { // no other point can be nearby + + var barLeft, barRight; + + switch (s.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -s.bars.barWidth; + break; + default: + barLeft = -s.bars.barWidth / 2; + } + + barRight = barLeft + s.bars.barWidth; + + for (j = 0; j < points.length; j += ps) { + var x = points[j], y = points[j + 1], b = points[j + 2]; + if (x == null) + continue; + + // for a bar graph, the cursor must be inside the bar + if (series[i].bars.horizontal ? + (mx <= Math.max(b, x) && mx >= Math.min(b, x) && + my >= y + barLeft && my <= y + barRight) : + (mx >= x + barLeft && mx <= x + barRight && + my >= Math.min(b, y) && my <= Math.max(b, y))) + item = [i, j / ps]; + } + } + } + + if (item) { + i = item[0]; + j = item[1]; + ps = series[i].datapoints.pointsize; + + return { datapoint: series[i].datapoints.points.slice(j * ps, (j + 1) * ps), + dataIndex: j, + series: series[i], + seriesIndex: i }; + } + + return null; + } + + function onMouseMove(e) { + if (options.grid.hoverable) + triggerClickHoverEvent("plothover", e, + function (s) { return s["hoverable"] != false; }); + } + + function onMouseLeave(e) { + if (options.grid.hoverable) + triggerClickHoverEvent("plothover", e, + function (s) { return false; }); + } + + function onClick(e) { + triggerClickHoverEvent("plotclick", e, + function (s) { return s["clickable"] != false; }); + } + + // trigger click or hover event (they send the same parameters + // so we share their code) + function triggerClickHoverEvent(eventname, event, seriesFilter) { + var offset = eventHolder.offset(), + canvasX = event.pageX - offset.left - plotOffset.left, + canvasY = event.pageY - offset.top - plotOffset.top, + pos = canvasToAxisCoords({ left: canvasX, top: canvasY }); + + pos.pageX = event.pageX; + pos.pageY = event.pageY; + + var item = findNearbyItem(canvasX, canvasY, seriesFilter); + + if (item) { + // fill in mouse pos for any listeners out there + item.pageX = parseInt(item.series.xaxis.p2c(item.datapoint[0]) + offset.left + plotOffset.left, 10); + item.pageY = parseInt(item.series.yaxis.p2c(item.datapoint[1]) + offset.top + plotOffset.top, 10); + } + + if (options.grid.autoHighlight) { + // clear auto-highlights + for (var i = 0; i < highlights.length; ++i) { + var h = highlights[i]; + if (h.auto == eventname && + !(item && h.series == item.series && + h.point[0] == item.datapoint[0] && + h.point[1] == item.datapoint[1])) + unhighlight(h.series, h.point); + } + + if (item) + highlight(item.series, item.datapoint, eventname); + } + + placeholder.trigger(eventname, [ pos, item ]); + } + + function triggerRedrawOverlay() { + var t = options.interaction.redrawOverlayInterval; + if (t == -1) { // skip event queue + drawOverlay(); + return; + } + + if (!redrawTimeout) + redrawTimeout = setTimeout(drawOverlay, t); + } + + function drawOverlay() { + redrawTimeout = null; + + // draw highlights + octx.save(); + overlay.clear(); + octx.translate(plotOffset.left, plotOffset.top); + + var i, hi; + for (i = 0; i < highlights.length; ++i) { + hi = highlights[i]; + + if (hi.series.bars.show) + drawBarHighlight(hi.series, hi.point); + else + drawPointHighlight(hi.series, hi.point); + } + octx.restore(); + + executeHooks(hooks.drawOverlay, [octx]); + } + + function highlight(s, point, auto) { + if (typeof s == "number") + s = series[s]; + + if (typeof point == "number") { + var ps = s.datapoints.pointsize; + point = s.datapoints.points.slice(ps * point, ps * (point + 1)); + } + + var i = indexOfHighlight(s, point); + if (i == -1) { + highlights.push({ series: s, point: point, auto: auto }); + + triggerRedrawOverlay(); + } + else if (!auto) + highlights[i].auto = false; + } + + function unhighlight(s, point) { + if (s == null && point == null) { + highlights = []; + triggerRedrawOverlay(); + return; + } + + if (typeof s == "number") + s = series[s]; + + if (typeof point == "number") { + var ps = s.datapoints.pointsize; + point = s.datapoints.points.slice(ps * point, ps * (point + 1)); + } + + var i = indexOfHighlight(s, point); + if (i != -1) { + highlights.splice(i, 1); + + triggerRedrawOverlay(); + } + } + + function indexOfHighlight(s, p) { + for (var i = 0; i < highlights.length; ++i) { + var h = highlights[i]; + if (h.series == s && h.point[0] == p[0] + && h.point[1] == p[1]) + return i; + } + return -1; + } + + function drawPointHighlight(series, point) { + var x = point[0], y = point[1], + axisx = series.xaxis, axisy = series.yaxis, + highlightColor = (typeof series.highlightColor === "string") ? series.highlightColor : $.color.parse(series.color).scale('a', 0.5).toString(); + + if (x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) + return; + + var pointRadius = series.points.radius + series.points.lineWidth / 2; + octx.lineWidth = pointRadius; + octx.strokeStyle = highlightColor; + var radius = 1.5 * pointRadius; + x = axisx.p2c(x); + y = axisy.p2c(y); + + octx.beginPath(); + if (series.points.symbol == "circle") + octx.arc(x, y, radius, 0, 2 * Math.PI, false); + else + series.points.symbol(octx, x, y, radius, false); + octx.closePath(); + octx.stroke(); + } + + function drawBarHighlight(series, point) { + var highlightColor = (typeof series.highlightColor === "string") ? series.highlightColor : $.color.parse(series.color).scale('a', 0.5).toString(), + fillStyle = highlightColor, + barLeft; + + switch (series.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -series.bars.barWidth; + break; + default: + barLeft = -series.bars.barWidth / 2; + } + + octx.lineWidth = series.bars.lineWidth; + octx.strokeStyle = highlightColor; + + drawBar(point[0], point[1], point[2] || 0, barLeft, barLeft + series.bars.barWidth, + function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal, series.bars.lineWidth); + } + + function getColorOrGradient(spec, bottom, top, defaultColor) { + if (typeof spec == "string") + return spec; + else { + // assume this is a gradient spec; IE currently only + // supports a simple vertical gradient properly, so that's + // what we support too + var gradient = ctx.createLinearGradient(0, top, 0, bottom); + + for (var i = 0, l = spec.colors.length; i < l; ++i) { + var c = spec.colors[i]; + if (typeof c != "string") { + var co = $.color.parse(defaultColor); + if (c.brightness != null) + co = co.scale('rgb', c.brightness); + if (c.opacity != null) + co.a *= c.opacity; + c = co.toString(); + } + gradient.addColorStop(i / (l - 1), c); + } + + return gradient; + } + } + } + + // Add the plot function to the top level of the jQuery object + + $.plot = function(placeholder, data, options) { + //var t0 = new Date(); + var plot = new Plot($(placeholder), data, options, $.plot.plugins); + //(window.console ? console.log : alert)("time used (msecs): " + ((new Date()).getTime() - t0.getTime())); + return plot; + }; + + $.plot.version = "0.8.3"; + + $.plot.plugins = []; + + // Also add the plot function as a chainable property + + $.fn.plot = function(data, options) { + return this.each(function() { + $.plot(this, data, options); + }); + }; + + // round to nearby lower multiple of base + function floorInBase(n, base) { + return base * Math.floor(n / base); + } + +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.selection.js b/src/plugins/timelion/public/webpackShims/jquery.flot.selection.js new file mode 100644 index 0000000000000..c8707b30f4e6f --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.selection.js @@ -0,0 +1,360 @@ +/* Flot plugin for selecting regions of a plot. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin supports these options: + +selection: { + mode: null or "x" or "y" or "xy", + color: color, + shape: "round" or "miter" or "bevel", + minSize: number of pixels +} + +Selection support is enabled by setting the mode to one of "x", "y" or "xy". +In "x" mode, the user will only be able to specify the x range, similarly for +"y" mode. For "xy", the selection becomes a rectangle where both ranges can be +specified. "color" is color of the selection (if you need to change the color +later on, you can get to it with plot.getOptions().selection.color). "shape" +is the shape of the corners of the selection. + +"minSize" is the minimum size a selection can be in pixels. This value can +be customized to determine the smallest size a selection can be and still +have the selection rectangle be displayed. When customizing this value, the +fact that it refers to pixels, not axis units must be taken into account. +Thus, for example, if there is a bar graph in time mode with BarWidth set to 1 +minute, setting "minSize" to 1 will not make the minimum selection size 1 +minute, but rather 1 pixel. Note also that setting "minSize" to 0 will prevent +"plotunselected" events from being fired when the user clicks the mouse without +dragging. + +When selection support is enabled, a "plotselected" event will be emitted on +the DOM element you passed into the plot function. The event handler gets a +parameter with the ranges selected on the axes, like this: + + placeholder.bind( "plotselected", function( event, ranges ) { + alert("You selected " + ranges.xaxis.from + " to " + ranges.xaxis.to) + // similar for yaxis - with multiple axes, the extra ones are in + // x2axis, x3axis, ... + }); + +The "plotselected" event is only fired when the user has finished making the +selection. A "plotselecting" event is fired during the process with the same +parameters as the "plotselected" event, in case you want to know what's +happening while it's happening, + +A "plotunselected" event with no arguments is emitted when the user clicks the +mouse to remove the selection. As stated above, setting "minSize" to 0 will +destroy this behavior. + +The plugin also adds the following methods to the plot object: + +- setSelection( ranges, preventEvent ) + + Set the selection rectangle. The passed in ranges is on the same form as + returned in the "plotselected" event. If the selection mode is "x", you + should put in either an xaxis range, if the mode is "y" you need to put in + an yaxis range and both xaxis and yaxis if the selection mode is "xy", like + this: + + setSelection({ xaxis: { from: 0, to: 10 }, yaxis: { from: 40, to: 60 } }); + + setSelection will trigger the "plotselected" event when called. If you don't + want that to happen, e.g. if you're inside a "plotselected" handler, pass + true as the second parameter. If you are using multiple axes, you can + specify the ranges on any of those, e.g. as x2axis/x3axis/... instead of + xaxis, the plugin picks the first one it sees. + +- clearSelection( preventEvent ) + + Clear the selection rectangle. Pass in true to avoid getting a + "plotunselected" event. + +- getSelection() + + Returns the current selection in the same format as the "plotselected" + event. If there's currently no selection, the function returns null. + +*/ + +(function ($) { + function init(plot) { + var selection = { + first: { x: -1, y: -1}, second: { x: -1, y: -1}, + show: false, + active: false + }; + + // FIXME: The drag handling implemented here should be + // abstracted out, there's some similar code from a library in + // the navigation plugin, this should be massaged a bit to fit + // the Flot cases here better and reused. Doing this would + // make this plugin much slimmer. + var savedhandlers = {}; + + var mouseUpHandler = null; + + function onMouseMove(e) { + if (selection.active) { + updateSelection(e); + + plot.getPlaceholder().trigger("plotselecting", [ getSelection() ]); + } + } + + function onMouseDown(e) { + if (e.which != 1) // only accept left-click + return; + + // cancel out any text selections + document.body.focus(); + + // prevent text selection and drag in old-school browsers + if (document.onselectstart !== undefined && savedhandlers.onselectstart == null) { + savedhandlers.onselectstart = document.onselectstart; + document.onselectstart = function () { return false; }; + } + if (document.ondrag !== undefined && savedhandlers.ondrag == null) { + savedhandlers.ondrag = document.ondrag; + document.ondrag = function () { return false; }; + } + + setSelectionPos(selection.first, e); + + selection.active = true; + + // this is a bit silly, but we have to use a closure to be + // able to whack the same handler again + mouseUpHandler = function (e) { onMouseUp(e); }; + + $(document).one("mouseup", mouseUpHandler); + } + + function onMouseUp(e) { + mouseUpHandler = null; + + // revert drag stuff for old-school browsers + if (document.onselectstart !== undefined) + document.onselectstart = savedhandlers.onselectstart; + if (document.ondrag !== undefined) + document.ondrag = savedhandlers.ondrag; + + // no more dragging + selection.active = false; + updateSelection(e); + + if (selectionIsSane()) + triggerSelectedEvent(); + else { + // this counts as a clear + plot.getPlaceholder().trigger("plotunselected", [ ]); + plot.getPlaceholder().trigger("plotselecting", [ null ]); + } + + return false; + } + + function getSelection() { + if (!selectionIsSane()) + return null; + + if (!selection.show) return null; + + var r = {}, c1 = selection.first, c2 = selection.second; + $.each(plot.getAxes(), function (name, axis) { + if (axis.used) { + var p1 = axis.c2p(c1[axis.direction]), p2 = axis.c2p(c2[axis.direction]); + r[name] = { from: Math.min(p1, p2), to: Math.max(p1, p2) }; + } + }); + return r; + } + + function triggerSelectedEvent() { + var r = getSelection(); + + plot.getPlaceholder().trigger("plotselected", [ r ]); + + // backwards-compat stuff, to be removed in future + if (r.xaxis && r.yaxis) + plot.getPlaceholder().trigger("selected", [ { x1: r.xaxis.from, y1: r.yaxis.from, x2: r.xaxis.to, y2: r.yaxis.to } ]); + } + + function clamp(min, value, max) { + return value < min ? min: (value > max ? max: value); + } + + function setSelectionPos(pos, e) { + var o = plot.getOptions(); + var offset = plot.getPlaceholder().offset(); + var plotOffset = plot.getPlotOffset(); + pos.x = clamp(0, e.pageX - offset.left - plotOffset.left, plot.width()); + pos.y = clamp(0, e.pageY - offset.top - plotOffset.top, plot.height()); + + if (o.selection.mode == "y") + pos.x = pos == selection.first ? 0 : plot.width(); + + if (o.selection.mode == "x") + pos.y = pos == selection.first ? 0 : plot.height(); + } + + function updateSelection(pos) { + if (pos.pageX == null) + return; + + setSelectionPos(selection.second, pos); + if (selectionIsSane()) { + selection.show = true; + plot.triggerRedrawOverlay(); + } + else + clearSelection(true); + } + + function clearSelection(preventEvent) { + if (selection.show) { + selection.show = false; + plot.triggerRedrawOverlay(); + if (!preventEvent) + plot.getPlaceholder().trigger("plotunselected", [ ]); + } + } + + // function taken from markings support in Flot + function extractRange(ranges, coord) { + var axis, from, to, key, axes = plot.getAxes(); + + for (var k in axes) { + axis = axes[k]; + if (axis.direction == coord) { + key = coord + axis.n + "axis"; + if (!ranges[key] && axis.n == 1) + key = coord + "axis"; // support x1axis as xaxis + if (ranges[key]) { + from = ranges[key].from; + to = ranges[key].to; + break; + } + } + } + + // backwards-compat stuff - to be removed in future + if (!ranges[key]) { + axis = coord == "x" ? plot.getXAxes()[0] : plot.getYAxes()[0]; + from = ranges[coord + "1"]; + to = ranges[coord + "2"]; + } + + // auto-reverse as an added bonus + if (from != null && to != null && from > to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; + } + + function setSelection(ranges, preventEvent) { + var axis, range, o = plot.getOptions(); + + if (o.selection.mode == "y") { + selection.first.x = 0; + selection.second.x = plot.width(); + } + else { + range = extractRange(ranges, "x"); + + selection.first.x = range.axis.p2c(range.from); + selection.second.x = range.axis.p2c(range.to); + } + + if (o.selection.mode == "x") { + selection.first.y = 0; + selection.second.y = plot.height(); + } + else { + range = extractRange(ranges, "y"); + + selection.first.y = range.axis.p2c(range.from); + selection.second.y = range.axis.p2c(range.to); + } + + selection.show = true; + plot.triggerRedrawOverlay(); + if (!preventEvent && selectionIsSane()) + triggerSelectedEvent(); + } + + function selectionIsSane() { + var minSize = plot.getOptions().selection.minSize; + return Math.abs(selection.second.x - selection.first.x) >= minSize && + Math.abs(selection.second.y - selection.first.y) >= minSize; + } + + plot.clearSelection = clearSelection; + plot.setSelection = setSelection; + plot.getSelection = getSelection; + + plot.hooks.bindEvents.push(function(plot, eventHolder) { + var o = plot.getOptions(); + if (o.selection.mode != null) { + eventHolder.mousemove(onMouseMove); + eventHolder.mousedown(onMouseDown); + } + }); + + + plot.hooks.drawOverlay.push(function (plot, ctx) { + // draw selection + if (selection.show && selectionIsSane()) { + var plotOffset = plot.getPlotOffset(); + var o = plot.getOptions(); + + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + var c = $.color.parse(o.selection.color); + + ctx.strokeStyle = c.scale('a', 0.8).toString(); + ctx.lineWidth = 1; + ctx.lineJoin = o.selection.shape; + ctx.fillStyle = c.scale('a', 0.4).toString(); + + var x = Math.min(selection.first.x, selection.second.x) + 0.5, + y = Math.min(selection.first.y, selection.second.y) + 0.5, + w = Math.abs(selection.second.x - selection.first.x) - 1, + h = Math.abs(selection.second.y - selection.first.y) - 1; + + ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + + ctx.restore(); + } + }); + + plot.hooks.shutdown.push(function (plot, eventHolder) { + eventHolder.unbind("mousemove", onMouseMove); + eventHolder.unbind("mousedown", onMouseDown); + + if (mouseUpHandler) + $(document).unbind("mouseup", mouseUpHandler); + }); + + } + + $.plot.plugins.push({ + init: init, + options: { + selection: { + mode: null, // one of null, "x", "y" or "xy" + color: "#e8cfac", + shape: "round", // one of "round", "miter", or "bevel" + minSize: 5 // minimum number of pixels + } + }, + name: 'selection', + version: '1.1' + }); +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.stack.js b/src/plugins/timelion/public/webpackShims/jquery.flot.stack.js new file mode 100644 index 0000000000000..0d91c0f3c0160 --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.stack.js @@ -0,0 +1,188 @@ +/* Flot plugin for stacking data sets rather than overlaying them. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The plugin assumes the data is sorted on x (or y if stacking horizontally). +For line charts, it is assumed that if a line has an undefined gap (from a +null point), then the line above it should have the same gap - insert zeros +instead of "null" if you want another behaviour. This also holds for the start +and end of the chart. Note that stacking a mix of positive and negative values +in most instances doesn't make sense (so it looks weird). + +Two or more series are stacked when their "stack" attribute is set to the same +key (which can be any number or string or just "true"). To specify the default +stack, you can set the stack option like this: + + series: { + stack: null/false, true, or a key (number/string) + } + +You can also specify it for a single series, like this: + + $.plot( $("#placeholder"), [{ + data: [ ... ], + stack: true + }]) + +The stacking order is determined by the order of the data series in the array +(later series end up on top of the previous). + +Internally, the plugin modifies the datapoints in each series, adding an +offset to the y value. For line series, extra data points are inserted through +interpolation. If there's a second y value, it's also adjusted (e.g for bar +charts or filled areas). + +*/ + +(function ($) { + var options = { + series: { stack: null } // or number/string + }; + + function init(plot) { + function findMatchingSeries(s, allseries) { + var res = null; + for (var i = 0; i < allseries.length; ++i) { + if (s == allseries[i]) + break; + + if (allseries[i].stack == s.stack) + res = allseries[i]; + } + + return res; + } + + function stackData(plot, s, datapoints) { + if (s.stack == null || s.stack === false) + return; + + var other = findMatchingSeries(s, plot.getData()); + if (!other) + return; + + var ps = datapoints.pointsize, + points = datapoints.points, + otherps = other.datapoints.pointsize, + otherpoints = other.datapoints.points, + newpoints = [], + px, py, intery, qx, qy, bottom, + withlines = s.lines.show, + horizontal = s.bars.horizontal, + withbottom = ps > 2 && (horizontal ? datapoints.format[2].x : datapoints.format[2].y), + withsteps = withlines && s.lines.steps, + fromgap = true, + keyOffset = horizontal ? 1 : 0, + accumulateOffset = horizontal ? 0 : 1, + i = 0, j = 0, l, m; + + while (true) { + if (i >= points.length) + break; + + l = newpoints.length; + + if (points[i] == null) { + // copy gaps + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + i += ps; + } + else if (j >= otherpoints.length) { + // for lines, we can't use the rest of the points + if (!withlines) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + } + i += ps; + } + else if (otherpoints[j] == null) { + // oops, got a gap + for (m = 0; m < ps; ++m) + newpoints.push(null); + fromgap = true; + j += otherps; + } + else { + // cases where we actually got two points + px = points[i + keyOffset]; + py = points[i + accumulateOffset]; + qx = otherpoints[j + keyOffset]; + qy = otherpoints[j + accumulateOffset]; + bottom = 0; + + if (px == qx) { + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + newpoints[l + accumulateOffset] += qy; + bottom = qy; + + i += ps; + j += otherps; + } + else if (px > qx) { + // we got past point below, might need to + // insert interpolated extra point + if (withlines && i > 0 && points[i - ps] != null) { + intery = py + (points[i - ps + accumulateOffset] - py) * (qx - px) / (points[i - ps + keyOffset] - px); + newpoints.push(qx); + newpoints.push(intery + qy); + for (m = 2; m < ps; ++m) + newpoints.push(points[i + m]); + bottom = qy; + } + + j += otherps; + } + else { // px < qx + if (fromgap && withlines) { + // if we come from a gap, we just skip this point + i += ps; + continue; + } + + for (m = 0; m < ps; ++m) + newpoints.push(points[i + m]); + + // we might be able to interpolate a point below, + // this can give us a better y + if (withlines && j > 0 && otherpoints[j - otherps] != null) + bottom = qy + (otherpoints[j - otherps + accumulateOffset] - qy) * (px - qx) / (otherpoints[j - otherps + keyOffset] - qx); + + newpoints[l + accumulateOffset] += bottom; + + i += ps; + } + + fromgap = false; + + if (l != newpoints.length && withbottom) + newpoints[l + 2] += bottom; + } + + // maintain the line steps invariant + if (withsteps && l != newpoints.length && l > 0 + && newpoints[l] != null + && newpoints[l] != newpoints[l - ps] + && newpoints[l + 1] != newpoints[l - ps + 1]) { + for (m = 0; m < ps; ++m) + newpoints[l + ps + m] = newpoints[l + m]; + newpoints[l + 1] = newpoints[l - ps + 1]; + } + } + + datapoints.points = newpoints; + } + + plot.hooks.processDatapoints.push(stackData); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'stack', + version: '1.2' + }); +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.symbol.js b/src/plugins/timelion/public/webpackShims/jquery.flot.symbol.js new file mode 100644 index 0000000000000..79f634971b6fa --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.symbol.js @@ -0,0 +1,71 @@ +/* Flot plugin that adds some extra symbols for plotting points. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +The symbols are accessed as strings through the standard symbol options: + + series: { + points: { + symbol: "square" // or "diamond", "triangle", "cross" + } + } + +*/ + +(function ($) { + function processRawData(plot, series, datapoints) { + // we normalize the area of each symbol so it is approximately the + // same as a circle of the given radius + + var handlers = { + square: function (ctx, x, y, radius, shadow) { + // pi * r^2 = (2s)^2 => s = r * sqrt(pi)/2 + var size = radius * Math.sqrt(Math.PI) / 2; + ctx.rect(x - size, y - size, size + size, size + size); + }, + diamond: function (ctx, x, y, radius, shadow) { + // pi * r^2 = 2s^2 => s = r * sqrt(pi/2) + var size = radius * Math.sqrt(Math.PI / 2); + ctx.moveTo(x - size, y); + ctx.lineTo(x, y - size); + ctx.lineTo(x + size, y); + ctx.lineTo(x, y + size); + ctx.lineTo(x - size, y); + }, + triangle: function (ctx, x, y, radius, shadow) { + // pi * r^2 = 1/2 * s^2 * sin (pi / 3) => s = r * sqrt(2 * pi / sin(pi / 3)) + var size = radius * Math.sqrt(2 * Math.PI / Math.sin(Math.PI / 3)); + var height = size * Math.sin(Math.PI / 3); + ctx.moveTo(x - size/2, y + height/2); + ctx.lineTo(x + size/2, y + height/2); + if (!shadow) { + ctx.lineTo(x, y - height/2); + ctx.lineTo(x - size/2, y + height/2); + } + }, + cross: function (ctx, x, y, radius, shadow) { + // pi * r^2 = (2s)^2 => s = r * sqrt(pi)/2 + var size = radius * Math.sqrt(Math.PI) / 2; + ctx.moveTo(x - size, y - size); + ctx.lineTo(x + size, y + size); + ctx.moveTo(x - size, y + size); + ctx.lineTo(x + size, y - size); + } + }; + + var s = series.points.symbol; + if (handlers[s]) + series.points.symbol = handlers[s]; + } + + function init(plot) { + plot.hooks.processDatapoints.push(processRawData); + } + + $.plot.plugins.push({ + init: init, + name: 'symbols', + version: '1.0' + }); +})(jQuery); diff --git a/src/plugins/timelion/public/webpackShims/jquery.flot.time.js b/src/plugins/timelion/public/webpackShims/jquery.flot.time.js new file mode 100644 index 0000000000000..34c1d121259a2 --- /dev/null +++ b/src/plugins/timelion/public/webpackShims/jquery.flot.time.js @@ -0,0 +1,432 @@ +/* Pretty handling of time axes. + +Copyright (c) 2007-2014 IOLA and Ole Laursen. +Licensed under the MIT license. + +Set axis.mode to "time" to enable. See the section "Time series data" in +API.txt for details. + +*/ + +(function($) { + + var options = { + xaxis: { + timezone: null, // "browser" for local to the client or timezone for timezone-js + timeformat: null, // format string to use + twelveHourClock: false, // 12 or 24 time in time mode + monthNames: null // list of names of months + } + }; + + // round to nearby lower multiple of base + + function floorInBase(n, base) { + return base * Math.floor(n / base); + } + + // Returns a string with the date d formatted according to fmt. + // A subset of the Open Group's strftime format is supported. + + function formatDate(d, fmt, monthNames, dayNames) { + + if (typeof d.strftime == "function") { + return d.strftime(fmt); + } + + var leftPad = function(n, pad) { + n = "" + n; + pad = "" + (pad == null ? "0" : pad); + return n.length == 1 ? pad + n : n; + }; + + var r = []; + var escape = false; + var hours = d.getHours(); + var isAM = hours < 12; + + if (monthNames == null) { + monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + } + + if (dayNames == null) { + dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + } + + var hours12; + + if (hours > 12) { + hours12 = hours - 12; + } else if (hours == 0) { + hours12 = 12; + } else { + hours12 = hours; + } + + for (var i = 0; i < fmt.length; ++i) { + + var c = fmt.charAt(i); + + if (escape) { + switch (c) { + case 'a': c = "" + dayNames[d.getDay()]; break; + case 'b': c = "" + monthNames[d.getMonth()]; break; + case 'd': c = leftPad(d.getDate()); break; + case 'e': c = leftPad(d.getDate(), " "); break; + case 'h': // For back-compat with 0.7; remove in 1.0 + case 'H': c = leftPad(hours); break; + case 'I': c = leftPad(hours12); break; + case 'l': c = leftPad(hours12, " "); break; + case 'm': c = leftPad(d.getMonth() + 1); break; + case 'M': c = leftPad(d.getMinutes()); break; + // quarters not in Open Group's strftime specification + case 'q': + c = "" + (Math.floor(d.getMonth() / 3) + 1); break; + case 'S': c = leftPad(d.getSeconds()); break; + case 'y': c = leftPad(d.getFullYear() % 100); break; + case 'Y': c = "" + d.getFullYear(); break; + case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break; + case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break; + case 'w': c = "" + d.getDay(); break; + } + r.push(c); + escape = false; + } else { + if (c == "%") { + escape = true; + } else { + r.push(c); + } + } + } + + return r.join(""); + } + + // To have a consistent view of time-based data independent of which time + // zone the client happens to be in we need a date-like object independent + // of time zones. This is done through a wrapper that only calls the UTC + // versions of the accessor methods. + + function makeUtcWrapper(d) { + + function addProxyMethod(sourceObj, sourceMethod, targetObj, targetMethod) { + sourceObj[sourceMethod] = function() { + return targetObj[targetMethod].apply(targetObj, arguments); + }; + }; + + var utc = { + date: d + }; + + // support strftime, if found + + if (d.strftime != undefined) { + addProxyMethod(utc, "strftime", d, "strftime"); + } + + addProxyMethod(utc, "getTime", d, "getTime"); + addProxyMethod(utc, "setTime", d, "setTime"); + + var props = ["Date", "Day", "FullYear", "Hours", "Milliseconds", "Minutes", "Month", "Seconds"]; + + for (var p = 0; p < props.length; p++) { + addProxyMethod(utc, "get" + props[p], d, "getUTC" + props[p]); + addProxyMethod(utc, "set" + props[p], d, "setUTC" + props[p]); + } + + return utc; + }; + + // select time zone strategy. This returns a date-like object tied to the + // desired timezone + + function dateGenerator(ts, opts) { + if (opts.timezone == "browser") { + return new Date(ts); + } else if (!opts.timezone || opts.timezone == "utc") { + return makeUtcWrapper(new Date(ts)); + } else if (typeof timezoneJS != "undefined" && typeof timezoneJS.Date != "undefined") { + var d = new timezoneJS.Date(); + // timezone-js is fickle, so be sure to set the time zone before + // setting the time. + d.setTimezone(opts.timezone); + d.setTime(ts); + return d; + } else { + return makeUtcWrapper(new Date(ts)); + } + } + + // map of app. size of time units in milliseconds + + var timeUnitSize = { + "second": 1000, + "minute": 60 * 1000, + "hour": 60 * 60 * 1000, + "day": 24 * 60 * 60 * 1000, + "month": 30 * 24 * 60 * 60 * 1000, + "quarter": 3 * 30 * 24 * 60 * 60 * 1000, + "year": 365.2425 * 24 * 60 * 60 * 1000 + }; + + // the allowed tick sizes, after 1 year we use + // an integer algorithm + + var baseSpec = [ + [1, "second"], [2, "second"], [5, "second"], [10, "second"], + [30, "second"], + [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], + [30, "minute"], + [1, "hour"], [2, "hour"], [4, "hour"], + [8, "hour"], [12, "hour"], + [1, "day"], [2, "day"], [3, "day"], + [0.25, "month"], [0.5, "month"], [1, "month"], + [2, "month"] + ]; + + // we don't know which variant(s) we'll need yet, but generating both is + // cheap + + var specMonths = baseSpec.concat([[3, "month"], [6, "month"], + [1, "year"]]); + var specQuarters = baseSpec.concat([[1, "quarter"], [2, "quarter"], + [1, "year"]]); + + function init(plot) { + plot.hooks.processOptions.push(function (plot, options) { + $.each(plot.getAxes(), function(axisName, axis) { + + var opts = axis.options; + + if (opts.mode == "time") { + axis.tickGenerator = function(axis) { + + var ticks = []; + var d = dateGenerator(axis.min, opts); + var minSize = 0; + + // make quarter use a possibility if quarters are + // mentioned in either of these options + + var spec = (opts.tickSize && opts.tickSize[1] === + "quarter") || + (opts.minTickSize && opts.minTickSize[1] === + "quarter") ? specQuarters : specMonths; + + if (opts.minTickSize != null) { + if (typeof opts.tickSize == "number") { + minSize = opts.tickSize; + } else { + minSize = opts.minTickSize[0] * timeUnitSize[opts.minTickSize[1]]; + } + } + + for (var i = 0; i < spec.length - 1; ++i) { + if (axis.delta < (spec[i][0] * timeUnitSize[spec[i][1]] + + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 + && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) { + break; + } + } + + var size = spec[i][0]; + var unit = spec[i][1]; + + // special-case the possibility of several years + + if (unit == "year") { + + // if given a minTickSize in years, just use it, + // ensuring that it's an integer + + if (opts.minTickSize != null && opts.minTickSize[1] == "year") { + size = Math.floor(opts.minTickSize[0]); + } else { + + var magn = Math.pow(10, Math.floor(Math.log(axis.delta / timeUnitSize.year) / Math.LN10)); + var norm = (axis.delta / timeUnitSize.year) / magn; + + if (norm < 1.5) { + size = 1; + } else if (norm < 3) { + size = 2; + } else if (norm < 7.5) { + size = 5; + } else { + size = 10; + } + + size *= magn; + } + + // minimum size for years is 1 + + if (size < 1) { + size = 1; + } + } + + axis.tickSize = opts.tickSize || [size, unit]; + var tickSize = axis.tickSize[0]; + unit = axis.tickSize[1]; + + var step = tickSize * timeUnitSize[unit]; + + if (unit == "second") { + d.setSeconds(floorInBase(d.getSeconds(), tickSize)); + } else if (unit == "minute") { + d.setMinutes(floorInBase(d.getMinutes(), tickSize)); + } else if (unit == "hour") { + d.setHours(floorInBase(d.getHours(), tickSize)); + } else if (unit == "month") { + d.setMonth(floorInBase(d.getMonth(), tickSize)); + } else if (unit == "quarter") { + d.setMonth(3 * floorInBase(d.getMonth() / 3, + tickSize)); + } else if (unit == "year") { + d.setFullYear(floorInBase(d.getFullYear(), tickSize)); + } + + // reset smaller components + + d.setMilliseconds(0); + + if (step >= timeUnitSize.minute) { + d.setSeconds(0); + } + if (step >= timeUnitSize.hour) { + d.setMinutes(0); + } + if (step >= timeUnitSize.day) { + d.setHours(0); + } + if (step >= timeUnitSize.day * 4) { + d.setDate(1); + } + if (step >= timeUnitSize.month * 2) { + d.setMonth(floorInBase(d.getMonth(), 3)); + } + if (step >= timeUnitSize.quarter * 2) { + d.setMonth(floorInBase(d.getMonth(), 6)); + } + if (step >= timeUnitSize.year) { + d.setMonth(0); + } + + var carry = 0; + var v = Number.NaN; + var prev; + + do { + + prev = v; + v = d.getTime(); + ticks.push(v); + + if (unit == "month" || unit == "quarter") { + if (tickSize < 1) { + + // a bit complicated - we'll divide the + // month/quarter up but we need to take + // care of fractions so we don't end up in + // the middle of a day + + d.setDate(1); + var start = d.getTime(); + d.setMonth(d.getMonth() + + (unit == "quarter" ? 3 : 1)); + var end = d.getTime(); + d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); + carry = d.getHours(); + d.setHours(0); + } else { + d.setMonth(d.getMonth() + + tickSize * (unit == "quarter" ? 3 : 1)); + } + } else if (unit == "year") { + d.setFullYear(d.getFullYear() + tickSize); + } else { + d.setTime(v + step); + } + } while (v < axis.max && v != prev); + + return ticks; + }; + + axis.tickFormatter = function (v, axis) { + + var d = dateGenerator(v, axis.options); + + // first check global format + + if (opts.timeformat != null) { + return formatDate(d, opts.timeformat, opts.monthNames, opts.dayNames); + } + + // possibly use quarters if quarters are mentioned in + // any of these places + + var useQuarters = (axis.options.tickSize && + axis.options.tickSize[1] == "quarter") || + (axis.options.minTickSize && + axis.options.minTickSize[1] == "quarter"); + + var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; + var span = axis.max - axis.min; + var suffix = (opts.twelveHourClock) ? " %p" : ""; + var hourCode = (opts.twelveHourClock) ? "%I" : "%H"; + var fmt; + + if (t < timeUnitSize.minute) { + fmt = hourCode + ":%M:%S" + suffix; + } else if (t < timeUnitSize.day) { + if (span < 2 * timeUnitSize.day) { + fmt = hourCode + ":%M" + suffix; + } else { + fmt = "%b %d " + hourCode + ":%M" + suffix; + } + } else if (t < timeUnitSize.month) { + fmt = "%b %d"; + } else if ((useQuarters && t < timeUnitSize.quarter) || + (!useQuarters && t < timeUnitSize.year)) { + if (span < timeUnitSize.year) { + fmt = "%b"; + } else { + fmt = "%b %Y"; + } + } else if (useQuarters && t < timeUnitSize.year) { + if (span < timeUnitSize.year) { + fmt = "Q%q"; + } else { + fmt = "Q%q %Y"; + } + } else { + fmt = "%Y"; + } + + var rt = formatDate(d, fmt, opts.monthNames, opts.dayNames); + + return rt; + }; + } + }); + }); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'time', + version: '1.0' + }); + + // Time-axis support used to be in Flot core, which exposed the + // formatDate function on the plot object. Various plugins depend + // on the function, so we need to re-expose it here. + + $.plot.formatDate = formatDate; + $.plot.dateGenerator = dateGenerator; + +})(jQuery); diff --git a/src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs_directive.js b/src/plugins/timelion/server/config.ts similarity index 67% rename from src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs_directive.js rename to src/plugins/timelion/server/config.ts index 7e77027f750c6..16e559761e9ad 100644 --- a/src/legacy/core_plugins/timelion/public/components/timelionhelp_tabs_directive.js +++ b/src/plugins/timelion/server/config.ts @@ -17,14 +17,16 @@ * under the License. */ -import 'ngreact'; +import { schema, TypeOf } from '@kbn/config-schema'; -import { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/timelion', ['react']); +export const configSchema = { + schema: schema.object({ + graphiteUrls: schema.maybe(schema.arrayOf(schema.string())), + enabled: schema.boolean({ defaultValue: true }), + ui: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), + }), +}; -import { TimelionHelpTabs } from './timelionhelp_tabs'; - -module.directive('timelionHelpTabs', function (reactDirective) { - return reactDirective(wrapInI18nContext(TimelionHelpTabs), undefined, { restrict: 'E' }); -}); +export type TimelionConfigType = TypeOf; diff --git a/src/plugins/timelion/server/index.ts b/src/plugins/timelion/server/index.ts index 5bb0c9e2567e0..28c5709d89132 100644 --- a/src/plugins/timelion/server/index.ts +++ b/src/plugins/timelion/server/index.ts @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { PluginInitializerContext } from 'src/core/server'; +import { PluginInitializerContext, PluginConfigDescriptor } from 'src/core/server'; import { TimelionPlugin } from './plugin'; +import { configSchema, TimelionConfigType } from './config'; -export const plugin = (context: PluginInitializerContext) => new TimelionPlugin(context); +export const config: PluginConfigDescriptor = { + schema: configSchema.schema, +}; + +export const plugin = (context: PluginInitializerContext) => + new TimelionPlugin(context); diff --git a/src/plugins/timelion/server/plugin.ts b/src/plugins/timelion/server/plugin.ts index 015f0c573e531..3e4cd5467dd44 100644 --- a/src/plugins/timelion/server/plugin.ts +++ b/src/plugins/timelion/server/plugin.ts @@ -16,12 +16,21 @@ * specific language governing permissions and limitations * under the License. */ + import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import { TimelionConfigType } from './config'; export class TimelionPlugin implements Plugin { - constructor(context: PluginInitializerContext) {} + constructor(context: PluginInitializerContext) {} - setup(core: CoreSetup) { + public setup(core: CoreSetup) { + core.capabilities.registerProvider(() => ({ + timelion: { + save: true, + }, + })); core.savedObjects.registerType({ name: 'timelion-sheet', hidden: false, @@ -46,6 +55,42 @@ export class TimelionPlugin implements Plugin { }, }, }); + + core.uiSettings.register({ + 'timelion:showTutorial': { + name: i18n.translate('timelion.uiSettings.showTutorialLabel', { + defaultMessage: 'Show tutorial', + }), + value: false, + description: i18n.translate('timelion.uiSettings.showTutorialDescription', { + defaultMessage: 'Should I show the tutorial by default when entering the timelion app?', + }), + category: ['timelion'], + schema: schema.boolean(), + }, + 'timelion:default_columns': { + name: i18n.translate('timelion.uiSettings.defaultColumnsLabel', { + defaultMessage: 'Default columns', + }), + value: 2, + description: i18n.translate('timelion.uiSettings.defaultColumnsDescription', { + defaultMessage: 'Number of columns on a timelion sheet by default', + }), + category: ['timelion'], + schema: schema.number(), + }, + 'timelion:default_rows': { + name: i18n.translate('timelion.uiSettings.defaultRowsLabel', { + defaultMessage: 'Default rows', + }), + value: 2, + description: i18n.translate('timelion.uiSettings.defaultRowsDescription', { + defaultMessage: 'Number of rows on a timelion sheet by default', + }), + category: ['timelion'], + schema: schema.number(), + }, + }); } start() {} stop() {} diff --git a/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap b/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap index a8fe25582717c..dc6571de969f0 100644 --- a/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap +++ b/src/plugins/vis_type_table/public/__snapshots__/table_vis_fn.test.ts.snap @@ -30,6 +30,7 @@ Object { "columnIndex": null, "direction": null, }, + "title": "My Chart title", "totalFunc": "sum", }, "visData": Object { diff --git a/src/plugins/vis_type_table/public/agg_table/agg_table.js b/src/plugins/vis_type_table/public/agg_table/agg_table.js index bd7626a493338..1e98a06c2a6a9 100644 --- a/src/plugins/vis_type_table/public/agg_table/agg_table.js +++ b/src/plugins/vis_type_table/public/agg_table/agg_table.js @@ -116,7 +116,7 @@ export function KbnAggTable(config, RecursionHelper) { return; } - self.csv.filename = (exportTitle || table.title || 'table') + '.csv'; + self.csv.filename = (exportTitle || table.title || 'unsaved') + '.csv'; $scope.rows = table.rows; $scope.formattedColumns = []; diff --git a/src/plugins/vis_type_table/public/table_vis_fn.test.ts b/src/plugins/vis_type_table/public/table_vis_fn.test.ts index 9accf8950d910..6cb3f3e0f3779 100644 --- a/src/plugins/vis_type_table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_type_table/public/table_vis_fn.test.ts @@ -37,6 +37,7 @@ describe('interpreter/functions#table', () => { columns: [{ id: 'col-0-1', name: 'Count' }], }; const visConfig = { + title: 'My Chart title', perPage: 10, showPartialRows: false, showMetricsAtAllLevels: false, diff --git a/src/plugins/vis_type_table/public/vis_controller.ts b/src/plugins/vis_type_table/public/vis_controller.ts index a5086e0c9a2d8..d87812b9f5d69 100644 --- a/src/plugins/vis_type_table/public/vis_controller.ts +++ b/src/plugins/vis_type_table/public/vis_controller.ts @@ -78,8 +78,18 @@ export function getTableVisualizationControllerClass( if (!this.$scope) { return; } + + // How things get into this $scope? + // To inject variables into this $scope there's the following pipeline of stuff to check: + // - visualize_embeddable => that's what the editor creates to wrap this Angular component + // - build_pipeline => it serialize all the params into an Angular template compiled on the fly + // - table_vis_fn => unserialize the params and prepare them for the final React/Angular bridge + // - visualization_renderer => creates the wrapper component for this controller and passes the params + // + // In case some prop is missing check into the top of the chain if they are available and check + // the list above that it is passing through this.$scope.vis = this.vis; - this.$scope.visState = { params: visParams }; + this.$scope.visState = { params: visParams, title: visParams.title }; this.$scope.esResponse = esResponse; this.$scope.visParams = visParams; diff --git a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js index 7f96066c16076..afdc273782709 100644 --- a/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js +++ b/src/plugins/vis_type_tagcloud/public/components/tag_cloud_visualization.test.js @@ -122,8 +122,6 @@ describe('TagCloudVisualizationTest', () => { uiState: false, }); - domNode.style.width = '256px'; - domNode.style.height = '368px'; await tagcloudVisualization.render(dummyTableGroup, vis.params, { resize: true, params: false, diff --git a/src/plugins/vis_type_timelion/public/index.ts b/src/plugins/vis_type_timelion/public/index.ts index 0aa5f3a810033..abfe345d8c672 100644 --- a/src/plugins/vis_type_timelion/public/index.ts +++ b/src/plugins/vis_type_timelion/public/index.ts @@ -25,5 +25,10 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { getTimezone } from './helpers/get_timezone'; +export { tickFormatters } from './helpers/tick_formatters'; +export { xaxisFormatterProvider } from './helpers/xaxis_formatter'; +export { generateTicksProvider } from './helpers/tick_generator'; + +export { DEFAULT_TIME_FORMAT, calculateInterval } from '../common/lib'; export { VisTypeTimelionPluginStart } from './plugin'; diff --git a/src/plugins/vis_type_timelion/server/plugin.ts b/src/plugins/vis_type_timelion/server/plugin.ts index 605c6be0a85df..5e6557e305692 100644 --- a/src/plugins/vis_type_timelion/server/plugin.ts +++ b/src/plugins/vis_type_timelion/server/plugin.ts @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { first } from 'rxjs/operators'; -import { TypeOf } from '@kbn/config-schema'; +import { TypeOf, schema } from '@kbn/config-schema'; import { RecursiveReadonly } from '@kbn/utility-types'; import { CoreSetup, PluginInitializerContext } from '../../../../src/core/server'; @@ -31,6 +31,10 @@ import { validateEsRoute } from './routes/validate_es'; import { runRoute } from './routes/run'; import { ConfigManager } from './lib/config_manager'; +const experimentalLabel = i18n.translate('timelion.uiSettings.experimentalLabel', { + defaultMessage: 'experimental', +}); + /** * Describes public Timelion plugin contract returned at the `setup` stage. */ @@ -82,6 +86,97 @@ export class Plugin { runRoute(router, deps); validateEsRoute(router); + core.uiSettings.register({ + 'timelion:es.timefield': { + name: i18n.translate('timelion.uiSettings.timeFieldLabel', { + defaultMessage: 'Time field', + }), + value: '@timestamp', + description: i18n.translate('timelion.uiSettings.timeFieldDescription', { + defaultMessage: 'Default field containing a timestamp when using {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + 'timelion:es.default_index': { + name: i18n.translate('timelion.uiSettings.defaultIndexLabel', { + defaultMessage: 'Default index', + }), + value: '_all', + description: i18n.translate('timelion.uiSettings.defaultIndexDescription', { + defaultMessage: 'Default elasticsearch index to search with {esParam}', + values: { esParam: '.es()' }, + }), + category: ['timelion'], + schema: schema.string(), + }, + 'timelion:target_buckets': { + name: i18n.translate('timelion.uiSettings.targetBucketsLabel', { + defaultMessage: 'Target buckets', + }), + value: 200, + description: i18n.translate('timelion.uiSettings.targetBucketsDescription', { + defaultMessage: 'The number of buckets to shoot for when using auto intervals', + }), + category: ['timelion'], + schema: schema.number(), + }, + 'timelion:max_buckets': { + name: i18n.translate('timelion.uiSettings.maximumBucketsLabel', { + defaultMessage: 'Maximum buckets', + }), + value: 2000, + description: i18n.translate('timelion.uiSettings.maximumBucketsDescription', { + defaultMessage: 'The maximum number of buckets a single datasource can return', + }), + category: ['timelion'], + schema: schema.number(), + }, + 'timelion:min_interval': { + name: i18n.translate('timelion.uiSettings.minimumIntervalLabel', { + defaultMessage: 'Minimum interval', + }), + value: '1ms', + description: i18n.translate('timelion.uiSettings.minimumIntervalDescription', { + defaultMessage: 'The smallest interval that will be calculated when using "auto"', + description: + '"auto" is a technical value in that context, that should not be translated.', + }), + category: ['timelion'], + schema: schema.string(), + }, + 'timelion:graphite.url': { + name: i18n.translate('timelion.uiSettings.graphiteURLLabel', { + defaultMessage: 'Graphite URL', + description: + 'The URL should be in the form of https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite', + }), + value: config.graphiteUrls && config.graphiteUrls.length ? config.graphiteUrls[0] : null, + description: i18n.translate('timelion.uiSettings.graphiteURLDescription', { + defaultMessage: + '{experimentalLabel} The URL of your graphite host', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + type: 'select', + options: config.graphiteUrls || [], + category: ['timelion'], + schema: schema.nullable(schema.string()), + }, + 'timelion:quandl.key': { + name: i18n.translate('timelion.uiSettings.quandlKeyLabel', { + defaultMessage: 'Quandl key', + }), + value: 'someKeyHere', + description: i18n.translate('timelion.uiSettings.quandlKeyDescription', { + defaultMessage: '{experimentalLabel} Your API key from www.quandl.com', + values: { experimentalLabel: `[${experimentalLabel}]` }, + }), + category: ['timelion'], + schema: schema.string(), + }, + }); + return deepFreeze({ uiEnabled: config.ui.enabled }); } diff --git a/src/plugins/vis_type_vega/public/__mocks__/services.ts b/src/plugins/vis_type_vega/public/__mocks__/services.ts deleted file mode 100644 index 4775241a66d50..0000000000000 --- a/src/plugins/vis_type_vega/public/__mocks__/services.ts +++ /dev/null @@ -1,57 +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 { CoreStart, IUiSettingsClient, NotificationsStart, SavedObjectsStart } from 'kibana/public'; - -import { createGetterSetter } from '../../../kibana_utils/public'; -import { DataPublicPluginStart } from '../../../data/public'; -import { dataPluginMock } from '../../../data/public/mocks'; -import { coreMock } from '../../../../core/public/mocks'; - -export const [getData, setData] = createGetterSetter('Data'); -setData(dataPluginMock.createStartContract()); - -export const [getNotifications, setNotifications] = createGetterSetter( - 'Notifications' -); -setNotifications(coreMock.createStart().notifications); - -export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); -setUISettings(coreMock.createStart().uiSettings); - -export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< - CoreStart['injectedMetadata'] ->('InjectedMetadata'); -setInjectedMetadata(coreMock.createStart().injectedMetadata); - -export const [getSavedObjects, setSavedObjects] = createGetterSetter( - 'SavedObjects' -); -setSavedObjects(coreMock.createStart().savedObjects); - -export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ - enableExternalUrls: boolean; - emsTileLayerId: unknown; -}>('InjectedVars'); -setInjectedVars({ - emsTileLayerId: {}, - enableExternalUrls: true, -}); - -export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; -export const getEmsTileLayerId = () => getInjectedVars().emsTileLayerId; diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap new file mode 100644 index 0000000000000..650d9c1b430f0 --- /dev/null +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"
"`; + +exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; + +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
"`; + +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
"`; diff --git a/src/legacy/core_plugins/timelion/public/services/saved_sheet_register.ts b/src/plugins/vis_type_vega/public/default_spec.ts similarity index 86% rename from src/legacy/core_plugins/timelion/public/services/saved_sheet_register.ts rename to src/plugins/vis_type_vega/public/default_spec.ts index 7c8a2909238da..71f44b694a10e 100644 --- a/src/legacy/core_plugins/timelion/public/services/saved_sheet_register.ts +++ b/src/plugins/vis_type_vega/public/default_spec.ts @@ -16,4 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import './saved_sheets'; + +// @ts-ignore +import defaultSpec from '!!raw-loader!./default.spec.hjson'; + +export const getDefaultSpec = () => defaultSpec; diff --git a/src/plugins/vis_type_vega/public/test_utils/default.spec.json b/src/plugins/vis_type_vega/public/test_utils/default.spec.json new file mode 100644 index 0000000000000..8cf763647115f --- /dev/null +++ b/src/plugins/vis_type_vega/public/test_utils/default.spec.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "title": "Event counts from all indexes", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "_all", + "body": { + "aggs": { + "time_buckets": { + "date_histogram": { + "field": "@timestamp", + "interval": { "%autointerval%": true }, + "extended_bounds": { + "min": { "%timefilter%": "min" }, + "max": { "%timefilter%": "max" } + }, + "min_doc_count": 0 + } + } + }, + "size": 0 + } + }, + "format": { "property": "aggregations.time_buckets.buckets" } + }, + "mark": "line", + "encoding": { + "x": { + "field": "key", + "type": "temporal", + "axis": { "title": false } + }, + "y": { + "field": "doc_count", + "type": "quantitative", + "axis": { "title": "Document count" } + } + } +} diff --git a/src/plugins/vis_type_vega/public/test_utils/vega_graph.json b/src/plugins/vis_type_vega/public/test_utils/vega_graph.json new file mode 100644 index 0000000000000..babde96fd3dae --- /dev/null +++ b/src/plugins/vis_type_vega/public/test_utils/vega_graph.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "data": [ + { + "name": "table", + "values": [ + {"x": 0, "y": 28, "c": 0}, {"x": 0, "y": 55, "c": 1}, {"x": 1, "y": 43, "c": 0}, {"x": 1, "y": 91, "c": 1}, + {"x": 2, "y": 81, "c": 0}, {"x": 2, "y": 53, "c": 1}, {"x": 3, "y": 19, "c": 0}, {"x": 3, "y": 87, "c": 1}, + {"x": 4, "y": 52, "c": 0}, {"x": 4, "y": 48, "c": 1}, {"x": 5, "y": 24, "c": 0}, {"x": 5, "y": 49, "c": 1}, + {"x": 6, "y": 87, "c": 0}, {"x": 6, "y": 66, "c": 1}, {"x": 7, "y": 17, "c": 0}, {"x": 7, "y": 27, "c": 1}, + {"x": 8, "y": 68, "c": 0}, {"x": 8, "y": 16, "c": 1}, {"x": 9, "y": 49, "c": 0}, {"x": 9, "y": 15, "c": 1} + ], + "transform": [ + { + "type": "stack", + "groupby": ["x"], + "sort": {"field": "c"}, + "field": "y" + } + ] + } + ], + "scales": [ + { + "name": "x", + "type": "point", + "range": "width", + "domain": {"data": "table", "field": "x"} + }, + { + "name": "y", + "type": "linear", + "range": "height", + "nice": true, + "zero": true, + "domain": {"data": "table", "field": "y1"} + }, + { + "name": "color", + "type": "ordinal", + "range": "category", + "domain": {"data": "table", "field": "c"} + } + ], + "marks": [ + { + "type": "group", + "from": { + "facet": {"name": "series", "data": "table", "groupby": "c"} + }, + "marks": [ + { + "type": "area", + "from": {"data": "series"}, + "encode": { + "enter": { + "interpolate": {"value": "monotone"}, + "x": {"scale": "x", "field": "x"}, + "y": {"scale": "y", "field": "y0"}, + "y2": {"scale": "y", "field": "y1"}, + "fill": {"scale": "color", "field": "c"} + }, + "update": { + "fillOpacity": {"value": 1} + }, + "hover": { + "fillOpacity": {"value": 0.5} + } + } + } + ] + } + ], + "autosize": { "type": "none" }, + "width": 512, + "height": 512, + "config": { "kibana": { "renderer": "svg" }} +} diff --git a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json new file mode 100644 index 0000000000000..9100de38ae387 --- /dev/null +++ b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "config": { + "kibana": { "renderer": "svg", "type": "map", "mapStyle": false} + }, + "width": 512, + "height": 512, + "marks": [ + { + "type": "rect", + "encode": { + "enter": { + "fill": {"value": "#0f0"}, + "width": {"signal": "width"}, + "height": {"signal": "height"} + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/public/test_utils/vegalite_graph.json b/src/plugins/vis_type_vega/public/test_utils/vegalite_graph.json new file mode 100644 index 0000000000000..5394f009b074f --- /dev/null +++ b/src/plugins/vis_type_vega/public/test_utils/vegalite_graph.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v4.json", + "data": { + "format": {"property": "aggregations.time_buckets.buckets"}, + "values": { + "aggregations": { + "time_buckets": { + "buckets": [ + {"key": 1512950400000, "doc_count": 0}, + {"key": 1513036800000, "doc_count": 0}, + {"key": 1513123200000, "doc_count": 0}, + {"key": 1513209600000, "doc_count": 4545}, + {"key": 1513296000000, "doc_count": 4667}, + {"key": 1513382400000, "doc_count": 4660}, + {"key": 1513468800000, "doc_count": 133}, + {"key": 1513555200000, "doc_count": 0}, + {"key": 1513641600000, "doc_count": 0}, + {"key": 1513728000000, "doc_count": 0} + ] + } + }, + "status": 200 + } + }, + "mark": "line", + "encoding": { + "x": { + "field": "key", + "type": "temporal", + "axis": null + }, + "y": { + "field": "doc_count", + "type": "quantitative", + "axis": null + } + }, + "autosize": { "type": "fit" }, + "width": 512, + "height": 512, + "config": { "kibana": { "renderer": "svg" }} +} diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 55ad134c05301..5825661f9001c 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -25,8 +25,7 @@ import { VegaVisEditor } from './components'; import { createVegaRequestHandler } from './vega_request_handler'; // @ts-ignore import { createVegaVisualization } from './vega_visualization'; -// @ts-ignore -import defaultSpec from '!!raw-loader!./default.spec.hjson'; +import { getDefaultSpec } from './default_spec'; export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { const requestHandler = createVegaRequestHandler(dependencies); @@ -40,7 +39,7 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen description: 'Vega and Vega-Lite are product names and should not be translated', }), icon: 'visVega', - visConfig: { defaults: { spec: defaultSpec } }, + visConfig: { defaults: { spec: getDefaultSpec() } }, editorConfig: { optionsTemplate: VegaVisEditor, enableAutoApply: true, diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js new file mode 100644 index 0000000000000..a6ad6e4908bb4 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -0,0 +1,232 @@ +/* + * 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 'jquery'; + +import 'leaflet/dist/leaflet.js'; +import 'leaflet-vega'; +import { createVegaVisualization } from './vega_visualization'; + +import vegaliteGraph from './test_utils/vegalite_graph.json'; +import vegaGraph from './test_utils/vega_graph.json'; +import vegaMapGraph from './test_utils/vega_map_test.json'; + +import { VegaParser } from './data_model/vega_parser'; +import { SearchAPI } from './data_model/search_api'; + +import { createVegaTypeDefinition } from './vega_type'; + +import { + setInjectedVars, + setData, + setSavedObjects, + setNotifications, + setKibanaMapFactory, +} from './services'; +import { coreMock } from '../../../core/public/mocks'; +import { dataPluginMock } from '../../data/public/mocks'; +import { KibanaMap } from '../../maps_legacy/public/map/kibana_map'; + +jest.mock('./default_spec', () => ({ + getDefaultSpec: () => jest.requireActual('./test_utils/default.spec.json'), +})); + +jest.mock('./lib/vega', () => ({ + vega: jest.requireActual('vega'), + vegaLite: jest.requireActual('vega-lite'), +})); + +describe('VegaVisualizations', () => { + let domNode; + let VegaVisualization; + let vis; + let vegaVisualizationDependencies; + let vegaVisType; + + let mockWidth; + let mockedWidthValue; + let mockHeight; + let mockedHeightValue; + + const coreStart = coreMock.createStart(); + const dataPluginStart = dataPluginMock.createStartContract(); + + const setupDOM = (width = 512, height = 512) => { + mockedWidthValue = width; + mockedHeightValue = height; + domNode = document.createElement('div'); + + mockWidth = jest.spyOn($.prototype, 'width').mockImplementation(() => mockedWidthValue); + mockHeight = jest.spyOn($.prototype, 'height').mockImplementation(() => mockedHeightValue); + }; + + setKibanaMapFactory((...args) => new KibanaMap(...args)); + setInjectedVars({ + emsTileLayerId: {}, + enableExternalUrls: true, + esShardTimeout: 10000, + }); + setData(dataPluginStart); + setSavedObjects(coreStart.savedObjects); + setNotifications(coreStart.notifications); + + beforeEach(() => { + vegaVisualizationDependencies = { + core: coreMock.createSetup(), + plugins: { + data: dataPluginMock.createSetupContract(), + }, + }; + + vegaVisType = createVegaTypeDefinition(vegaVisualizationDependencies); + VegaVisualization = createVegaVisualization(vegaVisualizationDependencies); + }); + + describe('VegaVisualization - basics', () => { + beforeEach(async () => { + setupDOM(); + + vis = { + type: vegaVisType, + }; + }); + + afterEach(() => { + mockWidth.mockRestore(); + mockHeight.mockRestore(); + }); + + test('should show vegalite graph and update on resize (may fail in dev env)', async () => { + let vegaVis; + try { + vegaVis = new VegaVisualization(domNode, vis); + + const vegaParser = new VegaParser( + JSON.stringify(vegaliteGraph), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }) + ); + await vegaParser.parseAsync(); + await vegaVis.render(vegaParser); + expect(domNode.innerHTML).toMatchSnapshot(); + + mockedWidthValue = 256; + mockedHeightValue = 256; + + await vegaVis._vegaView.resize(); + + expect(domNode.innerHTML).toMatchSnapshot(); + } finally { + vegaVis.destroy(); + } + }); + + test('should show vega graph (may fail in dev env)', async () => { + let vegaVis; + try { + vegaVis = new VegaVisualization(domNode, vis); + const vegaParser = new VegaParser( + JSON.stringify(vegaGraph), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }) + ); + await vegaParser.parseAsync(); + + await vegaVis.render(vegaParser); + expect(domNode.innerHTML).toMatchSnapshot(); + } finally { + vegaVis.destroy(); + } + }); + + test('should show vega blank rectangle on top of a map (vegamap)', async () => { + let vegaVis; + try { + vegaVis = new VegaVisualization(domNode, vis); + const vegaParser = new VegaParser( + JSON.stringify(vegaMapGraph), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }) + ); + await vegaParser.parseAsync(); + + mockedWidthValue = 256; + mockedHeightValue = 256; + + await vegaVis.render(vegaParser); + expect(domNode.innerHTML).toMatchSnapshot(); + } finally { + vegaVis.destroy(); + } + }); + + test('should add a small subpixel value to the height of the canvas to avoid getting it set to 0', async () => { + let vegaVis; + try { + vegaVis = new VegaVisualization(domNode, vis); + const vegaParser = new VegaParser( + `{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "marks": [ + { + "type": "text", + "encode": { + "update": { + "text": { + "value": "Test" + }, + "align": {"value": "center"}, + "baseline": {"value": "middle"}, + "xc": {"signal": "width/2"}, + "yc": {"signal": "height/2"} + fontSize: {value: "14"} + } + } + } + ] + }`, + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }) + ); + await vegaParser.parseAsync(); + + mockedWidthValue = 256; + mockedHeightValue = 256; + + await vegaVis.render(vegaParser); + const vegaView = vegaVis._vegaView._view; + expect(vegaView.height()).toBe(250.00000001); + } finally { + vegaVis.destroy(); + } + }); + }); +}); diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss index 6d96fa39e7c34..96c72bd5956d2 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss +++ b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss @@ -304,11 +304,14 @@ .series > path, .series > rect { - fill-opacity: .8; stroke-opacity: 1; stroke-width: 0; } + .series > path { + fill-opacity: .8; + } + .blur_shape { // sass-lint:disable-block no-important opacity: .3 !important; diff --git a/src/plugins/visualizations/public/expressions/visualization_renderer.tsx b/src/plugins/visualizations/public/expressions/visualization_renderer.tsx index 0fd81c753da24..1bca5b4f0d539 100644 --- a/src/plugins/visualizations/public/expressions/visualization_renderer.tsx +++ b/src/plugins/visualizations/public/expressions/visualization_renderer.tsx @@ -33,6 +33,7 @@ export const visualization = () => ({ const visType = config.visType || visConfig.type; const vis = new ExprVis({ + title: config.title, type: visType as string, params: visConfig as VisParams, }); diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index 62ff1f83426b9..2ef07bf18c91c 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -490,7 +490,7 @@ export const buildPipeline = async ( const { indexPattern, searchSource } = vis.data; const query = searchSource!.getField('query'); const filters = searchSource!.getField('filter'); - const { uiState } = vis; + const { uiState, title } = vis; // context let pipeline = `kibana | kibana_context `; @@ -519,7 +519,7 @@ export const buildPipeline = async ( timefilter: params.timefilter, }); if (buildPipelineVisFunction[vis.type.name]) { - pipeline += buildPipelineVisFunction[vis.type.name](vis.params, schemas, uiState); + pipeline += buildPipelineVisFunction[vis.type.name]({ title, ...vis.params }, schemas, uiState); } else if (vislibCharts.includes(vis.type.name)) { const visConfig = { ...vis.params }; visConfig.dimensions = await buildVislibDimensions(vis, params); diff --git a/src/test_utils/public/key_map.ts b/src/test_utils/public/key_map.ts new file mode 100644 index 0000000000000..aac3c6b2db3e0 --- /dev/null +++ b/src/test_utils/public/key_map.ts @@ -0,0 +1,121 @@ +/* + * 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 const keyMap: { [key: number]: string } = { + 8: 'backspace', + 9: 'tab', + 13: 'enter', + 16: 'shift', + 17: 'ctrl', + 18: 'alt', + 19: 'pause', + 20: 'capsLock', + 27: 'escape', + 32: 'space', + 33: 'pageUp', + 34: 'pageDown', + 35: 'end', + 36: 'home', + 37: 'left', + 38: 'up', + 39: 'right', + 40: 'down', + 45: 'insert', + 46: 'delete', + 48: '0', + 49: '1', + 50: '2', + 51: '3', + 52: '4', + 53: '5', + 54: '6', + 55: '7', + 56: '8', + 57: '9', + 65: 'a', + 66: 'b', + 67: 'c', + 68: 'd', + 69: 'e', + 70: 'f', + 71: 'g', + 72: 'h', + 73: 'i', + 74: 'j', + 75: 'k', + 76: 'l', + 77: 'm', + 78: 'n', + 79: 'o', + 80: 'p', + 81: 'q', + 82: 'r', + 83: 's', + 84: 't', + 85: 'u', + 86: 'v', + 87: 'w', + 88: 'x', + 89: 'y', + 90: 'z', + 91: 'leftWindowKey', + 92: 'rightWindowKey', + 93: 'selectKey', + 96: '0', + 97: '1', + 98: '2', + 99: '3', + 100: '4', + 101: '5', + 102: '6', + 103: '7', + 104: '8', + 105: '9', + 106: 'multiply', + 107: 'add', + 109: 'subtract', + 110: 'period', + 111: 'divide', + 112: 'f1', + 113: 'f2', + 114: 'f3', + 115: 'f4', + 116: 'f5', + 117: 'f6', + 118: 'f7', + 119: 'f8', + 120: 'f9', + 121: 'f10', + 122: 'f11', + 123: 'f12', + 144: 'numLock', + 145: 'scrollLock', + 186: 'semiColon', + 187: 'equalSign', + 188: 'comma', + 189: 'dash', + 190: 'period', + 191: 'forwardSlash', + 192: 'graveAccent', + 219: 'openBracket', + 220: 'backSlash', + 221: 'closeBracket', + 222: 'singleQuote', + 224: 'meta', +}; diff --git a/src/test_utils/public/simulate_keys.js b/src/test_utils/public/simulate_keys.js index 56596508a2181..460a75486169a 100644 --- a/src/test_utils/public/simulate_keys.js +++ b/src/test_utils/public/simulate_keys.js @@ -20,7 +20,7 @@ import $ from 'jquery'; import _ from 'lodash'; import Bluebird from 'bluebird'; -import { keyMap } from 'ui/directives/key_map'; +import { keyMap } from './key_map'; const reverseKeyMap = _.mapValues(_.invert(keyMap), _.ary(_.parseInt, 1)); /** diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index 6cb9d5dccdc9a..7db968df8357a 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -76,6 +76,7 @@ export default function ({ getService }) { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, references: [], + namespaces: ['default'], }, ], }); @@ -121,6 +122,7 @@ export default function ({ getService }) { title: 'An existing visualization', }, references: [], + namespaces: ['default'], migrationVersion: { visualization: resp.body.saved_objects[0].migrationVersion.visualization, }, @@ -134,6 +136,7 @@ export default function ({ getService }) { title: 'A great new dashboard', }, references: [], + namespaces: ['default'], migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index c802d52913065..56ee5a69be23e 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -68,6 +68,7 @@ export default function ({ getService }) { resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', @@ -94,6 +95,7 @@ export default function ({ getService }) { buildNum: 8467, defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab', }, + namespaces: ['default'], migrationVersion: resp.body.saved_objects[2].migrationVersion, references: [], }, diff --git a/test/api_integration/apis/saved_objects/bulk_update.js b/test/api_integration/apis/saved_objects/bulk_update.js index e3f994ff224e8..973ce382ea813 100644 --- a/test/api_integration/apis/saved_objects/bulk_update.js +++ b/test/api_integration/apis/saved_objects/bulk_update.js @@ -65,6 +65,7 @@ export default function ({ getService }) { attributes: { title: 'An existing visualization', }, + namespaces: ['default'], }); expect(secondObject) @@ -77,6 +78,7 @@ export default function ({ getService }) { attributes: { title: 'An existing dashboard', }, + namespaces: ['default'], }); }); @@ -233,6 +235,7 @@ export default function ({ getService }) { attributes: { title: 'An existing dashboard', }, + namespaces: ['default'], }); }); }); diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js index eddda3aded141..c1300125441bc 100644 --- a/test/api_integration/apis/saved_objects/create.js +++ b/test/api_integration/apis/saved_objects/create.js @@ -58,6 +58,7 @@ export default function ({ getService }) { title: 'My favorite vis', }, references: [], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); }); @@ -104,6 +105,7 @@ export default function ({ getService }) { title: 'My favorite vis', }, references: [], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); }); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 7cb5955e4a43d..f129bf22840da 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -48,6 +48,7 @@ export default function ({ getService }) { }, score: 0, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { id: '91200a00-9efd-11e7-acb3-3dab96693fab', @@ -107,6 +108,93 @@ export default function ({ getService }) { })); }); + describe('unknown namespace', () => { + it('should return 200 with empty response', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&namespaces=foo') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); + + describe('known namespace', () => { + it('should return 200 with individual responses', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + version: 'WzIsMV0=', + attributes: { + title: 'Count of requests', + }, + migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], + score: 0, + references: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], + updated_at: '2017-09-21T18:51:23.794Z', + }, + ], + }); + expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); + })); + }); + + describe('wildcard namespace', () => { + it('should return 200 with individual responses from the default namespace', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + version: 'WzIsMV0=', + attributes: { + title: 'Count of requests', + }, + migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], + score: 0, + references: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], + updated_at: '2017-09-21T18:51:23.794Z', + }, + ], + }); + expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); + })); + }); + describe('with a filter', () => { it('should return 200 with a valid response', async () => await supertest @@ -135,6 +223,7 @@ export default function ({ getService }) { .searchSourceJSON, }, }, + namespaces: ['default'], score: 0, references: [ { diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js index 55dfda251a75a..6bb5cf0c8a7ff 100644 --- a/test/api_integration/apis/saved_objects/get.js +++ b/test/api_integration/apis/saved_objects/get.js @@ -56,6 +56,7 @@ export default function ({ getService }) { id: '91200a00-9efd-11e7-acb3-3dab96693fab', }, ], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); })); diff --git a/test/api_integration/apis/saved_objects/update.js b/test/api_integration/apis/saved_objects/update.js index d613f46878bb5..7803c39897f28 100644 --- a/test/api_integration/apis/saved_objects/update.js +++ b/test/api_integration/apis/saved_objects/update.js @@ -56,6 +56,7 @@ export default function ({ getService }) { attributes: { title: 'My second favorite vis', }, + namespaces: ['default'], }); }); }); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index b5154d619685a..08c4327d7c0c4 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -49,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { id: '91200a00-9efd-11e7-acb3-3dab96693fab', diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index cb8b5a6ddc65f..97f2641b51d13 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }) { const es = getService('legacyEs'); const PageObjects = getPageObjects(['settings', 'common']); - describe('"Create Index Pattern" wizard', function () { + // Flaky: https://github.com/elastic/kibana/issues/71501 + describe.skip('"Create Index Pattern" wizard', function () { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index ea4db35d75ccf..41e56986f677b 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -246,9 +246,7 @@ export default function ({ getService, getPageObjects }) { await inspector.close(); }); - // Preventing ES Promotion for master (8.0) - // https://github.com/elastic/kibana/issues/64734 - it.skip('does not scale top hit agg', async () => { + it('does not scale top hit agg', async () => { const expectedTableData = [ ['2015-09-20 00:00', '6', '9.035KB'], ['2015-09-20 01:00', '9', '5.854KB'], diff --git a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx index 494570b26f561..9cbff335590a3 100644 --- a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx +++ b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx @@ -21,12 +21,12 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { Router, Switch, Route, Link } from 'react-router-dom'; import { CoreSetup, Plugin } from 'kibana/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../../src/plugins/management/public'; export class ManagementTestPlugin implements Plugin { public setup(core: CoreSetup, { management }: { management: ManagementSetup }) { - const testSection = management.sections.getSection(ManagementSectionId.Data); + const testSection = management.sections.section.data; testSection.registerApp({ id: 'test-management', diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index f3fc5f84583c9..f43fe9f96c3ef 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -209,7 +209,7 @@ def runErrorReporter() { bash( """ source src/dev/ci_setup/setup_env.sh - node scripts/report_failed_tests ${dryRun} + node scripts/report_failed_tests ${dryRun} target/junit/**/*.xml """, "Report failed tests, if necessary" ) diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 9e07727204f88..3470ede0f15c7 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -160,7 +160,7 @@ This is the primary function for an action type. Whenever the action needs to ex | config | The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | | params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | | services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but runs in the context of the user who is calling the action when security is enabled. | -| services.getScopedCallCluster | This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who is calling the action when security is enabled. This must only be called with instances of CallCluster provided by core. | +| services.getLegacyScopedClusterClient | This function returns an instance of the LegacyScopedClusterClient scoped to the user who is calling the action when security is enabled. | | services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | | services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | @@ -437,9 +437,12 @@ The config and params properties are modelled after the [Watcher Index Action](h ### `config` -| Property | Description | Type | -| -------- | -------------------------------------- | ------------------- | -| index | The Elasticsearch index to index into. | string _(optional)_ | +| Property | Description | Type | +| -------------------- | ---------------------------------------------------------- | -------------------- | +| index | The Elasticsearch index to index into. | string _(optional)_ | +| doc_id | The optional \_id of the document. | string _(optional)_ | +| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ | +| refresh | Setting of the refresh policy for the write request. | boolean _(optional)_ | ### `secrets` @@ -447,13 +450,9 @@ This action type has no `secrets` properties. ### `params` -| Property | Description | Type | -| -------------------- | ---------------------------------------------------------- | -------------------- | -| index | The Elasticsearch index to index into. | string _(optional)_ | -| doc_id | The optional \_id of the document. | string _(optional)_ | -| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ | -| refresh | Setting of the refresh policy for the write request | boolean _(optional)_ | -| body | The documument body/bodies to index. | object or object[] | +| Property | Description | Type | +| --------- | ---------------------------------------- | ------------------- | +| documents | JSON object that describes the [document](https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-index.html#getting-started-batch-processing). | object[] | --- diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 87aa571ce6b8a..b1e40dce811a0 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -38,7 +38,7 @@ const createServicesMock = () => { } > = { callCluster: elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser, - getScopedCallCluster: jest.fn(), + getLegacyScopedClusterClient: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), }; return mock; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index eae2595136627..114c85ae9f9da 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -307,8 +307,8 @@ export class ActionsPlugin implements Plugin, Plugi return (request) => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: getScopedClient(request), - getScopedCallCluster(clusterClient: ILegacyClusterClient) { - return clusterClient.asScoped(request).callAsCurrentUser; + getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient) { + return clusterClient.asScoped(request); }, }); } diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index ca5da2779139e..a8e19e3ff2e79 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -25,9 +25,7 @@ export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefine export interface Services { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; - getScopedCallCluster( - clusterClient: ILegacyClusterClient - ): ILegacyScopedClusterClient['callAsCurrentUser']; + getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient): ILegacyScopedClusterClient; } declare module 'src/core/server' { diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 811478426a8d3..0464ec78a4e9d 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -103,7 +103,7 @@ This is the primary function for an alert type. Whenever the alert needs to exec |---|---| |services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana but in the context of the user who created the alert when security is enabled.| |services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user who created the alert (only when security isenabled).| -|services.getScopedCallCluster|This function scopes an instance of CallCluster by returning a `callCluster(path, opts)` function that runs in the context of the user who created the alert when security is enabled. This must only be called with instances of CallCluster provided by core.| +|services.getLegacyScopedClusterClient|This function returns an instance of the LegacyScopedClusterClient scoped to the user who created the alert when security is enabled.| |services.alertInstanceFactory(id)|This [alert instance factory](#alert-instance-factory) creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance.| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| |startedAt|The date and time the alert type started execution.| @@ -482,13 +482,15 @@ A schedule is structured such that the key specifies the format you wish to use We currently support the _Interval format_ which specifies the interval in seconds, minutes, hours or days at which the alert should execute. Example: `{ interval: "10s" }`, `{ interval: "5m" }`, `{ interval: "1h" }`, `{ interval: "1d" }`. -There are plans to support multiple other schedule formats in the near fuiture. +There are plans to support multiple other schedule formats in the near future. ## Alert instance factory **alertInstanceFactory(id)** -One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same id. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. +One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The `id` you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same `id`. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. + +Note that the `id` only needs to be unique **within the scope of a specific alert**, not unique across all alerts or alert types. For example, Alert 1 and Alert 2 can both create an alert instance with an `id` of `"a"` without conflicting with one another. But if Alert 1 creates 2 alert instances, then they must be differentiated with `id`s of `"a"` and `"b"`. This factory returns an instance of `AlertInstance`. The alert instance class has the following methods, note that we have removed the methods that you shouldn't touch. diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index e8e6f82f13882..ba832c65319f9 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -13,6 +13,7 @@ import { SavedObjectReference, SavedObject, } from 'src/core/server'; +import _ from 'lodash'; import { ActionsClient } from '../../actions/server'; import { Alert, @@ -53,7 +54,7 @@ interface ConstructorOptions { spaceId?: string; namespace?: string; getUserName: () => Promise; - createAPIKey: () => Promise; + createAPIKey: (name: string) => Promise; invalidateAPIKey: (params: InvalidateAPIKeyParams) => Promise; getActionsClient: () => Promise; } @@ -129,7 +130,7 @@ export class AlertsClient { private readonly taskManager: TaskManagerStartContract; private readonly savedObjectsClient: SavedObjectsClientContract; private readonly alertTypeRegistry: AlertTypeRegistry; - private readonly createAPIKey: () => Promise; + private readonly createAPIKey: (name: string) => Promise; private readonly invalidateAPIKey: ( params: InvalidateAPIKeyParams ) => Promise; @@ -167,7 +168,10 @@ export class AlertsClient { const alertType = this.alertTypeRegistry.get(data.alertTypeId); const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.params); const username = await this.getUserName(); - const createdAPIKey = data.enabled ? await this.createAPIKey() : null; + + const createdAPIKey = data.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; this.validateActions(alertType, data.actions); @@ -334,7 +338,9 @@ export class AlertsClient { const { actions, references } = await this.denormalizeActions(data.actions); const username = await this.getUserName(); - const createdAPIKey = attributes.enabled ? await this.createAPIKey() : null; + const createdAPIKey = attributes.enabled + ? await this.createAPIKey(this.generateAPIKeyName(alertType.id, data.name)) + : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); const updatedObject = await this.savedObjectsClient.update( @@ -406,7 +412,10 @@ export class AlertsClient { id, { ...attributes, - ...this.apiKeyAsAlertAttributes(await this.createAPIKey(), username), + ...this.apiKeyAsAlertAttributes( + await this.createAPIKey(this.generateAPIKeyName(attributes.alertTypeId, attributes.name)), + username + ), updatedBy: username, }, { version } @@ -464,7 +473,12 @@ export class AlertsClient { { ...attributes, enabled: true, - ...this.apiKeyAsAlertAttributes(await this.createAPIKey(), username), + ...this.apiKeyAsAlertAttributes( + await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ), + username + ), updatedBy: username, }, { version } @@ -697,4 +711,8 @@ export class AlertsClient { references, }; } + + private generateAPIKeyName(alertTypeId: string, alertName: string) { + return _.truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); + } } diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index af546f965d7df..30fcd1b949f2b 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -70,7 +70,7 @@ export class AlertsClientFactory { const user = await securityPluginSetup.authc.getCurrentUser(request); return user ? user.username : null; }, - async createAPIKey() { + async createAPIKey(name: string) { if (!securityPluginSetup) { return { apiKeysEnabled: false }; } @@ -78,7 +78,11 @@ export class AlertsClientFactory { // API key for the user, instead of having the user create it themselves, which requires api_key // privileges const createAPIKeyResult = await securityPluginSetup.authc.grantAPIKeyAsInternalUser( - request + request, + { + name, + role_descriptors: {}, + } ); if (!createAPIKeyResult) { return { apiKeysEnabled: false }; diff --git a/x-pack/plugins/alerts/server/mocks.ts b/x-pack/plugins/alerts/server/mocks.ts index 84f79d53f218c..c39aa13b580fc 100644 --- a/x-pack/plugins/alerts/server/mocks.ts +++ b/x-pack/plugins/alerts/server/mocks.ts @@ -59,7 +59,7 @@ const createAlertServicesMock = () => { .fn, [string]>() .mockReturnValue(alertInstanceFactoryMock), callCluster: elasticsearchServiceMock.createLegacyScopedClusterClient().callAsCurrentUser, - getScopedCallCluster: jest.fn(), + getLegacyScopedClusterClient: jest.fn(), savedObjectsClient: savedObjectsClientMock.create(), }; }; diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 23a5dc51d0475..07ed021d8ca84 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -273,8 +273,8 @@ export class AlertingPlugin { return (request) => ({ callCluster: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, savedObjectsClient: this.getScopedClientWithAlertSavedObjectType(savedObjects, request), - getScopedCallCluster(clusterClient: ILegacyClusterClient) { - return clusterClient.asScoped(request).callAsCurrentUser; + getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient) { + return clusterClient.asScoped(request); }, }); } diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 24dfb391f0791..66eec370f2c20 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -40,9 +40,7 @@ declare module 'src/core/server' { export interface Services { callCluster: ILegacyScopedClusterClient['callAsCurrentUser']; savedObjectsClient: SavedObjectsClientContract; - getScopedCallCluster( - clusterClient: ILegacyClusterClient - ): ILegacyScopedClusterClient['callAsCurrentUser']; + getLegacyScopedClusterClient(clusterClient: ILegacyClusterClient): ILegacyScopedClusterClient; } export interface AlertServices extends Services { diff --git a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap index 4ee7692222d68..1d8cfa28aea75 100644 --- a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap @@ -1,929 +1,973 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the telemetry mapping 1`] = ` -Object { - "properties": Object { - "agents": Object { - "properties": Object { - "dotnet": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "go": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "java": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "js-base": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "nodejs": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "python": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "ruby": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - "rum-js": Object { - "properties": Object { - "agent": Object { - "properties": Object { - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "service": Object { - "properties": Object { - "framework": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "language": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "runtime": Object { - "properties": Object { - "composite": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "name": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "version": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - }, - }, - }, - }, - }, - }, - "cardinality": Object { - "properties": Object { - "transaction": Object { - "properties": Object { - "name": Object { - "properties": Object { - "all_agents": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "rum": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - }, - }, - }, - }, - "user_agent": Object { - "properties": Object { - "original": Object { - "properties": Object { - "all_agents": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "rum": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - }, - }, - }, - }, - }, - }, - "cloud": Object { - "properties": Object { - "availability_zone": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "provider": Object { - "ignore_above": 1024, - "type": "keyword", - }, - "region": Object { - "ignore_above": 1024, - "type": "keyword", - }, - }, - }, - "counts": Object { - "properties": Object { - "agent_configuration": Object { - "properties": Object { - "all": Object { - "type": "long", - }, - }, - }, - "error": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - "max_error_groups_per_service": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "max_transaction_groups_per_service": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "metric": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - "onboarding": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - "services": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "sourcemap": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - "span": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - "traces": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - }, - }, - "transaction": Object { - "properties": Object { - "1d": Object { - "type": "long", - }, - "all": Object { - "type": "long", - }, - }, - }, - }, - }, - "has_any_services": Object { - "type": "boolean", - }, - "indices": Object { - "properties": Object { - "all": Object { - "properties": Object { - "total": Object { - "properties": Object { - "docs": Object { - "properties": Object { - "count": Object { - "type": "long", - }, - }, - }, - "store": Object { - "properties": Object { - "size_in_bytes": Object { - "type": "long", - }, - }, - }, - }, - }, - }, - }, - "shards": Object { - "properties": Object { - "total": Object { - "type": "long", - }, - }, - }, - }, - }, - "integrations": Object { - "properties": Object { - "ml": Object { - "properties": Object { - "all_jobs_count": Object { - "type": "long", - }, - }, - }, - }, - }, - "retainment": Object { - "properties": Object { - "error": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - "metric": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - "onboarding": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - "span": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - "transaction": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "services_per_agent": Object { - "properties": Object { - "dotnet": Object { - "null_value": 0, - "type": "long", - }, - "go": Object { - "null_value": 0, - "type": "long", - }, - "java": Object { - "null_value": 0, - "type": "long", - }, - "js-base": Object { - "null_value": 0, - "type": "long", - }, - "nodejs": Object { - "null_value": 0, - "type": "long", - }, - "python": Object { - "null_value": 0, - "type": "long", - }, - "ruby": Object { - "null_value": 0, - "type": "long", - }, - "rum-js": Object { - "null_value": 0, - "type": "long", - }, - }, - }, - "tasks": Object { - "properties": Object { - "agent_configuration": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "agents": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "cardinality": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "groupings": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "indices_stats": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "integrations": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "processor_events": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "services": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - "versions": Object { - "properties": Object { - "took": Object { - "properties": Object { - "ms": Object { - "type": "long", - }, - }, - }, - }, - }, - }, - }, - "version": Object { - "properties": Object { - "apm_server": Object { - "properties": Object { - "major": Object { - "type": "long", - }, - "minor": Object { - "type": "long", - }, - "patch": Object { - "type": "long", - }, - }, - }, - }, - }, - }, +{ + "properties": { + "stack_stats": { + "properties": { + "kibana": { + "properties": { + "plugins": { + "properties": { + "apm": { + "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + } + } + }, + "cloud": { + "properties": { + "availability_zone": { + "type": "keyword", + "ignore_above": 1024 + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "region": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "client": { + "properties": { + "geo": { + "properites": { + "country_iso_code": { + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + }, + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "has_any_services": { + "type": "boolean" + }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services_per_agent": { + "properties": { + "dotnet": { + "type": "long", + "null_value": 0 + }, + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + }, + "rum-js": { + "type": "long", + "null_value": 0 + } + } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cloud": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } + } + } + } + } + } + } + } + } + } + } } `; 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 06ca3145bfce9..f7f2836745384 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 @@ -38,6 +38,8 @@ exports[`Error HOST_NAME 1`] = `"my hostname"`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -182,6 +184,8 @@ exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -326,6 +330,8 @@ exports[`Transaction HOST_NAME 1`] = `"my hostname"`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; +exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`; + exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/apm_telemetry.test.ts b/x-pack/plugins/apm/common/apm_telemetry.test.ts index 1612716142ce7..035c546a5b49a 100644 --- a/x-pack/plugins/apm/common/apm_telemetry.test.ts +++ b/x-pack/plugins/apm/common/apm_telemetry.test.ts @@ -4,48 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getApmTelemetryMapping, - mergeApmTelemetryMapping, -} from './apm_telemetry'; +import { getApmTelemetryMapping } from './apm_telemetry'; + +// Add this snapshot serializer for this test. The default snapshot serializer +// prints "Object" next to objects in the JSON output, but we want to be able to +// Use the output from this JSON snapshot to share with the telemetry team. When +// new fields are added to the mapping, we'll have a diff in the snapshot. +expect.addSnapshotSerializer({ + print: (contents) => { + return JSON.stringify(contents, null, 2); + }, + test: () => true, +}); describe('APM telemetry helpers', () => { describe('getApmTelemetry', () => { + // This test creates a snapshot with the JSON of our full telemetry mapping + // that can be PUT in a query to the index on the telemetry cluster. Sharing + // the contents of the snapshot with the telemetry team can provide them with + // useful information about changes to our telmetry. it('generates a JSON object with the telemetry mapping', () => { - expect(getApmTelemetryMapping()).toMatchSnapshot(); - }); - }); - - describe('mergeApmTelemetryMapping', () => { - describe('with an invalid mapping', () => { - it('throws an error', () => { - expect(() => mergeApmTelemetryMapping({})).toThrowError(); - }); - }); - - describe('with a valid mapping', () => { - it('merges the mapping', () => { - // This is "valid" in the sense that it has all of the deep fields - // needed to merge. It's not a valid mapping opbject. - const validTelemetryMapping = { - mappings: { + expect({ + properties: { + stack_stats: { properties: { - stack_stats: { + kibana: { properties: { - kibana: { - properties: { plugins: { properties: { apm: {} } } }, + plugins: { + properties: { + apm: getApmTelemetryMapping(), + }, }, }, }, }, }, - }; - - expect( - mergeApmTelemetryMapping(validTelemetryMapping)?.mappings.properties - .stack_stats.properties.kibana.properties.plugins.properties.apm - ).toEqual(getApmTelemetryMapping()); - }); + }, + }).toMatchSnapshot(); }); }); }); diff --git a/x-pack/plugins/apm/common/apm_telemetry.ts b/x-pack/plugins/apm/common/apm_telemetry.ts index 5837648f3e505..5fb6414674d1c 100644 --- a/x-pack/plugins/apm/common/apm_telemetry.ts +++ b/x-pack/plugins/apm/common/apm_telemetry.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { produce } from 'immer'; import { AGENT_NAMES } from './agent_name'; /** @@ -115,6 +114,15 @@ export function getApmTelemetryMapping() { }, cardinality: { properties: { + client: { + properties: { + geo: { + properites: { + country_iso_code: { rum: oneDayProperties }, + }, + }, + }, + }, user_agent: { properties: { original: { @@ -199,6 +207,7 @@ export function getApmTelemetryMapping() { agent_configuration: tookProperties, agents: tookProperties, cardinality: tookProperties, + cloud: tookProperties, groupings: tookProperties, indices_stats: tookProperties, integrations: tookProperties, @@ -221,16 +230,3 @@ export function getApmTelemetryMapping() { }, }; } - -/** - * Merge a telemetry mapping object (from https://github.com/elastic/telemetry/blob/master/config/templates/xpack-phone-home.json) - * with the output from `getApmTelemetryMapping`. - */ -export function mergeApmTelemetryMapping( - xpackPhoneHomeMapping: Record -) { - return produce(xpackPhoneHomeMapping, (draft: Record) => { - draft.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = getApmTelemetryMapping(); - return draft; - }); -} diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index a5a42ccbb9a21..d8d3827909b07 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -24,6 +24,7 @@ export const AGENT_VERSION = 'agent.version'; export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; +export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'; export const USER_ID = 'user.id'; export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; diff --git a/x-pack/plugins/apm/dev_docs/telemetry.md b/x-pack/plugins/apm/dev_docs/telemetry.md index fa8e057a59595..d61afbe07522f 100644 --- a/x-pack/plugins/apm/dev_docs/telemetry.md +++ b/x-pack/plugins/apm/dev_docs/telemetry.md @@ -55,20 +55,16 @@ The mapping for the telemetry data is here under `stack_stats.kibana.plugins.apm The mapping used there can be generated with the output of the [`getTelemetryMapping`](../common/apm_telemetry.ts) function. -To make a change to the mapping, edit this function, run the tests to update the snapshots, then use the `merge_telemetry_mapping` script to merge the data into the telemetry repository. +The `schema` property of the `makeUsageCollector` call in the [`createApmTelemetry` function](../server/lib/apm_telemetry/index.ts) contains the output of `getTelemetryMapping`. -If the [telemetry repository](https://github.com/elastic/telemetry) is cloned as a sibling to the kibana directory, you can run the following from x-pack/plugins/apm: - -```bash -node ./scripts/merge-telemetry-mapping.js ../../../../telemetry/config/templates/xpack-phone-home.json -``` - -this will replace the contents of the mapping in the repository checkout with the updated mapping. You can then [follow the telemetry team's instructions](https://github.com/elastic/telemetry#mappings) for opening a pull request with the mapping changes. +When adding a task, the key of the task and the `took` properties need to be added under the `tasks` properties in the mapping, as when tasks run they report the time they took. The queries for the stats are in the [collect data telemetry tasks](../server/lib/apm_telemetry/collect_data_telemetry/tasks.ts). The collection tasks also use the [`APMDataTelemetry` type](../server/lib/apm_telemetry/types.ts) which also needs to be updated with any changes to the fields. +Running `node scripts/telemetry_check --fix` from the root Kibana directory will update the schemas which schema should automatically notify the Telemetry team when a pull request is opened so they can update the mapping in the telemetry clusters. (At the time of this writing the APM schema is excluded. #70180 is open to remove these exclusions so at this time any pull requests with mapping changes will have to manually request the Telemetry team as a reviewer.) + ## Behavioral Telemetry Behavioral telemetry is recorded with the ui_metrics and application_usage methods from the Usage Collection plugin. diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 3cd04ee032e56..aa95918939dfa 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -12,6 +12,7 @@ import d3 from 'd3'; import { scaleUtc } from 'd3-scale'; import { mean } from 'lodash'; import React from 'react'; +import { px } from '../../../../style/variables'; import { asRelativeDateTimeRange } from '../../../../utils/formatters'; import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; // @ts-ignore @@ -88,6 +89,7 @@ export function ErrorDistribution({ distribution, title }: Props) { {title} bucket.x} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index b765dc42ede64..31f299f94bc26 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -16,18 +16,16 @@ import { import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/useFetcher'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; import { ApmHeader } from '../../shared/ApmHeader'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../observability/public'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const Titles = styled.div` margin-bottom: ${px(units.plus)}; @@ -181,24 +179,15 @@ export function ErrorGroupDetails() { )} - - - - - - - - - - + {showDetails && ( diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index a86f7fdf41f4f..0589fce727115 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -786,11 +786,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > a0ce2 @@ -831,11 +831,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -878,13 +878,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > f3ac9 @@ -1065,11 +1065,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1112,13 +1112,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > e9086 @@ -1299,11 +1299,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1346,13 +1346,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > 8673d @@ -1533,11 +1533,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1580,13 +1580,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > { const { urlParams, uiFilters } = useUrlParams(); @@ -99,28 +97,17 @@ const ErrorGroupOverview: React.FC = () => { - - - - - - - - - - - - - - + + + diff --git a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap index 9f461eeb5b6fc..4ded8286d8598 100644 --- a/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/Home/__snapshots__/Home.test.tsx.snap @@ -29,6 +29,9 @@ exports[`Home component should render services 1`] = ` "addWarning": [Function], }, }, + "uiSettings": Object { + "get": [Function], + }, }, "plugins": Object {}, } @@ -69,6 +72,9 @@ exports[`Home component should render traces 1`] = ` "addWarning": [Function], }, }, + "uiSettings": Object { + "get": [Function], + }, }, "plugins": Object {}, } diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 15be023c32e90..6aec6e9bf181a 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -17,7 +17,7 @@ import { const setBreadcrumbs = jest.fn(); -function expectBreadcrumbToMatchSnapshot(route: string, params = '') { +function mountBreadcrumb(route: string, params = '') { mount( ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(setBreadcrumbs.mock.calls[0][0]).toMatchSnapshot(); } describe('UpdateBreadcrumbs', () => { @@ -58,36 +57,88 @@ describe('UpdateBreadcrumbs', () => { }); it('Homepage', () => { - expectBreadcrumbToMatchSnapshot('/'); + mountBreadcrumb('/'); expect(window.document.title).toMatchInlineSnapshot(`"APM"`); }); it('/services/:serviceName/errors/:groupId', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors/myGroupId'); + mountBreadcrumb( + '/services/opbeans-node/errors/myGroupId', + 'rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0' + ); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { + text: 'APM', + href: + '#/?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'Services', + href: + '#/services?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'opbeans-node', + href: + '#/services/opbeans-node?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'Errors', + href: + '#/services/opbeans-node/errors?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { text: 'myGroupId', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"myGroupId | Errors | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/errors', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors'); + mountBreadcrumb('/services/opbeans-node/errors'); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { text: 'Errors', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"Errors | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/transactions', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/transactions'); + mountBreadcrumb('/services/opbeans-node/transactions'); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { text: 'Transactions', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"Transactions | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { - expectBreadcrumbToMatchSnapshot( + mountBreadcrumb( '/services/opbeans-node/transactions/view', 'transactionName=my-transaction-name' ); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { + text: 'Transactions', + href: '#/services/opbeans-node/transactions?kuery=myKuery', + }, + { text: 'my-transaction-name', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"my-transaction-name | Transactions | opbeans-node | Services | APM"` ); diff --git a/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap deleted file mode 100644 index e7f6cba59318a..0000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`UpdateBreadcrumbs /services/:serviceName/errors 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": undefined, - "text": "Errors", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/errors/:groupId 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": "#/services/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Errors", - }, - Object { - "href": undefined, - "text": "myGroupId", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/transactions 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": undefined, - "text": "Transactions", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/transactions/view?transactionName=my-transaction-name 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": "#/services/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Transactions", - }, - Object { - "href": undefined, - "text": "my-transaction-name", - }, -] -`; - -exports[`UpdateBreadcrumbs Homepage 1`] = ` -Array [ - Object { - "href": undefined, - "text": "APM", - }, -] -`; 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 241ba8c244496..e46da26f7dcb0 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 @@ -157,7 +157,7 @@ NodeList [ > My Go Service @@ -263,7 +263,7 @@ NodeList [ > My Python Service diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index c56b7b9aaa720..c4d5be5874215 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import React, { useMemo } from 'react'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; import { useWaterfall } from '../../../hooks/useWaterfall'; @@ -29,6 +30,7 @@ import { useTrackPageview } from '../../../../../observability/public'; import { PROJECTION } from '../../../../common/projections/typings'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { HeightRetainer } from '../../shared/HeightRetainer'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; export function TransactionDetails() { const location = useLocation(); @@ -84,7 +86,14 @@ export function TransactionDetails() { - + + + + + + + + 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 4ceeec8c50221..98702fe3686ff 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -19,10 +19,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { first } from 'lodash'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; import { TransactionList } from './List'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; @@ -125,7 +127,14 @@ export function TransactionOverview() { - + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx index 639277a79ac9a..215e97aebf646 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx @@ -17,6 +17,7 @@ import { mount } from 'enzyme'; import { EuiSuperDatePicker } from '@elastic/eui'; import { MemoryRouter } from 'react-router-dom'; import { wait } from '@testing-library/react'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const mockHistoryPush = jest.spyOn(history, 'push'); const mockRefreshTimeRange = jest.fn(); @@ -35,13 +36,15 @@ const MockUrlParamsProvider: React.FC<{ function mountDatePicker(params?: IUrlParams) { return mount( - - - - - - - + + + + + + + + + ); } @@ -58,6 +61,41 @@ describe('DatePicker', () => { jest.clearAllMocks(); }); + it('should set default query params in the URL', () => { + mountDatePicker(); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + expect.objectContaining({ + search: + 'rangeFrom=now-15m&rangeTo=now&refreshPaused=false&refreshInterval=10000', + }) + ); + }); + + it('should add missing default value', () => { + mountDatePicker({ + rangeTo: 'now', + refreshInterval: 5000, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + expect.objectContaining({ + search: + 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000&refreshPaused=false', + }) + ); + }); + + it('should not set default query params in the URL when values already defined', () => { + mountDatePicker({ + rangeFrom: 'now-1d', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 5000, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(0); + }); + it('should update the URL when the date range changes', () => { const datePicker = mountDatePicker(); datePicker.find(EuiSuperDatePicker).props().onTimeChange({ @@ -66,9 +104,11 @@ describe('DatePicker', () => { isInvalid: false, isQuickSelection: true, }); - expect(mockHistoryPush).toHaveBeenCalledWith( + expect(mockHistoryPush).toHaveBeenCalledTimes(2); + expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ - search: 'rangeFrom=updated-start&rangeTo=updated-end', + search: + 'rangeFrom=updated-start&rangeTo=updated-end&refreshInterval=5000&refreshPaused=false', }) ); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index 4391e4a5b8952..5201d80de5a12 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -5,75 +5,61 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; +import { isEmpty, isEqual, pickBy } from 'lodash'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { history } from '../../../utils/history'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { clearCache } from '../../../services/rest/callApi'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; +import { + TimePickerQuickRange, + TimePickerTimeDefaults, + TimePickerRefreshInterval, +} from './typings'; + +function removeUndefinedAndEmptyProps(obj: T): Partial { + return pickBy(obj, (value) => value !== undefined && !isEmpty(String(value))); +} export function DatePicker() { const location = useLocation(); + const { core } = useApmPluginContext(); + + const timePickerQuickRanges = core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_QUICK_RANGES + ); + + const timePickerTimeDefaults = core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerRefreshIntervalDefaults = core.uiSettings.get< + TimePickerRefreshInterval + >(UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS); + + const DEFAULT_VALUES = { + rangeFrom: timePickerTimeDefaults.from, + rangeTo: timePickerTimeDefaults.to, + refreshPaused: timePickerRefreshIntervalDefaults.pause, + /* + * Must be replaced by timePickerRefreshIntervalDefaults.value when this issue is fixed. + * https://github.com/elastic/kibana/issues/70562 + */ + refreshInterval: 10000, + }; + + const commonlyUsedRanges = timePickerQuickRanges.map( + ({ from, to, display }) => ({ + start: from, + end: to, + label: display, + }) + ); + const { urlParams, refreshTimeRange } = useUrlParams(); - const commonlyUsedRanges = [ - { - start: 'now-15m', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last15MinutesLabel', { - defaultMessage: 'Last 15 minutes', - }), - }, - { - start: 'now-30m', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last30MinutesLabel', { - defaultMessage: 'Last 30 minutes', - }), - }, - { - start: 'now-1h', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last1HourLabel', { - defaultMessage: 'Last 1 hour', - }), - }, - { - start: 'now-24h', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last24HoursLabel', { - defaultMessage: 'Last 24 hours', - }), - }, - { - start: 'now-7d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last7DaysLabel', { - defaultMessage: 'Last 7 days', - }), - }, - { - start: 'now-30d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last30DaysLabel', { - defaultMessage: 'Last 30 days', - }), - }, - { - start: 'now-90d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last90DaysLabel', { - defaultMessage: 'Last 90 days', - }), - }, - { - start: 'now-1y', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last1YearLabel', { - defaultMessage: 'Last 1 year', - }), - }, - ]; function updateUrl(nextQuery: { rangeFrom?: string; @@ -105,6 +91,20 @@ export function DatePicker() { } const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams; + const timePickerURLParams = removeUndefinedAndEmptyProps({ + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval, + }); + + const nextParams = { + ...DEFAULT_VALUES, + ...timePickerURLParams, + }; + if (!isEqual(nextParams, timePickerURLParams)) { + updateUrl(nextParams); + } return ( { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); @@ -45,7 +46,8 @@ describe('DiscoverLinks', () => { } as Span; const href = await getRenderedHref(() => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location); expect(href).toEqual( @@ -65,7 +67,8 @@ describe('DiscoverLinks', () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); @@ -87,7 +90,8 @@ describe('DiscoverLinks', () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); 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 c832d3ded6175..39082c2639a2c 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 @@ -15,7 +15,10 @@ describe('MLJobLink', () => { () => ( ), - { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + { + search: + '?rangeFrom=now/w&rangeTo=now-4h&refreshPaused=true&refreshInterval=0', + } as Location ); expect(href).toMatchInlineSnapshot( @@ -31,7 +34,10 @@ describe('MLJobLink', () => { transactionType="request" /> ), - { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + { + search: + '?rangeFrom=now/w&rangeTo=now-4h&refreshPaused=true&refreshInterval=0', + } as Location ); expect(href).toMatchInlineSnapshot( 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 840846adae019..b4187b2f797ab 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 @@ -15,7 +15,8 @@ test('MLLink produces the correct URL', async () => { ), { - search: '?rangeFrom=now-5h&rangeTo=now-2h', + search: + '?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx index d6518e76aa5e9..1e849e8865d0d 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx @@ -13,7 +13,8 @@ test('APMLink should produce the correct URL', async () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now-5h&rangeTo=now-2h', + search: + '?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); @@ -26,12 +27,13 @@ test('APMLink should retain current kuery value if it exists', async () => { const href = await getRenderedHref( () => , { - search: '?kuery=host.hostname~20~3A~20~22fakehostname~22', + search: + '?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); expect(href).toMatchInlineSnapshot( - `"#/some/path?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=host.hostname~20~3A~20~22fakehostname~22&transactionId=blah"` + `"#/some/path?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0&transactionId=blah"` ); }); @@ -44,11 +46,12 @@ test('APMLink should overwrite current kuery value if new kuery value is provide /> ), { - search: '?kuery=host.hostname~20~3A~20~22fakehostname~22', + search: + '?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); expect(href).toMatchInlineSnapshot( - `"#/some/path?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=host.os~20~3A~20~22linux~22"` + `"#/some/path?kuery=host.os~20~3A~20~22linux~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0"` ); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 3aff241c6dee2..353f476e3f993 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -10,7 +10,6 @@ import url from 'url'; import { pick } from 'lodash'; import { useLocation } from '../../../../hooks/useLocation'; import { APMQueryParams, toQuery, fromQuery } from '../url_helpers'; -import { TIMEPICKER_DEFAULTS } from '../../../../context/UrlParamsContext/constants'; interface Props extends EuiLinkAnchorProps { path?: string; @@ -36,7 +35,6 @@ export function getAPMHref( ) { const currentQuery = toQuery(currentSearch); const nextQuery = { - ...TIMEPICKER_DEFAULTS, ...pick(currentQuery, PERSISTENT_APM_PARAMS), ...query, }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx new file mode 100644 index 0000000000000..268d8bd7ea823 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.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 { showAlert } from './AnomalyDetectionSetupLink'; + +describe('#showAlert', () => { + describe('when an environment is selected', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], 'testing'); + expect(result).toBe(true); + }); + it('should return true when environment is not included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'testing' + ); + expect(result).toBe(true); + }); + it('should return false when environment is included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'staging' + ); + expect(result).toBe(false); + }); + }); + describe('there is no environment selected (All)', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], undefined); + expect(result).toBe(true); + }); + it('should return false when there are any number of jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + undefined + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx index 88d15239b8fba..6f3a5df480d7e 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -23,16 +23,12 @@ export function AnomalyDetectionSetupLink() { ); const isFetchSuccess = status === FETCH_STATUS.SUCCESS; - // Show alert if there are no jobs OR if no job matches the current environment - const showAlert = - isFetchSuccess && !data.jobs.some((job) => environment === job.environment); - return ( {ANOMALY_DETECTION_LINK_LABEL} - {showAlert && ( + {isFetchSuccess && showAlert(data.jobs, environment) && ( @@ -61,3 +57,16 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( 'xpack.apm.anomalyDetectionSetup.linkLabel', { defaultMessage: `Anomaly detection` } ); + +export function showAlert( + jobs: Array<{ environment: string }> = [], + environment: string | undefined +) { + return ( + // No job exists, or + jobs.length === 0 || + // no job exists for the selected environment + (environment !== undefined && + jobs.every((job) => environment !== job.environment)) + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts index 434bd285029ab..8b4d891dba83b 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts @@ -5,7 +5,6 @@ */ import { Location } from 'history'; -import { TIMEPICKER_DEFAULTS } from '../../../context/UrlParamsContext/constants'; import { toQuery } from './url_helpers'; export interface TimepickerRisonData { @@ -21,18 +20,20 @@ export interface TimepickerRisonData { export function getTimepickerRisonData(currentSearch: Location['search']) { const currentQuery = toQuery(currentSearch); - const nextQuery = { - ...TIMEPICKER_DEFAULTS, - ...currentQuery, - }; return { time: { - from: encodeURIComponent(nextQuery.rangeFrom), - to: encodeURIComponent(nextQuery.rangeTo), + from: currentQuery.rangeFrom + ? encodeURIComponent(currentQuery.rangeFrom) + : '', + to: currentQuery.rangeTo ? encodeURIComponent(currentQuery.rangeTo) : '', }, refreshInterval: { - pause: String(nextQuery.refreshPaused), - value: String(nextQuery.refreshInterval), + pause: currentQuery.refreshPaused + ? String(currentQuery.refreshPaused) + : '', + value: currentQuery.refreshInterval + ? String(currentQuery.refreshInterval) + : '', }, }; } diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts index 50325e0b9d604..186fc082ce5fe 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts @@ -17,6 +17,18 @@ describe('Transaction action menu', () => { const date = '2020-02-06T11:00:00.000Z'; const timestamp = { us: new Date(date).getTime() }; + const urlParams = { + rangeFrom: 'now-24h', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 0, + }; + + const location = ({ + search: + '?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + } as unknown) as Location; + it('shows required sections only', () => { const transaction = ({ timestamp, @@ -28,8 +40,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ @@ -77,8 +89,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ @@ -148,8 +160,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx deleted file mode 100644 index 3a0fb3dd17eec..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const TransactionBreakdownHeader: React.FC<{ - showChart: boolean; - onToggleClick: () => void; -}> = ({ showChart, onToggleClick }) => { - return ( - - - -

- {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', - })} -

-
-
- - onToggleClick()} - > - {showChart - ? i18n.translate('xpack.apm.transactionBreakdown.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('xpack.apm.transactionBreakdown.showChart', { - defaultMessage: 'Show chart', - })} - - -
- ); -}; - -export { TransactionBreakdownHeader }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 75ae4e44cfede..51cad6bc65a85 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -3,58 +3,51 @@ * 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 } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownHeader } from './TransactionBreakdownHeader'; -import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { useUiTracker } from '../../../../../observability/public'; +import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { defaultMessage: 'No data within this time range.', }); -const TransactionBreakdown: React.FC<{ - initialIsOpen?: boolean; -}> = ({ initialIsOpen }) => { - const [showChart, setShowChart] = useState(!!initialIsOpen); +const TransactionBreakdown = () => { const { data, status } = useTransactionBreakdown(); - const trackApmEvent = useUiTracker({ app: 'apm' }); const { kpis, timeseries } = data; const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; - const showEmptyMessage = noHits && !showChart; return ( - { - setShowChart(!showChart); - if (showChart) { - trackApmEvent({ metric: 'hide_breakdown_chart' }); - } else { - trackApmEvent({ metric: 'show_breakdown_chart' }); - } - }} - /> + +

+ {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { + defaultMessage: 'Time spent by span type', + })} +

+
- {showEmptyMessage ? ( + {noHits ? ( {emptyMessage} ) : ( )} - {showChart ? ( - - - - ) : null} + + +
); diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx similarity index 79% rename from x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index de60441f4faa0..f87be32b43fc1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -8,11 +8,11 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import React, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; import { useChartsSync } from '../../../../hooks/useChartsSync'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { unit } from '../../../../style/variables'; import { asPercent } from '../../../../utils/formatters'; // @ts-ignore import CustomPlot from '../CustomPlot'; @@ -21,15 +21,23 @@ const tickFormatY = (y?: number) => { return asPercent(y || 0, 1); }; -export const ErrorRateChart = () => { +export const ErroneousTransactionsRateChart = () => { const { urlParams, uiFilters } = useUrlParams(); const syncedChartsProps = useChartsSync(); - const { serviceName, start, end, errorGroupId } = urlParams; - const { data: errorRateData } = useFetcher(() => { + const { + serviceName, + start, + end, + transactionType, + transactionName, + } = urlParams; + + const { data } = useFetcher(() => { if (serviceName && start && end) { return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/rate', + pathname: + '/api/apm/services/{serviceName}/transaction_groups/error_rate', params: { path: { serviceName, @@ -37,13 +45,14 @@ export const ErrorRateChart = () => { query: { start, end, + transactionType, + transactionName, uiFilters: JSON.stringify(uiFilters), - groupId: errorGroupId, }, }, }); } - }, [serviceName, start, end, uiFilters, errorGroupId]); + }, [serviceName, start, end, uiFilters, transactionType, transactionName]); const combinedOnHover = useCallback( (hoverX: number) => { @@ -52,20 +61,20 @@ export const ErrorRateChart = () => { [syncedChartsProps] ); - const errorRates = errorRateData?.errorRates || []; + const errorRates = data?.erroneousTransactionsRate || []; return ( - <> + {i18n.translate('xpack.apm.errorRateChart.title', { - defaultMessage: 'Error Rate', + defaultMessage: 'Transaction error rate', })} { formatTooltipValue={({ y }: { y?: number }) => Number.isFinite(y) ? tickFormatY(y) : 'N/A' } - height={unit * 10} /> - + ); }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js index 002ff19d0d1df..3b2109d68c613 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js @@ -103,6 +103,7 @@ export class HistogramInner extends PureComponent { tooltipHeader, verticalLineHover, width: XY_WIDTH, + height, legends, } = this.props; const { hoveredBucket } = this.state; @@ -181,7 +182,7 @@ export class HistogramInner extends PureComponent { ); return ( -
+
{noHits ? ( <>{emptyStateChart} @@ -250,7 +251,7 @@ export class HistogramInner extends PureComponent { { return { @@ -297,6 +298,7 @@ HistogramInner.propTypes = { tooltipHeader: PropTypes.func, verticalLineHover: PropTypes.func, width: PropTypes.number.isRequired, + height: PropTypes.number, xType: PropTypes.string, legends: PropTypes.array, noHits: PropTypes.bool, @@ -311,6 +313,7 @@ HistogramInner.defaultProps = { verticalLineHover: () => null, xType: 'linear', noHits: false, + height: XY_HEIGHT, }; export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx index 922796afd39bf..7a5d0dd5ce877 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx @@ -44,7 +44,9 @@ describe('ErrorMarker', () => { return component; } function getKueryDecoded(url: string) { - return decodeURIComponent(url.substring(url.indexOf('kuery='), url.length)); + return decodeURIComponent( + url.substring(url.indexOf('kuery='), url.indexOf('&')) + ); } it('renders link with trace and transaction', () => { const component = openPopover(mark); diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 329368e0c80f1..8c38cdcda958d 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -7,6 +7,30 @@ import React from 'react'; import { ApmPluginContext, ApmPluginContextValue } from '.'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; import { ConfigSchema } from '../..'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; + +const uiSettings: Record = { + [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + ], + [UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS]: { + from: 'now-15m', + to: 'now', + }, + [UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS]: { + pause: false, + value: 100000, + }, +}; const mockCore = { chrome: { @@ -27,6 +51,9 @@ const mockCore = { addDanger: () => {}, }, }, + uiSettings: { + get: (key: string) => uiSettings[key], + }, }; const mockConfig: ConfigSchema = { diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx index b88e0b8e23ea5..fbb79eae6a136 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx @@ -53,15 +53,9 @@ describe('UrlParamsContext', () => { const params = getDataFromOutput(wrapper); expect(params).toEqual({ - start: '2000-06-14T12:00:00.000Z', serviceName: 'opbeans-node', - end: '2000-06-15T12:00:00.000Z', page: 0, processorEvent: 'transaction', - rangeFrom: 'now-24h', - rangeTo: 'now', - refreshInterval: 0, - refreshPaused: true, }); }); diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts index d654e60077be9..6297a560440d2 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts @@ -6,9 +6,3 @@ export const TIME_RANGE_REFRESH = 'TIME_RANGE_REFRESH'; export const LOCATION_UPDATE = 'LOCATION_UPDATE'; -export const TIMEPICKER_DEFAULTS = { - rangeFrom: 'now-24h', - rangeTo: 'now', - refreshPaused: 'true', - refreshInterval: '0', -}; diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts index bae7b9a796e19..2201e162904a2 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts @@ -16,7 +16,6 @@ import { toString, } from './helpers'; import { toQuery } from '../../components/shared/Links/url_helpers'; -import { TIMEPICKER_DEFAULTS } from './constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; import { pickKeys } from '../../../common/utils/pick_keys'; @@ -51,10 +50,10 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { sortDirection, sortField, kuery, - refreshPaused = TIMEPICKER_DEFAULTS.refreshPaused, - refreshInterval = TIMEPICKER_DEFAULTS.refreshInterval, - rangeFrom = TIMEPICKER_DEFAULTS.rangeFrom, - rangeTo = TIMEPICKER_DEFAULTS.rangeTo, + refreshPaused, + refreshInterval, + rangeFrom, + rangeTo, environment, searchTerm, } = query; @@ -67,8 +66,8 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { end: getEnd(state, rangeTo), rangeFrom, rangeTo, - refreshPaused: toBoolean(refreshPaused), - refreshInterval: toNumber(refreshInterval), + refreshPaused: refreshPaused ? toBoolean(refreshPaused) : undefined, + refreshInterval: refreshInterval ? toNumber(refreshInterval) : undefined, // query params sortDirection, diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 6e3a29d9f3dbc..f264ae6cd9852 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -39,9 +39,9 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { createStaticIndexPattern } from './services/rest/index_pattern'; import { - fetchLandingPageData, + fetchOverviewPageData, hasData, -} from './services/rest/observability_dashboard'; +} from './services/rest/apm_overview_fetchers'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -81,9 +81,7 @@ export class ApmPlugin implements Plugin { if (plugins.observability) { plugins.observability.dashboard.register({ appName: 'apm', - fetchData: async (params) => { - return fetchLandingPageData(params); - }, + fetchData: fetchOverviewPageData, hasData, }); } diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts similarity index 78% rename from x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts index fd407a8bf72ad..8b3ed38e25319 100644 --- a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts @@ -4,11 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchLandingPageData, hasData } from './observability_dashboard'; +import moment from 'moment'; +import { fetchOverviewPageData, hasData } from './apm_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; describe('Observability dashboard data', () => { const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const params = { + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T14:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, + bucketSize: '600s', + }; afterEach(() => { callApmApiMock.mockClear(); }); @@ -25,7 +37,7 @@ describe('Observability dashboard data', () => { }); }); - describe('fetchLandingPageData', () => { + describe('fetchOverviewPageData', () => { it('returns APM data with series and stats', async () => { callApmApiMock.mockImplementation(() => Promise.resolve({ @@ -37,14 +49,9 @@ describe('Observability dashboard data', () => { ], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -73,14 +80,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -105,14 +107,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts similarity index 70% rename from x-pack/plugins/apm/public/services/rest/observability_dashboard.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts index 409cec8b9ce10..78f3a0a0aaa80 100644 --- a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import { ApmFetchDataResponse, @@ -12,23 +11,26 @@ import { } from '../../../../observability/public'; import { callApmApi } from './createCallApmApi'; -export const fetchLandingPageData = async ({ - startTime, - endTime, +export const fetchOverviewPageData = async ({ + absoluteTime, + relativeTime, bucketSize, }: FetchDataParams): Promise => { const data = await callApmApi({ - pathname: '/api/apm/observability_dashboard', - params: { query: { start: startTime, end: endTime, bucketSize } }, + pathname: '/api/apm/observability_overview', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + bucketSize, + }, + }, }); const { serviceCount, transactionCoordinates } = data; return { - title: i18n.translate('xpack.apm.observabilityDashboard.title', { - defaultMessage: 'APM', - }), - appLink: '/app/apm', + appLink: `/app/apm#/services?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, stats: { services: { type: 'number', @@ -54,6 +56,6 @@ export const fetchLandingPageData = async ({ export async function hasData() { return await callApmApi({ - pathname: '/api/apm/observability_dashboard/has_data', + pathname: '/api/apm/observability_overview/has_data', }); } diff --git a/x-pack/plugins/apm/scripts/merge-telemetry-mapping.js b/x-pack/plugins/apm/scripts/merge-telemetry-mapping.js deleted file mode 100644 index 741df981a9cb0..0000000000000 --- a/x-pack/plugins/apm/scripts/merge-telemetry-mapping.js +++ /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. - */ - -// compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies -require('@babel/register')({ - extensions: ['.ts'], - plugins: [ - '@babel/plugin-proposal-optional-chaining', - '@babel/plugin-proposal-nullish-coalescing-operator', - ], - presets: [ - '@babel/typescript', - ['@babel/preset-env', { targets: { node: 'current' } }], - ], -}); - -require('./merge-telemetry-mapping/index.ts'); diff --git a/x-pack/plugins/apm/scripts/merge-telemetry-mapping/index.ts b/x-pack/plugins/apm/scripts/merge-telemetry-mapping/index.ts deleted file mode 100644 index c06d4cec150dc..0000000000000 --- a/x-pack/plugins/apm/scripts/merge-telemetry-mapping/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 { readFileSync, truncateSync, writeFileSync } from 'fs'; -import { resolve } from 'path'; -import { argv } from 'yargs'; -import { mergeApmTelemetryMapping } from '../../common/apm_telemetry'; - -function errorExit(error?: Error) { - console.error(`usage: ${argv.$0} /path/to/xpack-phone-home.json`); // eslint-disable-line no-console - if (error) { - throw error; - } - process.exit(1); -} - -try { - const filename = resolve(argv._[0]); - const xpackPhoneHomeMapping = JSON.parse(readFileSync(filename, 'utf-8')); - - const newMapping = mergeApmTelemetryMapping(xpackPhoneHomeMapping); - - truncateSync(filename); - writeFileSync(filename, JSON.stringify(newMapping, null, 2)); -} catch (error) { - errorExit(error); -} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts index e3161b49b315d..ea2b57c01acff 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts @@ -16,7 +16,7 @@ describe('data telemetry collection tasks', () => { } as ApmIndicesConfig; describe('cloud', () => { - const cloudTask = tasks.find((task) => task.name === 'cloud'); + const task = tasks.find((t) => t.name === 'cloud'); it('returns a map of cloud provider data', async () => { const search = jest.fn().mockResolvedValueOnce({ @@ -42,7 +42,7 @@ describe('data telemetry collection tasks', () => { }, }); - expect(await cloudTask?.executor({ indices, search } as any)).toEqual({ + expect(await task?.executor({ indices, search } as any)).toEqual({ cloud: { availability_zone: ['us-west-1', 'europe-west1-c'], provider: ['aws', 'gcp'], @@ -55,7 +55,7 @@ describe('data telemetry collection tasks', () => { it('returns an empty map', async () => { const search = jest.fn().mockResolvedValueOnce({}); - expect(await cloudTask?.executor({ indices, search } as any)).toEqual({ + expect(await task?.executor({ indices, search } as any)).toEqual({ cloud: { availability_zone: [], provider: [], @@ -66,8 +66,83 @@ describe('data telemetry collection tasks', () => { }); }); + describe('processor_events', () => { + const task = tasks.find((t) => t.name === 'processor_events'); + + it('returns a map of processor events', async () => { + const getTime = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValue(1594330792957); + + const search = jest.fn().mockImplementation((params: any) => { + const isTotalHitsQuery = params?.body?.track_total_hits; + + return Promise.resolve( + isTotalHitsQuery + ? { hits: { total: { value: 1 } } } + : { + hits: { + hits: [{ _source: { '@timestamp': 1 } }], + }, + } + ); + }); + + expect(await task?.executor({ indices, search } as any)).toEqual({ + counts: { + error: { + '1d': 1, + all: 1, + }, + metric: { + '1d': 1, + all: 1, + }, + onboarding: { + '1d': 1, + all: 1, + }, + sourcemap: { + '1d': 1, + all: 1, + }, + span: { + '1d': 1, + all: 1, + }, + transaction: { + '1d': 1, + all: 1, + }, + }, + retainment: { + error: { + ms: 0, + }, + metric: { + ms: 0, + }, + onboarding: { + ms: 0, + }, + sourcemap: { + ms: 0, + }, + span: { + ms: 0, + }, + transaction: { + ms: 0, + }, + }, + }); + + getTime.mockRestore(); + }); + }); + describe('integrations', () => { - const integrationsTask = tasks.find((task) => task.name === 'integrations'); + const task = tasks.find((t) => t.name === 'integrations'); it('returns the count of ML jobs', async () => { const transportRequest = jest @@ -75,7 +150,7 @@ describe('data telemetry collection tasks', () => { .mockResolvedValueOnce({ body: { count: 1 } }); expect( - await integrationsTask?.executor({ indices, transportRequest } as any) + await task?.executor({ indices, transportRequest } as any) ).toEqual({ integrations: { ml: { @@ -90,7 +165,7 @@ describe('data telemetry collection tasks', () => { const transportRequest = jest.fn().mockResolvedValueOnce({}); expect( - await integrationsTask?.executor({ indices, transportRequest } as any) + await task?.executor({ indices, transportRequest } as any) ).toEqual({ integrations: { ml: { @@ -101,4 +176,93 @@ describe('data telemetry collection tasks', () => { }); }); }); + + describe('indices_stats', () => { + const task = tasks.find((t) => t.name === 'indices_stats'); + + it('returns a map of index stats', async () => { + const indicesStats = jest.fn().mockResolvedValueOnce({ + _all: { total: { docs: { count: 1 }, store: { size_in_bytes: 1 } } }, + _shards: { total: 1 }, + }); + + expect(await task?.executor({ indices, indicesStats } as any)).toEqual({ + indices: { + shards: { + total: 1, + }, + all: { + total: { + docs: { + count: 1, + }, + store: { + size_in_bytes: 1, + }, + }, + }, + }, + }); + }); + + describe('with no results', () => { + it('returns zero values', async () => { + const indicesStats = jest.fn().mockResolvedValueOnce({}); + + expect(await task?.executor({ indices, indicesStats } as any)).toEqual({ + indices: { + shards: { + total: 0, + }, + all: { + total: { + docs: { + count: 0, + }, + store: { + size_in_bytes: 0, + }, + }, + }, + }, + }); + }); + }); + }); + + describe('cardinality', () => { + const task = tasks.find((t) => t.name === 'cardinality'); + + it('returns cardinalities', async () => { + const search = jest.fn().mockImplementation((params: any) => { + const isRumQuery = params.body.query.bool.filter.length === 2; + if (isRumQuery) { + return Promise.resolve({ + aggregations: { + 'client.geo.country_iso_code': { value: 5 }, + 'transaction.name': { value: 1 }, + 'user_agent.original': { value: 2 }, + }, + }); + } else { + return Promise.resolve({ + aggregations: { + 'transaction.name': { value: 3 }, + 'user_agent.original': { value: 4 }, + }, + }); + } + }); + + expect(await task?.executor({ search } as any)).toEqual({ + cardinality: { + client: { geo: { country_iso_code: { rum: { '1d': 5 } } } }, + transaction: { name: { all_agents: { '1d': 3 }, rum: { '1d': 1 } } }, + user_agent: { + original: { all_agents: { '1d': 4 }, rum: { '1d': 2 } }, + }, + }, + }); + }); + }); }); 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 4bbaaf3e86e78..2ecb5a935893f 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 @@ -9,6 +9,7 @@ import { AGENT_NAMES } from '../../../../common/agent_name'; import { AGENT_NAME, AGENT_VERSION, + CLIENT_GEO_COUNTRY_ISO_CODE, CLOUD_AVAILABILITY_ZONE, CLOUD_PROVIDER, CLOUD_REGION, @@ -34,6 +35,9 @@ import { APMTelemetry } from '../types'; const TIME_RANGES = ['1d', 'all'] as const; type TimeRange = typeof TIME_RANGES[number]; +const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; +const timeout = '5m'; + export const tasks: TelemetryTask[] = [ { name: 'cloud', @@ -62,6 +66,7 @@ export const tasks: TelemetryTask[] = [ ], body: { size: 0, + timeout, aggs: { [az]: { terms: { @@ -109,15 +114,14 @@ export const tasks: TelemetryTask[] = [ type ProcessorEvent = keyof typeof indicesByProcessorEvent; - const jobs: Array<{ + interface Job { processorEvent: ProcessorEvent; timeRange: TimeRange; - }> = flatten( - (Object.keys( - indicesByProcessorEvent - ) as ProcessorEvent[]).map((processorEvent) => - TIME_RANGES.map((timeRange) => ({ processorEvent, timeRange })) - ) + } + + const events = Object.keys(indicesByProcessorEvent) as ProcessorEvent[]; + const jobs: Job[] = events.flatMap((processorEvent) => + TIME_RANGES.map((timeRange) => ({ processorEvent, timeRange })) ); const allData = await jobs.reduce((prevJob, current) => { @@ -128,21 +132,12 @@ export const tasks: TelemetryTask[] = [ index: indicesByProcessorEvent[processorEvent], body: { size: 0, + timeout, query: { bool: { filter: [ { term: { [PROCESSOR_EVENT]: processorEvent } }, - ...(timeRange !== 'all' - ? [ - { - range: { - '@timestamp': { - gte: `now-${timeRange}`, - }, - }, - }, - ] - : []), + ...(timeRange === '1d' ? [range1d] : []), ], }, }, @@ -155,6 +150,7 @@ export const tasks: TelemetryTask[] = [ ? await search({ index: indicesByProcessorEvent[processorEvent], body: { + timeout, query: { bool: { filter: [ @@ -208,6 +204,7 @@ export const tasks: TelemetryTask[] = [ index: indices.apmAgentConfigurationIndex, body: { size: 0, + timeout, track_total_hits: true, }, }) @@ -237,6 +234,7 @@ export const tasks: TelemetryTask[] = [ ], body: { size: 0, + timeout, query: { bool: { filter: [ @@ -245,13 +243,7 @@ export const tasks: TelemetryTask[] = [ [AGENT_NAME]: agentName, }, }, - { - range: { - '@timestamp': { - gte: 'now-1d', - }, - }, - }, + range1d, ], }, }, @@ -297,6 +289,7 @@ export const tasks: TelemetryTask[] = [ }, }, size: 1, + timeout, sort: { '@timestamp': 'desc', }, @@ -330,12 +323,12 @@ export const tasks: TelemetryTask[] = [ { name: 'groupings', executor: async ({ search, indices }) => { - const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; const errorGroupsCount = ( await search({ index: indices['apm_oss.errorIndices'], body: { size: 0, + timeout, query: { bool: { filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d], @@ -368,6 +361,7 @@ export const tasks: TelemetryTask[] = [ index: indices['apm_oss.transactionIndices'], body: { size: 0, + timeout, query: { bool: { filter: [ @@ -415,6 +409,7 @@ export const tasks: TelemetryTask[] = [ }, track_total_hits: true, size: 0, + timeout, }, }) ).hits.total.value; @@ -428,6 +423,7 @@ export const tasks: TelemetryTask[] = [ ], body: { size: 0, + timeout, query: { bool: { filter: [range1d], @@ -497,12 +493,10 @@ export const tasks: TelemetryTask[] = [ ], body: { size: 0, + timeout, query: { bool: { - filter: [ - { term: { [AGENT_NAME]: agentName } }, - { range: { '@timestamp': { gte: 'now-1d' } } }, - ], + filter: [{ term: { [AGENT_NAME]: agentName } }, range1d], }, }, sort: { @@ -699,15 +693,15 @@ export const tasks: TelemetryTask[] = [ return { indices: { shards: { - total: response._shards.total, + total: response._shards?.total ?? 0, }, all: { total: { docs: { - count: response._all.total.docs.count, + count: response._all?.total?.docs?.count ?? 0, }, store: { - size_in_bytes: response._all.total.store.size_in_bytes, + size_in_bytes: response._all?.total?.store?.size_in_bytes ?? 0, }, }, }, @@ -721,9 +715,10 @@ export const tasks: TelemetryTask[] = [ const allAgentsCardinalityResponse = await search({ body: { size: 0, + timeout, query: { bool: { - filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }], + filter: [range1d], }, }, aggs: { @@ -744,15 +739,19 @@ export const tasks: TelemetryTask[] = [ const rumAgentCardinalityResponse = await search({ body: { size: 0, + timeout, query: { bool: { filter: [ - { range: { '@timestamp': { gte: 'now-1d' } } }, + range1d, { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } }, ], }, }, aggs: { + [CLIENT_GEO_COUNTRY_ISO_CODE]: { + cardinality: { field: CLIENT_GEO_COUNTRY_ISO_CODE }, + }, [TRANSACTION_NAME]: { cardinality: { field: TRANSACTION_NAME, @@ -769,6 +768,18 @@ export const tasks: TelemetryTask[] = [ return { cardinality: { + client: { + geo: { + country_iso_code: { + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[ + CLIENT_GEO_COUNTRY_ISO_CODE + ].value, + }, + }, + }, + }, transaction: { name: { all_agents: { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index 632e653a2f6e9..2836cf100a432 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,25 +3,26 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; +import { CoreSetup, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { APMConfig } from '../..'; import { - TaskManagerStartContract, TaskManagerSetupContract, + TaskManagerStartContract, } from '../../../../task_manager/server'; -import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { APM_TELEMETRY_SAVED_OBJECT_ID, APM_TELEMETRY_SAVED_OBJECT_TYPE, } from '../../../common/apm_saved_object_constants'; +import { getApmTelemetryMapping } from '../../../common/apm_telemetry'; +import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { collectDataTelemetry, CollectTelemetryParams, } from './collect_data_telemetry'; -import { APMConfig } from '../..'; -import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; @@ -97,6 +98,7 @@ export async function createApmTelemetry({ const collector = usageCollector.makeUsageCollector({ type: 'apm', + schema: getApmTelemetryMapping(), fetch: async () => { try { const data = ( diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts index a1d94333b1a08..4c376aac52f5b 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -44,6 +44,7 @@ export type APMDataTelemetry = DeepPartial<{ services: TimeframeMap; }; cardinality: { + client: { geo: { country_iso_code: { rum: TimeframeMap1d } } }; user_agent: { original: { all_agents: TimeframeMap1d; diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts b/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts deleted file mode 100644 index e91d3953942d9..0000000000000 --- a/x-pack/plugins/apm/server/lib/errors/get_error_rate.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 { - ERROR_GROUP_ID, - PROCESSOR_EVENT, - SERVICE_NAME, -} from '../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { getMetricsDateHistogramParams } from '../helpers/metrics'; -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../helpers/setup_request'; -import { rangeFilter } from '../../../common/utils/range_filter'; - -export async function getErrorRate({ - serviceName, - groupId, - setup, -}: { - serviceName: string; - groupId?: string; - setup: Setup & SetupTimeRange & SetupUIFilters; -}) { - const { start, end, uiFiltersES, client, indices } = setup; - - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES, - ]; - - const aggs = { - response_times: { - date_histogram: getMetricsDateHistogramParams(start, end), - }, - }; - - const getTransactionBucketAggregation = async () => { - const resp = await client.search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ], - }, - }, - aggs, - }, - }); - return { - totalHits: resp.hits.total.value, - responseTimeBuckets: resp.aggregations?.response_times.buckets, - }; - }; - const getErrorBucketAggregation = async () => { - const groupIdFilter = groupId - ? [{ term: { [ERROR_GROUP_ID]: groupId } }] - : []; - const resp = await client.search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - ...groupIdFilter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, - ], - }, - }, - aggs, - }, - }); - return resp.aggregations?.response_times.buckets; - }; - - const [transactions, errorResponseTimeBuckets] = await Promise.all([ - getTransactionBucketAggregation(), - getErrorBucketAggregation(), - ]); - - const transactionCountByTimestamp: Record = {}; - if (transactions?.responseTimeBuckets) { - transactions.responseTimeBuckets.forEach((bucket) => { - transactionCountByTimestamp[bucket.key] = bucket.doc_count; - }); - } - - const errorRates = errorResponseTimeBuckets?.map((bucket) => { - const { key, doc_count: errorCount } = bucket; - const relativeRate = errorCount / transactionCountByTimestamp[key]; - return { x: key, y: relativeRate }; - }); - - return { - noHits: transactions?.totalHits === 0, - errorRates, - }; -} diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index af073076a812a..6f381d4945ab4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -112,7 +112,7 @@ function getMlSetup(context: APMRequestHandlerContext, request: KibanaRequest) { return; } const ml = context.plugins.ml; - const mlClient = ml.mlClient.asScoped(request).callAsCurrentUser; + const mlClient = ml.mlClient.asScoped(request); return { mlSystem: ml.mlSystemProvider(mlClient, request), anomalyDetectors: ml.anomalyDetectorsProvider(mlClient, request), diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts rename to x-pack/plugins/apm/server/lib/observability_overview/has_data.ts 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 new file mode 100644 index 0000000000000..5b66f7d7a45e7 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + PROCESSOR_EVENT, + HTTP_RESPONSE_STATUS_CODE, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { getMetricsDateHistogramParams } from '../helpers/metrics'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; + +export async function getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, +}: { + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const { start, end, uiFiltersES, client, indices } = setup; + + const transactionNamefilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const transactionTypefilter = transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []; + + const filter = [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + { exists: { field: HTTP_RESPONSE_STATUS_CODE } }, + ...transactionNamefilter, + ...transactionTypefilter, + ...uiFiltersES, + ]; + + const params = { + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + total_transactions: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs: { + erroneous_transactions: { + filter: { range: { [HTTP_RESPONSE_STATUS_CODE]: { gte: 400 } } }, + }, + }, + }, + }, + }, + }; + + const resp = await client.search(params); + + const noHits = resp.hits.total.value === 0; + + const erroneousTransactionsRate = + resp.aggregations?.total_transactions.buckets.map( + ({ key, doc_count: totalTransactions, erroneous_transactions }) => { + const errornousTransactionsCount = + // @ts-ignore + erroneous_transactions.doc_count; + return { + x: key, + y: errornousTransactionsCount / totalTransactions, + }; + } + ) || []; + + return { noHits, erroneousTransactionsRate }; +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts index 2f5e703251c03..154821b261fd1 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts @@ -31,7 +31,7 @@ export async function getMlBucketSize({ body: { _source: 'bucket_span', size: 1, - terminateAfter: 1, + terminate_after: 1, query: { bool: { filter: [ 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 513c44904683e..4e3aa6d4ebe1d 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -13,7 +13,6 @@ import { errorDistributionRoute, errorGroupsRoute, errorsRoute, - errorRateRoute, } from './errors'; import { serviceAgentNameRoute, @@ -49,6 +48,7 @@ import { transactionGroupsRoute, transactionGroupsAvgDurationByCountry, transactionGroupsAvgDurationByBrowser, + transactionGroupsErrorRateRoute, } from './transaction_groups'; import { errorGroupsLocalFiltersRoute, @@ -79,9 +79,9 @@ import { rumServicesRoute, } from './rum_client'; import { - observabilityDashboardHasDataRoute, - observabilityDashboardDataRoute, -} from './observability_dashboard'; + observabilityOverviewHasDataRoute, + observabilityOverviewRoute, +} from './observability_overview'; import { anomalyDetectionJobsRoute, createAnomalyDetectionJobsRoute, @@ -99,7 +99,6 @@ const createApmApi = () => { .add(errorDistributionRoute) .add(errorGroupsRoute) .add(errorsRoute) - .add(errorRateRoute) // Services .add(serviceAgentNameRoute) @@ -139,6 +138,7 @@ const createApmApi = () => { .add(transactionGroupsRoute) .add(transactionGroupsAvgDurationByBrowser) .add(transactionGroupsAvgDurationByCountry) + .add(transactionGroupsErrorRateRoute) // UI filters .add(errorGroupsLocalFiltersRoute) @@ -176,8 +176,8 @@ const createApmApi = () => { .add(rumServicesRoute) // Observability dashboard - .add(observabilityDashboardHasDataRoute) - .add(observabilityDashboardDataRoute) + .add(observabilityOverviewHasDataRoute) + .add(observabilityOverviewRoute) // Anomaly detection .add(anomalyDetectionJobsRoute) diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 97314a9a61661..1615550027d3c 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,7 +11,6 @@ import { getErrorGroup } from '../lib/errors/get_error_group'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getErrorRate } from '../lib/errors/get_error_rate'; export const errorsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/errors', @@ -81,26 +80,3 @@ export const errorDistributionRoute = createRoute(() => ({ return getErrorDistribution({ serviceName, groupId, setup }); }, })); - -export const errorRateRoute = createRoute(() => ({ - path: '/api/apm/services/{serviceName}/errors/rate', - params: { - path: t.type({ - serviceName: t.string, - }), - query: t.intersection([ - t.partial({ - groupId: t.string, - }), - uiFiltersRt, - rangeRt, - ]), - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; - const { serviceName } = params.path; - const { groupId } = params.query; - return getErrorRate({ serviceName, groupId, setup }); - }, -})); diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts similarity index 74% rename from x-pack/plugins/apm/server/routes/observability_dashboard.ts rename to x-pack/plugins/apm/server/routes/observability_overview.ts index 10c74295fe3e4..d5bb3b49c2f4c 100644 --- a/x-pack/plugins/apm/server/routes/observability_dashboard.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -5,22 +5,22 @@ */ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; -import { hasData } from '../lib/observability_dashboard/has_data'; +import { getServiceCount } from '../lib/observability_overview/get_service_count'; +import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; +import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; -import { getServiceCount } from '../lib/observability_dashboard/get_service_count'; -import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates'; -export const observabilityDashboardHasDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard/has_data', +export const observabilityOverviewHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_data', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); return await hasData({ setup }); }, })); -export const observabilityDashboardDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard', +export const observabilityOverviewRoute = createRoute(() => ({ + path: '/api/apm/observability_overview', params: { query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }, diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 3d939b04795c6..dca2fb1d9b295 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -14,6 +14,7 @@ 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 { getErrorRate } from '../lib/transaction_groups/get_error_rate'; import { UIFilters } from '../../typings/ui_filters'; export const transactionGroupsRoute = createRoute(() => ({ @@ -209,3 +210,32 @@ export const transactionGroupsAvgDurationByCountry = createRoute(() => ({ }); }, })); + +export const transactionGroupsErrorRateRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/error_rate', + params: { + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + uiFiltersRt, + rangeRt, + t.partial({ + transactionType: t.string, + transactionName: t.string, + }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + const { serviceName } = params.path; + const { transactionType, transactionName } = params.query; + return getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, + }); + }, +})); diff --git a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts index 6c1783054a312..6008c52d0324b 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts @@ -22,7 +22,6 @@ import { import { ManagementSetup, RegisterManagementAppArgs, - ManagementSectionId, } from '../../../../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../../../../licensing/public'; import { BeatsManagementConfigType } from '../../../../common'; @@ -105,7 +104,7 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { } public registerManagementUI(mount: RegisterManagementAppArgs['mount']) { - const section = this.management.sections.getSection(ManagementSectionId.Ingest); + const section = this.management.sections.section.ingest; section.registerApp({ id: 'beats_management', title: i18n.translate('xpack.beatsManagement.centralManagementLinkLabel', { diff --git a/x-pack/plugins/canvas/.storybook/storyshots.test.js b/x-pack/plugins/canvas/.storybook/storyshots.test.js index a3412c3a14e79..7195b97712464 100644 --- a/x-pack/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/plugins/canvas/.storybook/storyshots.test.js @@ -84,6 +84,10 @@ import { RenderedElement } from '../shareable_runtime/components/rendered_elemen jest.mock('../shareable_runtime/components/rendered_element'); RenderedElement.mockImplementation(() => 'RenderedElement'); +import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; +jest.mock('@elastic/eui/test-env/components/observer/observer'); +EuiObserver.mockImplementation(() => 'EuiObserver'); + addSerializer(styleSheetSerializer); // Initialize Storyshots and build the Jest Snapshots diff --git a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts index 271fc7a979057..4b1f31cb14687 100644 --- a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts +++ b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts @@ -25,6 +25,7 @@ const BaseWorkpad: CanvasWorkpad = { pages: [], colors: [], isWriteable: true, + variables: [], }; const BasePage: CanvasPage = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js index 7384986fa5c2b..618fe756ba0a4 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js @@ -107,7 +107,7 @@ const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => { diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 8acda5da4f0d2..78083f26a38b1 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -545,7 +545,7 @@ export const ComponentStrings = { }), getTitle: () => i18n.translate('xpack.canvas.pageConfig.title', { - defaultMessage: 'Page styles', + defaultMessage: 'Page settings', }), getTransitionLabel: () => i18n.translate('xpack.canvas.pageConfig.transitionLabel', { @@ -899,6 +899,144 @@ export const ComponentStrings = { defaultMessage: 'Close tray', }), }, + VarConfig: { + getAddButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.addButtonLabel', { + defaultMessage: 'Add a variable', + }), + getAddTooltipLabel: () => + i18n.translate('xpack.canvas.varConfig.addTooltipLabel', { + defaultMessage: 'Add a variable', + }), + getCopyActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.copyActionButtonLabel', { + defaultMessage: 'Copy snippet', + }), + getCopyActionTooltipLabel: () => + i18n.translate('xpack.canvas.varConfig.copyActionTooltipLabel', { + defaultMessage: 'Copy variable syntax to clipboard', + }), + getCopyNotificationDescription: () => + i18n.translate('xpack.canvas.varConfig.copyNotificationDescription', { + defaultMessage: 'Variable syntax copied to clipboard', + }), + getDeleteActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.deleteActionButtonLabel', { + defaultMessage: 'Delete variable', + }), + getDeleteNotificationDescription: () => + i18n.translate('xpack.canvas.varConfig.deleteNotificationDescription', { + defaultMessage: 'Variable successfully deleted', + }), + getEditActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.editActionButtonLabel', { + defaultMessage: 'Edit variable', + }), + getEmptyDescription: () => + i18n.translate('xpack.canvas.varConfig.emptyDescription', { + defaultMessage: + 'This workpad has no variables currently. You may add variables to store and edit common values. These variables can then be used in elements or within the expression editor.', + }), + getTableNameLabel: () => + i18n.translate('xpack.canvas.varConfig.tableNameLabel', { + defaultMessage: 'Name', + }), + getTableTypeLabel: () => + i18n.translate('xpack.canvas.varConfig.tableTypeLabel', { + defaultMessage: 'Type', + }), + getTableValueLabel: () => + i18n.translate('xpack.canvas.varConfig.tableValueLabel', { + defaultMessage: 'Value', + }), + getTitle: () => + i18n.translate('xpack.canvas.varConfig.titleLabel', { + defaultMessage: 'Variables', + }), + getTitleTooltip: () => + i18n.translate('xpack.canvas.varConfig.titleTooltip', { + defaultMessage: 'Add variables to store and edit common values', + }), + }, + VarConfigDeleteVar: { + getCancelButtonLabel: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + getDeleteButtonLabel: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.deleteButtonLabel', { + defaultMessage: 'Delete variable', + }), + getTitle: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.titleLabel', { + defaultMessage: 'Delete variable?', + }), + getWarningDescription: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.warningDescription', { + defaultMessage: + 'Deleting this variable may adversely affect the workpad. Are you sure you wish to continue?', + }), + }, + VarConfigEditVar: { + getAddTitle: () => + i18n.translate('xpack.canvas.varConfigEditVar.addTitleLabel', { + defaultMessage: 'Add variable', + }), + getCancelButtonLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + getDuplicateNameError: () => + i18n.translate('xpack.canvas.varConfigEditVar.duplicateNameError', { + defaultMessage: 'Variable name already in use', + }), + getEditTitle: () => + i18n.translate('xpack.canvas.varConfigEditVar.editTitleLabel', { + defaultMessage: 'Edit variable', + }), + getEditWarning: () => + i18n.translate('xpack.canvas.varConfigEditVar.editWarning', { + defaultMessage: 'Editing a variable in use may adversely affect your workpad', + }), + getNameFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.nameFieldLabel', { + defaultMessage: 'Name', + }), + getSaveButtonLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.saveButtonLabel', { + defaultMessage: 'Save changes', + }), + getTypeBooleanLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeBooleanLabel', { + defaultMessage: 'Boolean', + }), + getTypeFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeFieldLabel', { + defaultMessage: 'Type', + }), + getTypeNumberLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeNumberLabel', { + defaultMessage: 'Number', + }), + getTypeStringLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeStringLabel', { + defaultMessage: 'String', + }), + getValueFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.valueFieldLabel', { + defaultMessage: 'Value', + }), + }, + VarConfigVarValueField: { + getFalseOption: () => + i18n.translate('xpack.canvas.varConfigVarValueField.falseOption', { + defaultMessage: 'False', + }), + getTrueOption: () => + i18n.translate('xpack.canvas.varConfigVarValueField.trueOption', { + defaultMessage: 'True', + }), + }, WorkpadConfig: { getApplyStylesheetButtonLabel: () => i18n.translate('xpack.canvas.workpadConfig.applyStylesheetButtonLabel', { diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js index dfd99b18646a6..f356eedff19cf 100644 --- a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js @@ -120,7 +120,7 @@ class ArgFormComponent extends PureComponent { ); return ( -
+
{ @@ -17,18 +17,16 @@ export const ArgLabel = (props) => { {expandable ? ( - - {label} - + {label} } extraAction={simpleArg} initialIsOpen={initialIsOpen} > -
{children}
+
{children}
) : ( simpleArg && ( diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js index 045e98bab870e..dcd933c2320cf 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js @@ -15,10 +15,13 @@ export const DatasourcePreview = compose( withState('datatable', 'setDatatable'), lifecycle({ componentDidMount() { - interpretAst({ - type: 'expression', - chain: [this.props.function], - }).then(this.props.setDatatable); + interpretAst( + { + type: 'expression', + chain: [this.props.function], + }, + {} + ).then(this.props.setDatatable); }, }), branch(({ datatable }) => !datatable, renderComponent(Loading)) diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.js b/x-pack/plugins/canvas/public/components/element_config/element_config.js deleted file mode 100644 index 5d710ef883548..0000000000000 --- a/x-pack/plugins/canvas/public/components/element_config/element_config.js +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion, EuiText, EuiSpacer } from '@elastic/eui'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { ComponentStrings } from '../../../i18n'; - -const { ElementConfig: strings } = ComponentStrings; - -export const ElementConfig = ({ elementStats }) => { - if (!elementStats) { - return null; - } - - const { total, ready, error } = elementStats; - const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100; - - return ( - - {strings.getTitle()} - - } - initialIsOpen={false} - > - - - - - - - - - - - - - - - - - ); -}; - -ElementConfig.propTypes = { - elementStats: PropTypes.object, -}; diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.tsx b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx new file mode 100644 index 0000000000000..c2fd827d62099 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion } from '@elastic/eui'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { ComponentStrings } from '../../../i18n'; +import { State } from '../../../types'; + +const { ElementConfig: strings } = ComponentStrings; + +interface Props { + elementStats: State['transient']['elementStats']; +} + +export const ElementConfig = ({ elementStats }: Props) => { + if (!elementStats) { + return null; + } + + const { total, ready, error } = elementStats; + const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100; + + return ( +
+ +
+ + + + + + + + + + + + + + +
+
+
+ ); +}; + +ElementConfig.propTypes = { + elementStats: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/page_config/page_config.js b/x-pack/plugins/canvas/public/components/page_config/page_config.js index 51a4762fca501..c45536ac7b175 100644 --- a/x-pack/plugins/canvas/public/components/page_config/page_config.js +++ b/x-pack/plugins/canvas/public/components/page_config/page_config.js @@ -30,7 +30,7 @@ export const PageConfig = ({ }) => { return ( - +

{strings.getTitle()}

diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx index 44d2f70fcdfad..c318743086b44 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, ChangeEvent, FunctionComponent, useState, useEffect } from 'react'; +import React, { + Fragment, + ChangeEvent, + FunctionComponent, + useState, + useEffect, + useRef, +} from 'react'; import PropTypes from 'prop-types'; import { EuiModal, @@ -72,12 +79,16 @@ export const SavedElementsModal: FunctionComponent = ({ removeCustomElement, updateCustomElement, }) => { + const hasLoadedElements = useRef(false); const [elementToDelete, setElementToDelete] = useState(null); const [elementToEdit, setElementToEdit] = useState(null); useEffect(() => { - findCustomElements(); - }); + if (!hasLoadedElements.current) { + hasLoadedElements.current = true; + findCustomElements(); + } + }, [findCustomElements, hasLoadedElements]); const showEditModal = (element: CustomElement) => setElementToEdit(element); const hideEditModal = () => setElementToEdit(null); diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx index f89ab79a086cf..62673a5b38cc8 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx @@ -17,8 +17,6 @@ export const GlobalConfig: FunctionComponent = () => ( - - diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss index 338d515165e43..76d758197aa19 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss @@ -31,12 +31,68 @@ &--isEmpty { border-bottom: none; } + + .canvasSidebar__expandable:last-child { + .canvasSidebar__accordion { + margin-bottom: (-$euiSizeS); + } + + .canvasSidebar__accordion:after { + content: none; + } + + .canvasSidebar__accordion.euiAccordion-isOpen:after { + display: none; + } + } } .canvasSidebar__panel-noMinWidth .euiButton { min-width: 0; } +.canvasSidebar__expandable + .canvasSidebar__expandable { + margin-top: 0; + + .canvasSidebar__accordion:before { + display: none; + } +} + +.canvasSidebar__accordion { + padding: $euiSizeM; + margin: 0 (-$euiSizeM); + background: $euiColorLightestShade; + position: relative; + + &.euiAccordion-isOpen { + background: transparent; + } + + &:before, + &:after { + content: ''; + height: 1px; + position: absolute; + left: 0; + width: 100%; + background: $euiColorLightShade; + } + + &:before { + top: 0; + } + + &:after { + bottom: 0; + } +} + +.canvasSidebar__accordionContent { + padding-top: $euiSize; + padding-left: $euiSizeXS + $euiSizeS + $euiSize; +} + @keyframes sidebarPop { 0% { opacity: 0; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot new file mode 100644 index 0000000000000..64f8cba665c15 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/DeleteVar default 1`] = ` +Array [ +
+ +
, +
+
+
+
+
+
+ Deleting this variable may adversely affect the workpad. Are you sure you wish to continue? +
+
+
+
+
+
+
+ +
+
+ +
+
+
+
, +] +`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot new file mode 100644 index 0000000000000..65043e13e5143 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot @@ -0,0 +1,1236 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/EditVar edit variable (boolean) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + Boolean + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar edit variable (number) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + Number + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar edit variable (string) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + String + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar new variable 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + String + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot new file mode 100644 index 0000000000000..146f07a9d0118 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/VarConfig default 1`] = ` +
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx new file mode 100644 index 0000000000000..8f5b73d1f6ae9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx @@ -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 { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { DeleteVar } from '../delete_var'; + +const variable: CanvasVariable = { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', +}; + +storiesOf('components/Variables/DeleteVar', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx new file mode 100644 index 0000000000000..0369c2c09a39c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.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 { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { EditVar } from '../edit_var'; + +const variables: CanvasVariable[] = [ + { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', + }, + { + name: 'bigNumber', + value: 1000, + type: 'number', + }, + { + name: 'zenMode', + value: true, + type: 'boolean', + }, +]; + +storiesOf('components/Variables/EditVar', module) + .add('new variable', () => ( + + )) + .add('edit variable (string)', () => ( + + )) + .add('edit variable (number)', () => ( + + )) + .add('edit variable (boolean)', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx new file mode 100644 index 0000000000000..ac5c97d122138 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { VarConfig } from '../var_config'; + +const variables: CanvasVariable[] = [ + { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', + }, + { + name: 'bigNumber', + value: 1000, + type: 'number', + }, + { + name: 'zenMode', + value: true, + type: 'boolean', + }, +]; + +storiesOf('components/Variables/VarConfig', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx new file mode 100644 index 0000000000000..fa1771a752848 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { CanvasVariable } from '../../../types'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigDeleteVar: strings } = ComponentStrings; + +import './var_panel.scss'; + +interface Props { + selectedVar: CanvasVariable; + onDelete: (v: CanvasVariable) => void; + onCancel: () => void; +} + +export const DeleteVar: FC = ({ selectedVar, onCancel, onDelete }) => { + return ( + +
+ +
+
+
+ + + + {strings.getWarningDescription()} + + + + + + + + + onDelete(selectedVar)} + iconType="trash" + > + {strings.getDeleteButtonLabel()} + + + + onCancel()}> + {strings.getCancelButtonLabel()} + + + +
+
+
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.scss b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss new file mode 100644 index 0000000000000..7d4a7a4c81ba1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss @@ -0,0 +1,8 @@ +.canvasEditVar__typeOption { + display: flex; + align-items: center; + + .canvasEditVar__tokenIcon { + margin-right: 15px; + } +} diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx b/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx new file mode 100644 index 0000000000000..a1a5541431d26 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.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 React, { useState, FC } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToken, + EuiSuperSelect, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiButton, + EuiButtonEmpty, + EuiSpacer, + EuiCallOut, +} from '@elastic/eui'; +import { CanvasVariable } from '../../../types'; + +import { VarValueField } from './var_value_field'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigEditVar: strings } = ComponentStrings; + +import './edit_var.scss'; +import './var_panel.scss'; + +interface Props { + selectedVar: CanvasVariable | null; + variables: CanvasVariable[]; + onSave: (v: CanvasVariable) => void; + onCancel: () => void; +} + +const checkDupeName = (newName: string, oldName: string | null, variables: CanvasVariable[]) => { + const match = variables.find((v) => { + // If the new name matches an existing variable and that + // matched variable name isn't the old name, then there + // is a duplicate + return newName === v.name && (!oldName || v.name !== oldName); + }); + + return !!match; +}; + +export const EditVar: FC = ({ variables, selectedVar, onCancel, onSave }) => { + // If there isn't a selected variable, we're creating a new var + const isNew = selectedVar === null; + + const [type, setType] = useState(isNew ? 'string' : selectedVar!.type); + const [name, setName] = useState(isNew ? '' : selectedVar!.name); + const [value, setValue] = useState(isNew ? '' : selectedVar!.value); + + const hasDupeName = checkDupeName(name, selectedVar && selectedVar.name, variables); + + const typeOptions = [ + { + value: 'string', + inputDisplay: ( +
+ {' '} + {strings.getTypeStringLabel()} +
+ ), + }, + { + value: 'number', + inputDisplay: ( +
+ {' '} + {strings.getTypeNumberLabel()} +
+ ), + }, + { + value: 'boolean', + inputDisplay: ( +
+ {' '} + {strings.getTypeBooleanLabel()} +
+ ), + }, + ]; + + return ( + <> +
+ +
+
+ {!isNew && ( +
+ + +
+ )} + + + + { + // Only have these types possible in the dropdown + setType(v as CanvasVariable['type']); + + // Reset default value + if (v === 'boolean') { + // Just setting a default value + setValue(true); + } else if (v === 'number') { + // Setting default number + setValue(0); + } else { + setValue(''); + } + }} + compressed={true} + /> + + + setName(e.target.value)} + isInvalid={hasDupeName} + /> + + + setValue(v)} /> + + + + + + + + onSave({ + name, + value, + type, + }) + } + disabled={hasDupeName || !name} + iconType="save" + > + {strings.getSaveButtonLabel()} + + + + onCancel()}> + {strings.getCancelButtonLabel()} + + + + +
+ + ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/index.tsx b/x-pack/plugins/canvas/public/components/var_config/index.tsx new file mode 100644 index 0000000000000..526037b79e0e0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/index.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import copy from 'copy-to-clipboard'; +import { VarConfig as ChildComponent } from './var_config'; +import { + withKibana, + KibanaReactContextValue, + KibanaServices, +} from '../../../../../../src/plugins/kibana_react/public'; +import { CanvasServices } from '../../services'; + +import { ComponentStrings } from '../../../i18n'; + +import { CanvasVariable } from '../../../types'; + +const { VarConfig: strings } = ComponentStrings; + +interface Props { + kibana: KibanaReactContextValue<{ canvas: CanvasServices } & KibanaServices>; + + variables: CanvasVariable[]; + setVariables: (variables: CanvasVariable[]) => void; +} + +const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { + const onDeleteVar = (v: CanvasVariable) => { + const index = variables.findIndex((targetVar: CanvasVariable) => { + return targetVar.name === v.name; + }); + if (index !== -1) { + const newVars = [...variables]; + newVars.splice(index, 1); + setVariables(newVars); + + kibana.services.canvas.notify.success(strings.getDeleteNotificationDescription()); + } + }; + + const onCopyVar = (v: CanvasVariable) => { + const snippetStr = `{var "${v.name}"}`; + copy(snippetStr, { debug: true }); + kibana.services.canvas.notify.success(strings.getCopyNotificationDescription()); + }; + + const onAddVar = (v: CanvasVariable) => { + setVariables([...variables, v]); + }; + + const onEditVar = (oldVar: CanvasVariable, newVar: CanvasVariable) => { + const existingVarIndex = variables.findIndex((v) => oldVar.name === v.name); + + const newVars = [...variables]; + newVars[existingVarIndex] = newVar; + + setVariables(newVars); + }; + + return ; +}; + +export const VarConfig = withKibana(WrappedComponent); diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.scss b/x-pack/plugins/canvas/public/components/var_config/var_config.scss new file mode 100644 index 0000000000000..19fe64e7422fd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_config.scss @@ -0,0 +1,66 @@ +.canvasVarConfig__container { + width: 100%; + position: relative; + + &.canvasVarConfig-isEditMode { + .canvasVarConfig__innerContainer { + transform: translateX(-50%); + } + } +} + +.canvasVarConfig__list { + table { + background-color: transparent; + } + + thead tr th, + thead tr td { + border-bottom: none; + border-top: none; + } + + tbody tr td { + border-top: none; + border-bottom: none; + } + + tbody tr:hover { + background-color: transparent; + } + + tbody tr:last-child td { + border-bottom: none; + } +} + +.canvasVarConfig__innerContainer { + width: calc(200% + 48px); // Account for the extra padding + + position: relative; + + display: flex; + flex-direction: row; + align-content: stretch; + + .canvasVarConfig__editView { + margin-left: 0; + } + + .canvasVarConfig__listView { + margin-right: 0; + } +} + +.canvasVarConfig__editView { + width: 50%; + height: 100%; + + flex-shrink: 0; +} + +.canvasVarConfig__listView { + width: 50%; + + flex-shrink: 0; +} diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.tsx b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx new file mode 100644 index 0000000000000..6120130c77e24 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx @@ -0,0 +1,230 @@ +/* + * 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, FC } from 'react'; +import { + EuiAccordion, + EuiButtonIcon, + EuiToken, + EuiToolTip, + EuiText, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiTableActionsColumnType, + EuiSpacer, + EuiButton, +} from '@elastic/eui'; + +import { CanvasVariable } from '../../../types'; +import { ComponentStrings } from '../../../i18n'; + +import { EditVar } from './edit_var'; +import { DeleteVar } from './delete_var'; + +import './var_config.scss'; + +const { VarConfig: strings } = ComponentStrings; + +enum PanelMode { + List, + Edit, + Delete, +} + +const typeToToken = { + number: 'tokenNumber', + boolean: 'tokenBoolean', + string: 'tokenString', +}; + +interface Props { + variables: CanvasVariable[]; + onCopyVar: (v: CanvasVariable) => void; + onDeleteVar: (v: CanvasVariable) => void; + onAddVar: (v: CanvasVariable) => void; + onEditVar: (oldVar: CanvasVariable, newVar: CanvasVariable) => void; +} + +export const VarConfig: FC = ({ + variables, + onCopyVar, + onDeleteVar, + onAddVar, + onEditVar, +}) => { + const [panelMode, setPanelMode] = useState(PanelMode.List); + const [selectedVar, setSelectedVar] = useState(null); + + const selectAndEditVar = (v: CanvasVariable) => { + setSelectedVar(v); + setPanelMode(PanelMode.Edit); + }; + + const selectAndDeleteVar = (v: CanvasVariable) => { + setSelectedVar(v); + setPanelMode(PanelMode.Delete); + }; + + const actions: EuiTableActionsColumnType['actions'] = [ + { + type: 'icon', + name: strings.getCopyActionButtonLabel(), + description: strings.getCopyActionTooltipLabel(), + icon: 'copyClipboard', + onClick: onCopyVar, + isPrimary: true, + }, + { + type: 'icon', + name: strings.getEditActionButtonLabel(), + description: '', + icon: 'pencil', + onClick: selectAndEditVar, + }, + { + type: 'icon', + name: strings.getDeleteActionButtonLabel(), + description: '', + icon: 'trash', + color: 'danger', + onClick: selectAndDeleteVar, + }, + ]; + + const varColumns: Array> = [ + { + field: 'type', + name: strings.getTableTypeLabel(), + sortable: true, + render: (varType: CanvasVariable['type'], _v: CanvasVariable) => { + return ; + }, + width: '50px', + }, + { + field: 'name', + name: strings.getTableNameLabel(), + sortable: true, + }, + { + field: 'value', + name: strings.getTableValueLabel(), + sortable: true, + truncateText: true, + render: (value: CanvasVariable['value'], _v: CanvasVariable) => { + return '' + value; + }, + }, + { + actions, + width: '60px', + }, + ]; + + return ( +
+
+ + {strings.getTitle()} + + } + extraAction={ + + { + setSelectedVar(null); + setPanelMode(PanelMode.Edit); + }} + /> + + } + > + {variables.length !== 0 && ( +
+ +
+ )} + {variables.length === 0 && ( +
+ + {strings.getEmptyDescription()} + + + setPanelMode(PanelMode.Edit)} + > + {strings.getAddButtonLabel()} + +
+ )} +
+
+ {panelMode === PanelMode.Edit && ( + { + if (!selectedVar) { + onAddVar(newVar); + } else { + onEditVar(selectedVar, newVar); + } + + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + onCancel={() => { + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + /> + )} + + {panelMode === PanelMode.Delete && selectedVar && ( + { + onDeleteVar(v); + + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + onCancel={() => { + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + /> + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/var_panel.scss b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss new file mode 100644 index 0000000000000..84f92aab28146 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss @@ -0,0 +1,31 @@ +.canvasVarHeader__triggerWrapper { + display: flex; + align-items: center; +} + +.canvasVarHeader__button { + @include euiFontSize; + text-align: left; + + width: 100%; + flex-grow: 1; + + display: flex; + align-items: center; +} + +.canvasVarHeader__iconWrapper { + width: $euiSize; + height: $euiSize; + + border-radius: $euiBorderRadius; + + margin-right: $euiSizeS; + margin-left: $euiSizeXS; + + flex-shrink: 0; +} + +.canvasVarHeader__anchor { + display: inline-block; +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx b/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx new file mode 100644 index 0000000000000..c86be4efec043 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFieldText, EuiFieldNumber, EuiButtonGroup } from '@elastic/eui'; +import { htmlIdGenerator } from '@elastic/eui'; + +import { CanvasVariable } from '../../../types'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigVarValueField: strings } = ComponentStrings; + +interface Props { + type: CanvasVariable['type']; + value: CanvasVariable['value']; + onChange: (v: CanvasVariable['value']) => void; +} + +export const VarValueField: FC = ({ type, value, onChange }) => { + const idPrefix = htmlIdGenerator()(); + + const options = [ + { + id: `${idPrefix}-true`, + label: strings.getTrueOption(), + }, + { + id: `${idPrefix}-false`, + label: strings.getFalseOption(), + }, + ]; + + if (type === 'number') { + return ( + onChange(e.target.value)} + /> + ); + } else if (type === 'boolean') { + return ( + { + const val = id.replace(`${idPrefix}-`, '') === 'true'; + onChange(val); + }} + buttonSize="compressed" + isFullWidth + /> + ); + } + + return ( + onChange(e.target.value)} + /> + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.ts b/x-pack/plugins/canvas/public/components/workpad_config/index.ts index c69a1fd9b8137..bba08d7647e9e 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_config/index.ts @@ -7,11 +7,17 @@ import { connect } from 'react-redux'; import { get } from 'lodash'; -import { sizeWorkpad as setSize, setName, setWorkpadCSS } from '../../state/actions/workpad'; +import { + sizeWorkpad as setSize, + setName, + setWorkpadCSS, + updateWorkpadVariables, +} from '../../state/actions/workpad'; + import { getWorkpad } from '../../state/selectors/workpad'; import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; import { WorkpadConfig as Component } from './workpad_config'; -import { State } from '../../../types'; +import { State, CanvasVariable } from '../../../types'; const mapStateToProps = (state: State) => { const workpad = getWorkpad(state); @@ -23,6 +29,7 @@ const mapStateToProps = (state: State) => { height: get(workpad, 'height'), }, css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), + variables: get(workpad, 'variables', []), }; }; @@ -30,6 +37,7 @@ const mapDispatchToProps = { setSize, setName, setWorkpadCSS, + setWorkpadVariables: (vars: CanvasVariable[]) => updateWorkpadVariables(vars), }; export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx index 7b7a1e08b2c5d..a7424882f1072 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx @@ -19,10 +19,13 @@ import { EuiToolTip, EuiTextArea, EuiAccordion, - EuiText, EuiButton, } from '@elastic/eui'; + +import { VarConfig } from '../var_config'; + import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { CanvasVariable } from '../../../types'; import { ComponentStrings } from '../../../i18n'; const { WorkpadConfig: strings } = ComponentStrings; @@ -34,14 +37,16 @@ interface Props { }; name: string; css?: string; + variables: CanvasVariable[]; setSize: ({ height, width }: { height: number; width: number }) => void; setName: (name: string) => void; setWorkpadCSS: (css: string) => void; + setWorkpadVariables: (vars: CanvasVariable[]) => void; } export const WorkpadConfig: FunctionComponent = (props) => { const [css, setCSS] = useState(props.css); - const { size, name, setSize, setName, setWorkpadCSS } = props; + const { size, name, setSize, setName, setWorkpadCSS, variables, setWorkpadVariables } = props; const rotate = () => setSize({ width: size.height, height: size.width }); const badges = [ @@ -129,23 +134,25 @@ export const WorkpadConfig: FunctionComponent = (props) => {
-
+ + + +
- - {strings.getGlobalCSSLabel()} - + {strings.getGlobalCSSLabel()} } > -
+
F if (filterList && filterList.length) { const filterExpression = filterList.join(' | '); const filterAST = fromExpression(filterExpression); - return interpretAst(filterAST); + return interpretAst(filterAST, getWorkpadVariablesAsObject(getState())); } else { const filterType = initialize.typesRegistry.get('filter'); return filterType?.from(null, {}); diff --git a/x-pack/plugins/canvas/public/lib/run_interpreter.ts b/x-pack/plugins/canvas/public/lib/run_interpreter.ts index 07c0ca4b1ce15..12e07ed3535f6 100644 --- a/x-pack/plugins/canvas/public/lib/run_interpreter.ts +++ b/x-pack/plugins/canvas/public/lib/run_interpreter.ts @@ -15,8 +15,12 @@ interface Options { /** * Meant to be a replacement for plugins/interpreter/interpretAST */ -export async function interpretAst(ast: ExpressionAstExpression): Promise { - return await expressionsService.getService().execute(ast).getData(); +export async function interpretAst( + ast: ExpressionAstExpression, + variables: Record +): Promise { + const context = { variables }; + return await expressionsService.getService().execute(ast, null, context).getData(); } /** @@ -24,6 +28,7 @@ export async function interpretAst(ast: ExpressionAstExpression): Promise, options: Options = {} ): Promise { + const context = { variables }; + try { - const renderable = await expressionsService.getService().execute(ast, input).getData(); + const renderable = await expressionsService.getService().execute(ast, input, context).getData(); if (getType(renderable) === 'render') { return renderable; } if (options.castToRender) { - return runInterpreter(fromExpression('render'), renderable, { + return runInterpreter(fromExpression('render'), renderable, variables, { castToRender: false, }); } diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js index 1617759e83dd8..2047e20424acc 100644 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ b/x-pack/plugins/canvas/public/lib/workpad_service.js @@ -21,6 +21,7 @@ const validKeys = [ 'assets', 'colors', 'css', + 'variables', 'height', 'id', 'isWriteable', @@ -61,6 +62,7 @@ export function create(workpad) { return fetch.post(getApiPath(), { ...sanitizeWorkpad({ ...workpad }), assets: workpad.assets || {}, + variables: workpad.variables || [], }); } @@ -73,7 +75,7 @@ export async function createFromTemplate(templateId) { export function get(workpadId) { return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, ...workpad }; + return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; }); } diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index e89e62917da39..2ba011373c670 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -9,7 +9,13 @@ import immutable from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; import { createThunk } from '../../lib/create_thunk'; -import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad'; +import { + getPages, + getWorkpadVariablesAsObject, + getNodeById, + getNodes, + getSelectedPageIndex, +} from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; import { ErrorStrings } from '../../../i18n'; @@ -96,13 +102,15 @@ export const fetchContext = createThunk( return i < index; }); + const variables = getWorkpadVariablesAsObject(getState()); + // get context data from a partial AST return interpretAst( { ...element.ast, chain: astChain, }, - prevContextValue + variables ).then((value) => { dispatch( args.setValue({ @@ -114,7 +122,7 @@ export const fetchContext = createThunk( } ); -const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { +const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, context) => { const argumentPath = [element.id, 'expressionRenderable']; dispatch( args.setLoading({ @@ -128,7 +136,9 @@ const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { value: renderable, }); - return runInterpreter(ast, context, { castToRender: true }) + const variables = getWorkpadVariablesAsObject(getState()); + + return runInterpreter(ast, context, variables, { castToRender: true }) .then((renderable) => { dispatch(getAction(renderable)); }) @@ -172,7 +182,9 @@ export const fetchAllRenderables = createThunk( const ast = element.ast || safeElementFromExpression(element.expression); const argumentPath = [element.id, 'expressionRenderable']; - return runInterpreter(ast, null, { castToRender: true }) + const variables = getWorkpadVariablesAsObject(getState()); + + return runInterpreter(ast, null, variables, { castToRender: true }) .then((renderable) => ({ path: argumentPath, value: renderable })) .catch((err) => { services.notify.getService().error(err); diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.ts b/x-pack/plugins/canvas/public/state/actions/workpad.ts index 419832e404594..7af55730f5787 100644 --- a/x-pack/plugins/canvas/public/state/actions/workpad.ts +++ b/x-pack/plugins/canvas/public/state/actions/workpad.ts @@ -10,7 +10,7 @@ import { createThunk } from '../../lib/create_thunk'; import { getWorkpadColors } from '../selectors/workpad'; // @ts-expect-error import { fetchAllRenderables } from './elements'; -import { CanvasWorkpad } from '../../../types'; +import { CanvasWorkpad, CanvasVariable } from '../../../types'; export const sizeWorkpad = createAction<{ height: number; width: number }>('sizeWorkpad'); export const setName = createAction('setName'); @@ -18,6 +18,7 @@ export const setWriteable = createAction('setWriteable'); export const setColors = createAction('setColors'); export const setRefreshInterval = createAction('setRefreshInterval'); export const setWorkpadCSS = createAction('setWorkpadCSS'); +export const setWorkpadVariables = createAction('setWorkpadVariables'); export const enableAutoplay = createAction('enableAutoplay'); export const setAutoplayInterval = createAction('setAutoplayInterval'); export const resetWorkpad = createAction('resetWorkpad'); @@ -38,6 +39,14 @@ export const removeColor = createThunk('removeColor', ({ dispatch, getState }, c dispatch(setColors(without(getWorkpadColors(getState()), color))); }); +export const updateWorkpadVariables = createThunk( + 'updateWorkpadVariables', + ({ dispatch }, vars) => { + dispatch(setWorkpadVariables(vars)); + dispatch(fetchAllRenderables()); + } +); + export const setWorkpad = createThunk( 'setWorkpad', ( diff --git a/x-pack/plugins/canvas/public/state/defaults.js b/x-pack/plugins/canvas/public/state/defaults.js index 13ff7102bcafe..5cffb5e865d64 100644 --- a/x-pack/plugins/canvas/public/state/defaults.js +++ b/x-pack/plugins/canvas/public/state/defaults.js @@ -81,6 +81,7 @@ export const getDefaultWorkpad = () => { '#FFFFFF', 'rgba(255,255,255,0)', // 'transparent' ], + variables: [], isWriteable: true, }; }; diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js index 30f9c638a054f..9a0c30bdf1337 100644 --- a/x-pack/plugins/canvas/public/state/reducers/workpad.js +++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js @@ -14,6 +14,7 @@ import { setName, setWriteable, setWorkpadCSS, + setWorkpadVariables, resetWorkpad, } from '../actions/workpad'; @@ -59,6 +60,10 @@ export const workpadReducer = handleActions( return { ...workpadState, css: payload }; }, + [setWorkpadVariables]: (workpadState, { payload }) => { + return { ...workpadState, variables: payload }; + }, + [resetWorkpad]: () => ({ ...getDefaultWorkpad() }), }, {} diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 83f4984b4a300..1d7ea05daaa61 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -10,7 +10,14 @@ import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/comm // @ts-expect-error untyped local import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; -import { State, CanvasWorkpad, CanvasPage, CanvasElement, ResolvedArgType } from '../../../types'; +import { + State, + CanvasWorkpad, + CanvasPage, + CanvasElement, + CanvasVariable, + ResolvedArgType, +} from '../../../types'; import { ExpressionContext, CanvasGroup, @@ -49,6 +56,23 @@ export function getWorkpadPersisted(state: State) { return getWorkpad(state); } +export function getWorkpadVariables(state: State) { + const workpad = getWorkpad(state); + return get(workpad, 'variables', []); +} + +export function getWorkpadVariablesAsObject(state: State) { + const variables = getWorkpadVariables(state); + if (variables.length === 0) { + return {}; + } + + return (variables as CanvasVariable[]).reduce( + (vars: Record, v: CanvasVariable) => ({ ...vars, [v.name]: v.value }), + {} + ); +} + export function getWorkpadInfo(state: State): WorkpadInfo { return { ...getWorkpad(state), @@ -326,7 +350,9 @@ export function getElements( return elements.map((el) => omit(el, ['ast'])); } - return elements.map(appendAst); + const elementAppendAst = (elem: CanvasElement) => appendAst(elem); + + return elements.map(elementAppendAst); } const augment = (type: string) => (n: T): T => ({ diff --git a/x-pack/plugins/canvas/server/lib/sanitize_name.js b/x-pack/plugins/canvas/server/lib/sanitize_name.js index 295315c3ceb2e..4c787c816a331 100644 --- a/x-pack/plugins/canvas/server/lib/sanitize_name.js +++ b/x-pack/plugins/canvas/server/lib/sanitize_name.js @@ -5,9 +5,9 @@ */ export function sanitizeName(name) { - // blacklisted characters - const blacklist = ['(', ')']; - const pattern = blacklist.map((v) => escapeRegExp(v)).join('|'); + // invalid characters + const invalid = ['(', ')']; + const pattern = invalid.map((v) => escapeRegExp(v)).join('|'); const regex = new RegExp(pattern, 'g'); return name.replace(regex, '_'); } diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts index 0c31f517a74b3..5bbd2caa0cb99 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts @@ -51,12 +51,19 @@ export const WorkpadAssetSchema = schema.object({ value: schema.string(), }); +export const WorkpadVariable = schema.object({ + name: schema.string(), + value: schema.oneOf([schema.string(), schema.number(), schema.boolean()]), + type: schema.string(), +}); + export const WorkpadSchema = schema.object({ '@created': schema.maybe(schema.string()), '@timestamp': schema.maybe(schema.string()), assets: schema.maybe(schema.recordOf(schema.string(), WorkpadAssetSchema)), colors: schema.arrayOf(schema.string()), css: schema.string(), + variables: schema.arrayOf(WorkpadVariable), height: schema.number(), id: schema.string(), isWriteable: schema.maybe(schema.boolean()), diff --git a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts index 95f0dc4c3da39..416d3aee2dd03 100644 --- a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts +++ b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts @@ -1644,5 +1644,6 @@ export const pitch: CanvasTemplate = { }, css: ".canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5 {\nfont-family: 'Futura';\ncolor: #444444;\n}\n\n.canvasPage h1 {\nfont-size: 112px;\nfont-weight: bold;\ncolor: #FFFFFF;\n}\n\n.canvasPage h2 {\nfont-size: 48px;\nfont-weight: bold;\n}\n\n.canvasPage h3 {\nfont-size: 30px;\nfont-weight: 300;\ntext-transform: uppercase;\ncolor: #FFFFFF;\n}\n\n.canvasPage h5 {\nfont-size: 24px;\nfont-style: italic;\n}", + variables: [], }, }; diff --git a/x-pack/plugins/canvas/server/templates/status_report.ts b/x-pack/plugins/canvas/server/templates/status_report.ts index b396ed784cbed..447e1f99afaee 100644 --- a/x-pack/plugins/canvas/server/templates/status_report.ts +++ b/x-pack/plugins/canvas/server/templates/status_report.ts @@ -17,6 +17,7 @@ export const status: CanvasTemplate = { height: 792, css: '.canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5, .canvasPage h6, .canvasPage li, .canvasPage p, .canvasPage th, .canvasPage td {\nfont-family: "Gill Sans" !important;\ncolor: #333333;\n}\n\n.canvasPage h1, .canvasPage h2 {\nfont-weight: 400;\n}\n\n.canvasPage h2 {\ntext-transform: uppercase;\ncolor: #1785B0;\n}\n\n.canvasMarkdown p,\n.canvasMarkdown li {\nfont-size: 18px;\n}\n\n.canvasMarkdown li {\nmargin-bottom: .75em;\n}\n\n.canvasMarkdown h3:not(:first-child) {\nmargin-top: 2em;\n}\n\n.canvasMarkdown a {\ncolor: #1785B0;\n}\n\n.canvasMarkdown th,\n.canvasMarkdown td {\npadding: .5em 1em;\n}\n\n.canvasMarkdown th {\nbackground-color: #FAFBFD;\n}\n\n.canvasMarkdown table,\n.canvasMarkdown th,\n.canvasMarkdown td {\nborder: 1px solid #e4e9f2;\n}', + variables: [], page: 0, pages: [ { diff --git a/x-pack/plugins/canvas/server/templates/summary_report.ts b/x-pack/plugins/canvas/server/templates/summary_report.ts index 1b32a80fa82c7..64f04eef4194e 100644 --- a/x-pack/plugins/canvas/server/templates/summary_report.ts +++ b/x-pack/plugins/canvas/server/templates/summary_report.ts @@ -493,5 +493,6 @@ export const summary: CanvasTemplate = { '@created': '2019-05-31T16:01:45.751Z', assets: {}, css: 'h3 {\ncolor: #343741;\nfont-weight: 400;\n}\n\nh5 {\ncolor: #69707D;\n}', + variables: [], }, }; diff --git a/x-pack/plugins/canvas/server/templates/theme_dark.ts b/x-pack/plugins/canvas/server/templates/theme_dark.ts index 8dce2c5eb9b6e..5822a17976cd3 100644 --- a/x-pack/plugins/canvas/server/templates/theme_dark.ts +++ b/x-pack/plugins/canvas/server/templates/theme_dark.ts @@ -17,6 +17,7 @@ export const dark: CanvasTemplate = { height: 720, page: 0, css: '', + variables: [], pages: [ { id: 'page-fda26a1f-c096-44e4-a149-cb99e1038a34', diff --git a/x-pack/plugins/canvas/server/templates/theme_light.ts b/x-pack/plugins/canvas/server/templates/theme_light.ts index fb654a2fd2954..d278e057bb441 100644 --- a/x-pack/plugins/canvas/server/templates/theme_light.ts +++ b/x-pack/plugins/canvas/server/templates/theme_light.ts @@ -14,6 +14,7 @@ export const light: CanvasTemplate = { template: { name: 'Light', css: '', + variables: [], width: 1080, height: 720, page: 0, diff --git a/x-pack/plugins/canvas/types/canvas.ts b/x-pack/plugins/canvas/types/canvas.ts index 2f20dc88fdec4..cc07f498f1eec 100644 --- a/x-pack/plugins/canvas/types/canvas.ts +++ b/x-pack/plugins/canvas/types/canvas.ts @@ -37,12 +37,19 @@ export interface CanvasPage { groups: CanvasGroup[]; } +export interface CanvasVariable { + name: string; + value: boolean | number | string; + type: 'boolean' | 'number' | 'string'; +} + export interface CanvasWorkpad { '@created': string; '@timestamp': string; assets: { [id: string]: CanvasAsset }; colors: string[]; css: string; + variables: CanvasVariable[]; height: number; id: string; isWriteable: boolean; diff --git a/x-pack/plugins/cross_cluster_replication/public/plugin.ts b/x-pack/plugins/cross_cluster_replication/public/plugin.ts index 8bf0d519e685d..7aa0d19fa976f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/public/plugin.ts @@ -9,7 +9,6 @@ import { get } from 'lodash'; import { first } from 'rxjs/operators'; import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { PLUGIN, MANAGEMENT_ID } from '../common/constants'; import { init as initUiMetric } from './app/services/track_ui_metric'; import { init as initNotification } from './app/services/notifications'; @@ -23,7 +22,7 @@ export class CrossClusterReplicationPlugin implements Plugin { public setup(coreSetup: CoreSetup, plugins: PluginDependencies) { const { licensing, remoteClusters, usageCollection, management, indexManagement } = plugins; - const esSection = management.sections.getSection(ManagementSectionId.Data); + const esSection = management.sections.section.data; const { http, diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 7c1001697421f..7b29117495a67 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -8,12 +8,13 @@ import { first } from 'rxjs/operators'; import { mapKeys, snakeCase } from 'lodash'; import { SearchResponse } from 'elasticsearch'; import { Observable } from 'rxjs'; -import { LegacyAPICaller, SharedGlobalConfig } from '../../../../../src/core/server'; -import { ES_SEARCH_STRATEGY } from '../../../../../src/plugins/data/common'; import { - ISearch, + LegacyAPICaller, + SharedGlobalConfig, + RequestHandlerContext, +} from '../../../../../src/core/server'; +import { ISearchOptions, - ISearchCancel, getDefaultSearchParams, getTotalLoaded, ISearchStrategy, @@ -30,11 +31,11 @@ export interface AsyncSearchResponse { export const enhancedEsSearchStrategyProvider = ( config$: Observable -): ISearchStrategy => { - const search: ISearch = async ( - context, +): ISearchStrategy => { + const search = async ( + context: RequestHandlerContext, request: IEnhancedEsSearchRequest, - options + options?: ISearchOptions ) => { const config = await config$.pipe(first()).toPromise(); const caller = context.core.elasticsearch.legacy.client.callAsCurrentUser; @@ -46,7 +47,7 @@ export const enhancedEsSearchStrategyProvider = ( : asyncSearch(caller, { ...request, params }, options); }; - const cancel: ISearchCancel = async (context, id) => { + const cancel = async (context: RequestHandlerContext, id: string) => { const method = 'DELETE'; const path = encodeURI(`/_async_search/${id}`); await context.core.elasticsearch.legacy.client.callAsCurrentUser('transport.request', { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index eea19bb1aa7dd..5d4ea5a6370e4 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -939,6 +939,7 @@ describe('#bulkGet', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, { @@ -950,6 +951,7 @@ describe('#bulkGet', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, ], @@ -1015,6 +1017,7 @@ describe('#bulkGet', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, { @@ -1026,6 +1029,7 @@ describe('#bulkGet', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, ], diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index bdc2b6cb2e667..3246457179f68 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -25,6 +25,7 @@ import { } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; interface EncryptedSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; @@ -47,10 +48,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} - // only include namespace in AAD descriptor if the specified type is single-namespace - private getDescriptorNamespace = (type: string, namespace?: string) => - this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; - public async create( type: string, attributes: T = {} as T, @@ -70,7 +67,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); - const namespace = this.getDescriptorNamespace(type, options.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options.namespace + ); return await this.handleEncryptedAttributesInResponse( await this.options.baseClient.create( type, @@ -109,7 +110,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); - const namespace = this.getDescriptorNamespace(object.type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + object.type, + options?.namespace + ); return { ...object, id, @@ -124,8 +129,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkCreate(encryptedObjects, options), - objects, - options?.namespace + objects ); } @@ -142,7 +146,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return object; } - const namespace = this.getDescriptorNamespace(type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options?.namespace + ); return { ...object, attributes: await this.options.service.encryptAttributes( @@ -156,8 +164,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkUpdate(encryptedObjects, options), - objects, - options?.namespace + objects ); } @@ -168,8 +175,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public async find(options: SavedObjectsFindOptions) { return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.find(options), - undefined, - options.namespace + undefined ); } @@ -179,8 +185,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ) { return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkGet(objects, options), - undefined, - options?.namespace + undefined ); } @@ -188,7 +193,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInResponse( await this.options.baseClient.get(type, id, options), undefined as unknown, - this.getDescriptorNamespace(type, options?.namespace) + getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) ); } @@ -201,7 +206,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return await this.options.baseClient.update(type, id, attributes, options); } - const namespace = this.getDescriptorNamespace(type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options?.namespace + ); return this.handleEncryptedAttributesInResponse( await this.options.baseClient.update( type, @@ -270,7 +279,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon * response portion isn't registered, it is returned as is. * @param response Raw response returned by the underlying base client. * @param [objects] Optional list of saved objects with original attributes. - * @param [namespace] Optional namespace that was used for the saved objects operation. */ private async handleEncryptedAttributesInBulkResponse< T, @@ -279,12 +287,16 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse, O extends Array> | Array> - >(response: R, objects?: O, namespace?: string) { + >(response: R, objects?: O) { for (const [index, savedObject] of response.saved_objects.entries()) { await this.handleEncryptedAttributesInResponse( savedObject, objects?.[index].attributes ?? undefined, - this.getDescriptorNamespace(savedObject.type, namespace) + getDescriptorNamespace( + this.options.baseTypeRegistry, + savedObject.type, + savedObject.namespaces ? savedObject.namespaces[0] : undefined + ) ); } diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts new file mode 100644 index 0000000000000..7ba90a5a76ab3 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; + +describe('getDescriptorNamespace', () => { + describe('namespace agnostic', () => { + it('returns undefined', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(true); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', undefined)).toEqual( + undefined + ); + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', 'foo-namespace')).toEqual( + undefined + ); + }); + }); + + describe('multi-namespace', () => { + it('returns undefined', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', undefined)).toEqual( + undefined + ); + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', 'foo-namespace')).toEqual( + undefined + ); + }); + }); + + describe('single namespace', () => { + it('returns `undefined` if provided namespace is undefined or `default`', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', undefined)).toEqual( + undefined + ); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'default')).toEqual( + undefined + ); + }); + + it('returns the provided namespace', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'foo-namespace')).toEqual( + 'foo-namespace' + ); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts new file mode 100644 index 0000000000000..b2842df909a1d --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectTypeRegistry } from 'kibana/server'; + +export const getDescriptorNamespace = ( + typeRegistry: ISavedObjectTypeRegistry, + type: string, + namespace?: string +) => { + const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined; + return descriptorNamespace === 'default' ? undefined : descriptorNamespace; +}; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index af00050183b77..0e5be4e4eee5a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -15,6 +15,7 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; interface SetupSavedObjectsParams { service: PublicMethodsOf; @@ -84,7 +85,7 @@ export function setupSavedObjects({ { type, id, - namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined, + namespace: getDescriptorNamespace(typeRegistry, type, options?.namespace), }, savedObject.attributes as Record )) as T, diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 8c316c848184b..31ee304fe2247 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -2,7 +2,10 @@ ## Overview -This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness: + +- **App Search:** A basic engines overview with links into the product. +- **Workplace Search:** A simple app overview with basic statistics, links to the sources, users (if standard auth), and product settings. ## Development diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index c134131caba75..fc9a47717871b 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error + export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index 14fde357a980a..6f82946c0ea14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -7,7 +7,11 @@ export { mockHistory } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; export { mockLicenseContext } from './license_context.mock'; -export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock'; +export { + mountWithContext, + mountWithKibanaContext, + mountWithAsyncContext, +} from './mount_with_context.mock'; export { shallowWithIntl } from './shallow_with_i18n.mock'; // Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index dfcda544459d4..1e0df1326c177 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -5,7 +5,8 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../'; @@ -47,3 +48,33 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje ); }; + +/** + * This helper is intended for components that have async effects + * (e.g. http fetches) on mount. It mostly adds act/update boilerplate + * that's needed for the wrapper to play nice with Enzyme/Jest + * + * Example usage: + * + * const wrapper = mountWithAsyncContext(, { http: { get: () => someData } }); + */ +export const mountWithAsyncContext = async ( + children: React.ReactNode, + context: object +): Promise => { + let wrapper: ReactWrapper | undefined; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithContext(children, context); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index 767a52a75d1fb..2bcdd42c38055 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -19,7 +19,7 @@ jest.mock('react', () => ({ /** * Example usage within a component test using shallow(): * - * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * import '../../../__mocks__/shallow_usecontext'; // Must come before React's import, adjust relative path as needed * * import React from 'react'; * import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx index 12bf003564103..25a9fa7430c40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -9,6 +9,7 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; +import { ErrorStatePrompt } from '../../../shared/error_state'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), @@ -22,7 +23,7 @@ describe('ErrorState', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index d8eeff2aba1c6..7ac02082ee75c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -4,21 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; -import { EuiButton } from '../../../shared/react_router_helpers'; +import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; export const ErrorState: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - return ( @@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => { - - - - - } - titleSize="l" - body={ - <> -

- {enterpriseSearchUrl}, - }} - /> -

-
    -
  1. - config/kibana.yml, - }} - /> -
  2. -
  3. - -
  4. -
  5. - [enterpriseSearch][plugins], - }} - /> -
  6. -
- - } - actions={ - - - - } - /> + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 4d2a2ea1df9aa..45ab5dc5b9ab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -8,51 +8,45 @@ import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { render, ReactWrapper } from 'enzyme'; +import { shallow, ReactWrapper } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { KibanaContext } from '../../../'; -import { LicenseContext } from '../../../shared/licensing'; -import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; -import { EmptyState, ErrorState } from '../empty_states'; -import { EngineTable, IEngineTablePagination } from './engine_table'; +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { EngineTable } from './engine_table'; import { EngineOverview } from './'; describe('EngineOverview', () => { + const mockHttp = mockKibanaContext.http; + describe('non-happy-path states', () => { it('isLoading', () => { - // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) - // TODO: Consider pulling this out to a renderWithContext mock/helper - const wrapper: Cheerio = render( - - - - - - - - ); - - // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly - expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + const wrapper = shallow(); + + expect(wrapper.find(LoadingState)).toHaveLength(1); }); it('isEmpty', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ - results: [], - meta: { page: { total_results: 0 } }, - }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }, }); expect(wrapper.find(EmptyState)).toHaveLength(1); }); it('hasErrorConnecting', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ invalidPayload: true }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ invalidPayload: true }), + }, }); expect(wrapper.find(ErrorState)).toHaveLength(1); }); @@ -78,17 +72,17 @@ describe('EngineOverview', () => { }, }; const mockApi = jest.fn(() => mockedApiResponse); - let wrapper: ReactWrapper; - beforeAll(async () => { - wrapper = await mountWithApiMock({ get: mockApi }); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders', () => { - expect(wrapper.find(EngineTable)).toHaveLength(1); - }); + it('renders and calls the engines API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); - it('calls the engines API', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { query: { type: 'indexed', @@ -97,19 +91,42 @@ describe('EngineOverview', () => { }); }); + describe('when on a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + license: { type: 'platinum', isActive: true }, + }); + + expect(wrapper.find(EngineTable)).toHaveLength(2); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + describe('pagination', () => { - const getTablePagination: () => IEngineTablePagination = () => - wrapper.find(EngineTable).first().prop('pagination'); + const getTablePagination = (wrapper: ReactWrapper) => + wrapper.find(EngineTable).prop('pagination'); - it('passes down page data from the API', () => { - const pagination = getTablePagination(); + it('passes down page data from the API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + const pagination = getTablePagination(wrapper); expect(pagination.totalEngines).toEqual(100); expect(pagination.pageIndex).toEqual(0); }); it('re-polls the API on page change', async () => { - await act(async () => getTablePagination().onPaginate(5)); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + await act(async () => getTablePagination(wrapper).onPaginate(5)); wrapper.update(); expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { @@ -118,54 +135,8 @@ describe('EngineOverview', () => { pageIndex: 5, }, }); - expect(getTablePagination().pageIndex).toEqual(4); - }); - }); - - describe('when on a platinum license', () => { - beforeAll(async () => { - mockApi.mockClear(); - wrapper = await mountWithApiMock({ - license: { type: 'platinum', isActive: true }, - get: mockApi, - }); - }); - - it('renders a 2nd meta engines table', () => { - expect(wrapper.find(EngineTable)).toHaveLength(2); - }); - - it('makes a 2nd call to the engines API with type meta', () => { - expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { - query: { - type: 'meta', - pageIndex: 1, - }, - }); + expect(getTablePagination(wrapper).pageIndex).toEqual(4); }); }); }); - - /** - * Test helpers - */ - - const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { - let wrapper: ReactWrapper | undefined; - const httpMock = { ...mockKibanaContext.http, get }; - - // We get a lot of act() warning/errors in the terminal without this. - // TBH, I don't fully understand why since Enzyme's mount is supposed to - // have act() baked in - could be because of the wrapping context provider? - await act(async () => { - wrapper = mountWithContext(, { http: httpMock, license }); - }); - if (wrapper) { - wrapper.update(); // This seems to be required for the DOM to actually update - - return wrapper; - } else { - throw new Error('Could not mount wrapper'); - } - }; }); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 1aead8468ca3b..70e16e61846b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -6,14 +6,16 @@ import React from 'react'; +import { AppMountParameters } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks'; import { renderApp } from './'; import { AppSearch } from './app_search'; +import { WorkplaceSearch } from './workplace_search'; describe('renderApp', () => { - const params = coreMock.createAppMountParamters(); + let params: AppMountParameters; const core = coreMock.createStart(); const config = {}; const plugins = { @@ -22,6 +24,7 @@ describe('renderApp', () => { beforeEach(() => { jest.clearAllMocks(); + params = coreMock.createAppMountParamters(); }); it('mounts and unmounts UI', () => { @@ -37,4 +40,9 @@ describe('renderApp', () => { renderApp(AppSearch, core, params, config, plugins); expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); + + it('renders WorkplaceSearch', () => { + renderApp(WorkplaceSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx new file mode 100644 index 0000000000000..29b773b80158a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { ErrorStatePrompt } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx new file mode 100644 index 0000000000000..81455cea0b497 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx @@ -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 React, { useContext } from 'react'; +import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton } from '../react_router_helpers'; +import { KibanaContext, IKibanaContext } from '../../index'; + +export const ErrorStatePrompt: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts similarity index 71% rename from x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts index 0499fdd1ac8db..1012fdf4126a2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EntriesArray, exceptionListType, namespaceType } from '../../../lists/common/schemas'; +export { ErrorStatePrompt } from './error_state_prompt'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts index 7ea73577c4de6..70aa723d62601 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -5,7 +5,7 @@ */ import { generateBreadcrumb } from './generate_breadcrumbs'; -import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs, workplaceSearchBreadcrumbs } from './'; import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; const mockHistory = mockHistoryUntyped as any; @@ -204,3 +204,86 @@ describe('appSearchBreadcrumbs', () => { }); }); }); + +describe('workplaceSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}` + ); + }); + + const subject = () => workplaceSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and Workplace Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + { + href: '/enterprise_search/workplace_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/workplace_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(workplaceSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to Workplace Search second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[3] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts index 8f72875a32bd4..b57fdfdbb75ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -52,3 +52,6 @@ export const enterpriseSearchBreadcrumbs = (history: History) => ( export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); + +export const workplaceSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([{ text: 'Workplace Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts index cf8bbbc593f2f..c4ef68704b7e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; -export { appSearchBreadcrumbs } from './generate_breadcrumbs'; -export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; +export { + enterpriseSearchBreadcrumbs, + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, +} from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs, SetWorkplaceSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx index 530117e197616..e54f1a12b73cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -8,7 +8,11 @@ import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { EuiBreadcrumb } from '@elastic/eui'; import { KibanaContext, IKibanaContext } from '../../index'; -import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; +import { + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, + TBreadcrumbs, +} from './generate_breadcrumbs'; /** * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view @@ -17,19 +21,17 @@ import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; -interface IBreadcrumbProps { +interface IBreadcrumbsProps { text: string; isRoot?: never; } -interface IRootBreadcrumbProps { +interface IRootBreadcrumbsProps { isRoot: true; text?: never; } +type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps; -export const SetAppSearchBreadcrumbs: React.FC = ({ - text, - isRoot, -}) => { +export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { const history = useHistory(); const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; @@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { + const history = useHistory(); + const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(workplaceSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts index f871f48b17154..eadf7fa805590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -6,3 +6,4 @@ export { sendTelemetry } from './send_telemetry'; export { SendAppSearchTelemetry } from './send_telemetry'; +export { SendWorkplaceSearchTelemetry } from './send_telemetry'; 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 9825c0d8ab889..3c873dbc25e37 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 @@ -7,8 +7,10 @@ import React from 'react'; import { httpServiceMock } from 'src/core/public/mocks'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { mountWithKibanaContext } from '../../__mocks__'; -import { sendTelemetry, SendAppSearchTelemetry } from './'; + +import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { const httpMock = httpServiceMock.createSetupContract(); @@ -27,8 +29,8 @@ describe('Shared Telemetry Helpers', () => { }); expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"viewed","metric":"setup_guide"}', + headers, + body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); }); @@ -47,9 +49,20 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"clicked","metric":"button"}', + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"app_search","action":"clicked","metric":"button"}', + }); + }); + + it('SendWorkplaceSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"workplace_search","action":"viewed","metric":"page"}', }); }); }); 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 300cb18272717..715d61b31512c 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 @@ -7,6 +7,7 @@ import React, { useContext, useEffect } from 'react'; import { HttpSetup } from 'src/core/public'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { KibanaContext, IKibanaContext } from '../../index'; interface ISendTelemetryProps { @@ -25,10 +26,8 @@ interface ISendTelemetry extends ISendTelemetryProps { export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { try { - await http.put(`/api/${product}/telemetry`, { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, metric }), - }); + const body = JSON.stringify({ product, action, metric }); + await http.put('/api/enterprise_search/telemetry', { headers, body }); } catch (error) { throw new Error('Unable to send telemetry'); } @@ -36,7 +35,7 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele /** * React component helpers - useful for on-page-load/views - * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + * TODO: SendEnterpriseSearchTelemetry */ export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { @@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC = ({ action, return null; }; + +export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'workplace_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts new file mode 100644 index 0000000000000..3f28710d92295 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IFlashMessagesProps { + info?: string[]; + warning?: string[]; + error?: string[]; + success?: string[]; + isWrapped?: boolean; + children?: React.ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png new file mode 100644 index 0000000000000..b6267b6e2c48e Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png differ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg new file mode 100644 index 0000000000000..e6b987c398268 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx new file mode 100644 index 0000000000000..ab5cd7f0de90f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { ErrorState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx new file mode 100644 index 0000000000000..9fa508d599425 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.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 { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { ViewContentHeader } from '../shared/view_content_header'; + +export const ErrorState: React.FC = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts similarity index 82% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts rename to x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts index 90f90ba168a2f..b4d58bab58ff1 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createGenerateCsv } from './generate_csv'; +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts new file mode 100644 index 0000000000000..9ee1b444ee817 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/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 { Overview } from './overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx new file mode 100644 index 0000000000000..1d7c565935e97 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { OnboardingCard } from './onboarding_card'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const cardProps = { + title: 'My card', + icon: 'icon', + description: 'this is a card', + actionTitle: 'action', + testSubj: 'actionButton', +}; + +describe('OnboardingCard', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(1); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + + const button = prompt.find('[data-test-subj="actionButton"]'); + expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders an empty button when onboarding is completed', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx new file mode 100644 index 0000000000000..288c0be84fa9a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + IconType, + EuiButtonProps, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +interface IOnboardingCardProps { + title: React.ReactNode; + icon: React.ReactNode; + description: React.ReactNode; + actionTitle: React.ReactNode; + testSubj: string; + actionPath?: string; + complete?: boolean; +} + +export const OnboardingCard: React.FC = ({ + title, + icon, + description, + actionTitle, + testSubj, + actionPath, + complete, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }); + const buttonActionProps = actionPath + ? { + onClick, + href: getWSRoute(actionPath), + target: '_blank', + 'data-test-subj': testSubj, + } + : { + 'data-test-subj': testSubj, + }; + + const emptyButtonProps = { + ...buttonActionProps, + } as EuiButtonEmptyProps & EuiLinkProps; + const fillButtonProps = { + ...buttonActionProps, + color: 'secondary', + fill: true, + } as EuiButtonProps & EuiLinkProps; + + return ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx new file mode 100644 index 0000000000000..6174dc1c795eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; +import { OnboardingCard } from './onboarding_card'; +import { defaultServerData } from './overview'; + +const account = { + id: '1', + isAdmin: true, + canCreatePersonalSources: true, + groups: [], + supportEligible: true, + isCurated: false, +}; + +describe('OnboardingSteps', () => { + describe('Shared Sources', () => { + it('renders 0 sources state', () => { + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(1); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('description')).toBe( + 'Add shared sources for your organization to start searching.' + ); + }); + + it('renders completed sources state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('description')).toEqual( + 'You have added 2 shared sources. Happy searching.' + ); + }); + + it('disables link when the user cannot create sources', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard)).toHaveLength(2); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Invite your colleagues into this organization to search with you.' + ); + }); + + it('renders completed users state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + const wrapper = shallow(); + + const button = wrapper + .find(OrgNameOnboarding) + .dive() + .find('[data-test-subj="orgNameChangeButton"]'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('hides card when name has been changed', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx new file mode 100644 index 0000000000000..1b00347437338 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx @@ -0,0 +1,179 @@ +/* + * 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, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiSpacer, + EuiButtonEmpty, + EuiTitle, + EuiPanel, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import sharedSourcesIcon from '../shared/assets/share_circle.svg'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; + +import { ContentSection } from '../shared/content_section'; + +import { IAppServerData } from './overview'; + +import { OnboardingCard } from './onboarding_card'; + +const SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', + { defaultMessage: 'Shared sources' } +); + +const USERS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', + { defaultMessage: 'Users & invitations' } +); + +const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', + { defaultMessage: 'Add shared sources for your organization to start searching.' } +); + +const USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', + { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } +); + +const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', + { defaultMessage: 'Invite your colleagues into this organization to search with you.' } +); + +export const OnboardingSteps: React.FC = ({ + hasUsers, + hasOrgSources, + canCreateContentSources, + canCreateInvitations, + accountsCount, + sourcesCount, + fpAccount: { isCurated }, + organization: { name, defaultOrgName }, + isFederatedAuth, +}) => { + const accountsPath = + !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined; + + const SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', + { + defaultMessage: + 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', + values: { sourcesCount }, + } + ); + + return ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +export const OrgNameOnboarding: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'org_name_change_button', + }); + + const buttonProps = { + onClick, + target: '_blank', + color: 'primary', + href: getWSRoute(ORG_SETTINGS_PATH), + 'data-test-subj': 'orgNameChangeButton', + } as EuiButtonEmptyProps & EuiLinkProps; + + return ( + + + + + + + +

+ +

+
+
+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx new file mode 100644 index 0000000000000..112e9a910667a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGrid } from '@elastic/eui'; + +import { OrganizationStats } from './organization_stats'; +import { StatisticCard } from './statistic_card'; +import { defaultServerData } from './overview'; + +describe('OrganizationStats', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx new file mode 100644 index 0000000000000..aa9be81f32bae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.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 { EuiFlexGrid } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { ContentSection } from '../shared/content_section'; +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { IAppServerData } from './overview'; + +import { StatisticCard } from './statistic_card'; + +export const OrganizationStats: React.FC = ({ + sourcesCount, + pendingInvitationsCount, + accountsCount, + personalSourcesCount, + isFederatedAuth, +}) => ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx new file mode 100644 index 0000000000000..e5e5235c52368 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/react_router_history.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; + +import { ErrorState } from '../error_state'; +import { Loading } from '../shared/loading'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity } from './recent_activity'; +import { Overview, defaultServerData } from './overview'; + +describe('Overview', () => { + const mockHttp = mockKibanaContext.http; + + describe('non-happy-path states', () => { + it('isLoading', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => Promise.reject({ invalidPayload: true }), + }, + }); + + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('renders onboarding state', async () => { + const mockApi = jest.fn(() => defaultServerData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(1); + expect(wrapper.find(OrganizationStats)).toHaveLength(1); + expect(wrapper.find(RecentActivity)).toHaveLength(1); + }); + + it('renders when onboarding complete', async () => { + const obCompleteData = { + ...defaultServerData, + hasUsers: true, + hasOrgSources: true, + isOldAccount: true, + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }; + const mockApi = jest.fn(() => obCompleteData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx new file mode 100644 index 0000000000000..bacd65a2be75f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect, useState } from 'react'; +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { IAccount } from '../../types'; + +import { ErrorState } from '../error_state'; + +import { Loading } from '../shared/loading'; +import { ProductButton } from '../shared/product_button'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity, IFeedActivity } from './recent_activity'; + +export interface IAppServerData { + hasUsers: boolean; + hasOrgSources: boolean; + canCreateContentSources: boolean; + canCreateInvitations: boolean; + isOldAccount: boolean; + sourcesCount: number; + pendingInvitationsCount: number; + accountsCount: number; + personalSourcesCount: number; + activityFeed: IFeedActivity[]; + organization: { + name: string; + defaultOrgName: string; + }; + isFederatedAuth: boolean; + currentUser: { + firstName: string; + email: string; + name: string; + color: string; + }; + fpAccount: IAccount; +} + +export const defaultServerData = { + accountsCount: 1, + activityFeed: [], + canCreateContentSources: true, + canCreateInvitations: true, + currentUser: { + firstName: '', + email: '', + name: '', + color: '', + }, + fpAccount: {} as IAccount, + hasOrgSources: false, + hasUsers: false, + isFederatedAuth: true, + isOldAccount: false, + organization: { + name: '', + defaultOrgName: '', + }, + pendingInvitationsCount: 0, + personalSourcesCount: 0, + sourcesCount: 0, +} as IAppServerData; + +const ONBOARDING_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', + { defaultMessage: 'Get started with Workplace Search' } +); + +const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { + defaultMessage: 'Organization overview', +}); + +const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', + { defaultMessage: 'Complete the following to set up your organization.' } +); + +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', + { defaultMessage: "Your organizations's statistics and activity" } +); + +export const Overview: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + const [isLoading, setIsLoading] = useState(true); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + const [appData, setAppData] = useState(defaultServerData); + + const getAppData = async () => { + try { + const response = await http.get('/api/workplace_search/overview'); + setAppData(response); + } catch (error) { + setHasErrorConnecting(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getAppData(); + }, []); + + if (hasErrorConnecting) return ; + if (isLoading) return ; + + const { + hasUsers, + hasOrgSources, + isOldAccount, + organization: { name: orgName, defaultOrgName }, + } = appData as IAppServerData; + const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; + + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + + return ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss new file mode 100644 index 0000000000000..2d1e474c03faa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss @@ -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. + */ + +.activity { + display: flex; + justify-content: space-between; + padding: $euiSizeM; + font-size: $euiFontSizeS; + + &--error { + font-weight: $euiFontWeightSemiBold; + color: $euiColorDanger; + background: rgba($euiColorDanger, 0.1); + + &__label { + margin-left: $euiSizeS * 1.75; + font-weight: $euiFontWeightRegular; + text-decoration: underline; + opacity: 0.7; + } + } + + &__message { + flex-grow: 1; + } + + &__date { + flex-grow: 0; + } + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx new file mode 100644 index 0000000000000..e9bdedb199dad --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; + +import { RecentActivity, RecentActivityItem } from './recent_activity'; +import { defaultServerData } from './overview'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const org = { name: 'foo', defaultOrgName: 'bar' }; + +const feed = [ + { + id: 'demo', + sourceId: 'd2d2d23d', + message: 'was successfully connected', + target: 'http://localhost:3002/ws/org/sources', + timestamp: '2020-06-24 16:34:16', + }, +]; + +describe('RecentActivity', () => { + it('renders with no feed data', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + shallow(); + }); + + it('renders an activity feed with links', () => { + const wrapper = shallow(); + const activity = wrapper.find(RecentActivityItem).dive(); + + expect(activity).toHaveLength(1); + + const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + link.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders activity item error state', () => { + const props = { ...feed[0], status: 'error' }; + const wrapper = shallow(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx new file mode 100644 index 0000000000000..8d69582c93684 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx @@ -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 React, { useContext } from 'react'; + +import moment from 'moment'; + +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ContentSection } from '../shared/content_section'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { getSourcePath } from '../../routes'; + +import { IAppServerData } from './overview'; + +import './recent_activity.scss'; + +export interface IFeedActivity { + status?: string; + id: string; + message: string; + timestamp: string; + sourceId: string; +} + +export const RecentActivity: React.FC = ({ + organization: { name, defaultOrgName }, + activityFeed, +}) => { + return ( + + } + headerSpacer="m" + > + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: IFeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + id, + status, + message, + timestamp, + sourceId, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'recent_activity_source_details_link', + }); + + const linkProps = { + onClick, + target: '_blank', + href: getWSRoute(getSourcePath(sourceId)), + external: true, + color: status === 'error' ? 'danger' : 'primary', + 'data-test-subj': 'viewSourceDetailsLink', + } as EuiLinkProps; + + return ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx new file mode 100644 index 0000000000000..edf266231b39e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; + +import { StatisticCard } from './statistic_card'; + +const props = { + title: 'foo', +}; + +describe('StatisticCard', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx new file mode 100644 index 0000000000000..9bc8f4f768073 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.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 from 'react'; + +import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; + +import { useRoutes } from '../shared/use_routes'; + +interface IStatisticCardProps { + title: string; + count?: number; + actionPath?: string; +} + +export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { + const { getWSRoute } = useRoutes(); + + const linkProps = actionPath + ? { + href: getWSRoute(actionPath), + target: '_blank', + rel: 'noopener', + } + : {}; + // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) + + return ( + + + {count} + + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/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 { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..b87c35d5a5942 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..5b5d067d23eb8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.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 { EuiSpacer, EuiTitle, EuiText, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import GettingStarted from '../../assets/getting_started.png'; + +const GETTING_STARTED_LINK_URL = + 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html'; + +export const SetupGuide: React.FC = () => { + return ( + + + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.setupGuide.imageAlt', + + + +

+ +

+
+ + + Get started with Workplace Search + + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg new file mode 100644 index 0000000000000..f8d2ea1e634f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx new file mode 100644 index 0000000000000..f406fb136f13f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { ContentSection } from './'; + +const props = { + children:
, + testSubj: 'contentSection', + className: 'test', +}; + +describe('ContentSection', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.prop('data-test-subj')).toEqual('contentSection'); + expect(wrapper.prop('className')).toEqual('test'); + expect(wrapper.find('.children')).toHaveLength(1); + }); + + it('displays title and description', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTitle)).toHaveLength(1); + expect(wrapper.find('p').text()).toEqual('bar'); + }); + + it('displays header content', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find('.header')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx new file mode 100644 index 0000000000000..b2a9eebc72e85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { TSpacerSize } from '../../../types'; + +interface IContentSectionProps { + children: React.ReactNode; + className?: string; + title?: React.ReactNode; + description?: React.ReactNode; + headerChildren?: React.ReactNode; + headerSpacer?: TSpacerSize; + testSubj?: string; +} + +export const ContentSection: React.FC = ({ + children, + className = '', + title, + description, + headerChildren, + headerSpacer, + testSubj, +}) => ( +
+ {title && ( + <> + +

{title}

+
+ {description &&

{description}

} + {headerChildren} + {headerSpacer && } + + )} + {children} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts new file mode 100644 index 0000000000000..7dcb1b13ad1dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/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 { ContentSection } from './content_section'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts new file mode 100644 index 0000000000000..745639955dcba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/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 { Loading } from './loading'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss new file mode 100644 index 0000000000000..008a8066f807b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss @@ -0,0 +1,14 @@ +.loadingSpinnerWrapper { + width: 100%; + height: 90vh; + margin: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.loadingSpinner { + width: $euiSizeXXL * 1.25; + height: $euiSizeXXL * 1.25; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx new file mode 100644 index 0000000000000..8d168b436cc3b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { Loading } from './'; + +describe('Loading', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx new file mode 100644 index 0000000000000..399abedf55e87 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLoadingSpinner } from '@elastic/eui'; + +import './loading.scss'; + +export const Loading: React.FC = () => ( +
+ +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts new file mode 100644 index 0000000000000..c41e27bacb892 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/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 { ProductButton } from './product_button'; 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 new file mode 100644 index 0000000000000..429a2c509813d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ProductButton } from './'; + +jest.mock('../../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../../shared/telemetry'; + +describe('ProductButton', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); 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 new file mode 100644 index 0000000000000..5b86e14132e0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const ProductButton: React.FC = () => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + fill: true, + iconType: 'popout', + 'data-test-subj': 'launchButton', + } as EuiButtonProps & EuiLinkProps; + buttonProps.href = `${enterpriseSearchUrl}/ws`; + buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'header_launch_button', + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts new file mode 100644 index 0000000000000..cb9684408c459 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/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 { useRoutes } from './use_routes'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx new file mode 100644 index 0000000000000..48b8695f82b43 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const useRoutes = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`; + return { getWSRoute }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts new file mode 100644 index 0000000000000..774b3d85c8c85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/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 { ViewContentHeader } from './view_content_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx new file mode 100644 index 0000000000000..4680f15771caa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGroup } from '@elastic/eui'; + +import { ViewContentHeader } from './'; + +const props = { + title: 'Header', + alignItems: 'flexStart' as any, +}; + +describe('ViewContentHeader', () => { + it('renders with title and alignItems', () => { + const wrapper = shallow(); + + expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); + }); + + it('shows description, when present', () => { + const wrapper = shallow(); + + expect(wrapper.find('p').text()).toEqual('Hello World'); + }); + + it('shows action, when present', () => { + const wrapper = shallow(} />); + + expect(wrapper.find('.action')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx new file mode 100644 index 0000000000000..0408517fd4ec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { FlexGroupAlignItems } from '@elastic/eui/src/components/flex/flex_group'; + +interface IViewContentHeaderProps { + title: React.ReactNode; + description?: React.ReactNode; + action?: React.ReactNode; + alignItems?: FlexGroupAlignItems; +} + +export const ViewContentHeader: React.FC = ({ + title, + description, + action, + alignItems = 'center', +}) => ( + <> + + + +

{title}

+
+ {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + +); 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 new file mode 100644 index 0000000000000..743080d965c36 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.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 '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +import { WorkplaceSearch } from './'; + +describe('Workplace Search Routes', () => { + describe('/', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Overview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); + const wrapper = shallow(); + + expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); 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 new file mode 100644 index 0000000000000..36b1a56ecba26 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.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, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { KibanaContext, IKibanaContext } from '../index'; + +import { SETUP_GUIDE_PATH } from './routes'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +export const WorkplaceSearch: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts new file mode 100644 index 0000000000000..d9798d1f30cfc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.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 const ORG_SOURCES_PATH = '/org/sources'; +export const USERS_PATH = '/org/users'; +export const ORG_SETTINGS_PATH = '/org/settings'; +export const SETUP_GUIDE_PATH = '/setup_guide'; + +export const getSourcePath = (sourceId: string): string => `${ORG_SOURCES_PATH}/${sourceId}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts new file mode 100644 index 0000000000000..b448c59c52f3e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IAccount { + id: string; + isCurated?: boolean; + isAdmin: boolean; + canCreatePersonalSources: boolean; + groups: string[]; + supportEligible: boolean; +} + +export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index fbfcc303de47a..fc95828a3f4a4 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -22,6 +22,7 @@ import { LicensingPluginSetup } from '../../licensing/public'; import { getPublicUrl } from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; +import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; export interface ClientConfigType { host?: string; @@ -58,7 +59,21 @@ export class EnterpriseSearchPlugin implements Plugin { return renderApp(AppSearch, coreStart, params, config, plugins); }, }); - // TODO: Workplace Search will need to register its own plugin. + + core.application.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + appRoute: '/app/enterprise_search/workplace_search', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + + const { renderApp } = await import('./applications'); + const { WorkplaceSearch } = await import('./applications/workplace_search'); + + return renderApp(WorkplaceSearch, coreStart, params, config, plugins); + }, + }); plugins.home.featureCatalogue.register({ id: 'appSearch', @@ -70,7 +85,17 @@ export class EnterpriseSearchPlugin implements Plugin { category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); - // TODO: Workplace Search will need to register its own feature catalogue section/card. + + plugins.home.featureCatalogue.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + icon: WorkplaceSearchLogo, + description: + 'Search all documents, files, and sources available across your virtual workplace.', + path: '/app/enterprise_search/workplace_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); } public start(core: CoreStart) {} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index e95056b871324..53c6dee61cd1d 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -4,20 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingSystemMock } from 'src/core/server/mocks'; +import { mockLogger } from '../../routes/__mocks__'; -jest.mock('../../../../../../src/core/server', () => ({ - SavedObjectsErrorHelpers: { - isNotFoundError: jest.fn(), - }, -})); -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; - -import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; +import { registerTelemetryUsageCollector } from './telemetry'; describe('App Search Telemetry Usage Collector', () => { - const mockLogger = loggingSystemMock.create().get(); - const makeUsageCollectorStub = jest.fn(); const registerStub = jest.fn(); const usageCollectionMock = { @@ -103,41 +94,5 @@ describe('App Search Telemetry Usage Collector', () => { }, }); }); - - it('should not throw but log a warning if saved objects errors', async () => { - const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any; - registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger); - - // Without log warning (not found) - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).not.toHaveBeenCalled(); - - // With log warning - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function' - ); - }); - }); - - describe('incrementUICounter', () => { - it('should increment the saved objects internal repository', async () => { - const response = await incrementUICounter({ - savedObjects: savedObjectsMock, - uiAction: 'ui_clicked', - metric: 'button', - }); - - expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( - 'app_search_telemetry', - 'app_search_telemetry', - 'ui_clicked.button' - ); - expect(response).toEqual({ success: true }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index a10f96907ad28..f700088cb67a0 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -5,16 +5,10 @@ */ import { get } from 'lodash'; -import { - ISavedObjectsRepository, - SavedObjectsServiceStart, - SavedObjectAttributes, - Logger, -} from 'src/core/server'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; interface ITelemetry { ui_viewed: { @@ -70,10 +64,11 @@ export const registerTelemetryUsageCollector = ( const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { const savedObjectsRepository = savedObjects.createInternalRepository(); - const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + AS_TELEMETRY_NAME, savedObjectsRepository, log - )) as SavedObjectAttributes; + ); const defaultTelemetrySavedObject: ITelemetry = { ui_viewed: { @@ -114,43 +109,3 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log }, } as ITelemetry; }; - -/** - * Helper function - fetches saved objects attributes - */ - -const getSavedObjectAttributesFromRepo = async ( - savedObjectsRepository: ISavedObjectsRepository, - log: Logger -) => { - try { - return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; - } catch (e) { - if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { - log.warn(`Failed to retrieve App Search telemetry data: ${e}`); - } - return null; - } -}; - -/** - * Set saved objection attributes - used by telemetry route - */ - -interface IIncrementUICounter { - savedObjects: SavedObjectsServiceStart; - uiAction: string; - metric: string; -} - -export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { - const internalRepository = savedObjects.createInternalRepository(); - - await internalRepository.incrementCounter( - AS_TELEMETRY_NAME, - AS_TELEMETRY_NAME, - `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide - ); - - return { success: true }; -} diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts new file mode 100644 index 0000000000000..3ab3b03dd7725 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../routes/__mocks__'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry'; + +describe('App Search Telemetry Usage Collector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSavedObjectAttributesFromRepo', () => { + // Note: savedObjectsRepository.get() is best tested as a whole from + // individual fetchTelemetryMetrics tests. This mostly just tests error handling + it('should not throw but log a warning if saved objects errors', async () => { + const errorSavedObjectsMock = {} as any; + + // Without log warning (not found) + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + + // With log warning + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to retrieve some_id telemetry data: TypeError: savedObjectsRepository.get is not a function' + ); + }); + }); + + describe('incrementUICounter', () => { + const incrementCounterMock = jest.fn(); + const savedObjectsMock = { + createInternalRepository: jest.fn(() => ({ + incrementCounter: incrementCounterMock, + })), + } as any; + + it('should increment the saved objects internal repository', async () => { + const response = await incrementUICounter({ + id: 'app_search_telemetry', + savedObjects: savedObjectsMock, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(incrementCounterMock).toHaveBeenCalledWith( + 'app_search_telemetry', + 'app_search_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts new file mode 100644 index 0000000000000..f5f4fa368555f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, + Logger, +} from 'src/core/server'; + +// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * Fetches saved objects attributes - used by collectors + */ + +export const getSavedObjectAttributesFromRepo = async ( + id: string, // Telemetry name + savedObjectsRepository: ISavedObjectsRepository, + log: Logger +): Promise => { + try { + return (await savedObjectsRepository.get(id, id)).attributes as SavedObjectAttributes; + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + log.warn(`Failed to retrieve ${id} telemetry data: ${e}`); + } + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + id: string; // Telemetry name + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ + id, + savedObjects, + uiAction, + metric, +}: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + id, + id, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts new file mode 100644 index 0000000000000..496b2f254f9a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../routes/__mocks__'; + +import { registerTelemetryUsageCollector } from './telemetry'; + +describe('Workplace Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_clicked.header_launch_button': 30, + 'ui_clicked.org_name_change_button': 40, + 'ui_clicked.onboarding_card_button': 50, + 'ui_clicked.recent_activity_source_details_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('workplace_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + overview: 20, + }, + ui_error: { + cannot_connect: 3, + }, + ui_clicked: { + header_launch_button: 30, + org_name_change_button: 40, + onboarding_card_button: 50, + recent_activity_source_details_link: 60, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..892de5cfee35e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; + +interface ITelemetry { + ui_viewed: { + setup_guide: number; + overview: number; + }; + ui_error: { + cannot_connect: number; + }; + ui_clicked: { + header_launch_button: number; + org_name_change_button: number; + onboarding_card_button: number; + recent_activity_source_details_link: number; + }; +} + +export const WS_TELEMETRY_NAME = 'workplace_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'workplace_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + }, + ui_clicked: { + header_launch_button: { type: 'long' }, + org_name_change_button: { type: 'long' }, + onboarding_card_button: { type: 'long' }, + recent_activity_source_details_link: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + WS_TELEMETRY_NAME, + savedObjectsRepository, + log + ); + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + overview: get(savedObjectAttributes, 'ui_viewed.overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + org_name_change_button: get(savedObjectAttributes, 'ui_clicked.org_name_change_button', 0), + onboarding_card_button: get(savedObjectAttributes, 'ui_clicked.onboarding_card_button', 0), + recent_activity_source_details_link: get( + savedObjectAttributes, + 'ui_clicked.recent_activity_source_details_link', + 0 + ), + }, + } as ITelemetry; +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 70be8600862e9..a7bd68f92f78b 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -22,10 +22,15 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { ConfigType } from './'; import { checkAccess } from './lib/check_access'; import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; -import { registerEnginesRoute } from './routes/app_search/engines'; -import { registerTelemetryRoute } from './routes/app_search/telemetry'; -import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; + import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; +import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerEnginesRoute } from './routes/app_search/engines'; + +import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; +import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; +import { registerWSOverviewRoute } from './routes/workplace_search/overview'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -64,8 +69,8 @@ export class EnterpriseSearchPlugin implements Plugin { order: 0, icon: 'logoEnterpriseSearch', navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId - app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' - catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + app: ['kibana', 'appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' + catalogue: ['appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' privileges: null, }); @@ -75,15 +80,16 @@ export class EnterpriseSearchPlugin implements Plugin { capabilities.registerSwitcher(async (request: KibanaRequest) => { const dependencies = { config, security, request, log: this.logger }; - const { hasAppSearchAccess } = await checkAccess(dependencies); - // TODO: hasWorkplaceSearchAccess + const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); return { navLinks: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, catalogue: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, }; }); @@ -96,23 +102,24 @@ export class EnterpriseSearchPlugin implements Plugin { registerPublicUrlRoute(dependencies); registerEnginesRoute(dependencies); + registerWSOverviewRoute(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry */ savedObjects.registerType(appSearchTelemetryType); + savedObjects.registerType(workplaceSearchTelemetryType); let savedObjectsStarted: SavedObjectsServiceStart; getStartServices().then(([coreStart]) => { savedObjectsStarted = coreStart.savedObjects; + if (usageCollection) { - registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } }); - registerTelemetryRoute({ - ...dependencies, - getSavedObjectsService: () => savedObjectsStarted, - }); + registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted }); } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts similarity index 56% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index e2d5fbcec3705..ebd84d3e0e79a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -7,20 +7,21 @@ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; -import { registerTelemetryRoute } from './telemetry'; - -jest.mock('../../collectors/app_search/telemetry', () => ({ +jest.mock('../../collectors/lib/telemetry', () => ({ incrementUICounter: jest.fn(), })); -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { registerTelemetryRoute } from './telemetry'; /** * Since these route callbacks are so thin, these serve simply as integration tests * to ensure they're wired up to the collector functions correctly. Business logic * is tested more thoroughly in the collectors/telemetry tests. */ -describe('App Search Telemetry API', () => { +describe('Enterprise Search Telemetry API', () => { let mockRouter: MockRouter; + const successResponse = { success: true }; beforeEach(() => { jest.clearAllMocks(); @@ -34,14 +35,20 @@ describe('App Search Telemetry API', () => { }); }); - describe('PUT /api/app_search/telemetry', () => { - it('increments the saved objects counter', async () => { - const successResponse = { success: true }; + describe('PUT /api/enterprise_search/telemetry', () => { + it('increments the saved objects counter for App Search', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); - await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); + await mockRouter.callRoute({ + body: { + product: 'app_search', + action: 'viewed', + metric: 'setup_guide', + }, + }); expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'app_search_telemetry', savedObjects: expect.any(Object), uiAction: 'ui_viewed', metric: 'setup_guide', @@ -49,10 +56,36 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); }); + it('increments the saved objects counter for Workplace Search', async () => { + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); + + await mockRouter.callRoute({ + body: { + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }, + }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'workplace_search_telemetry', + savedObjects: expect.any(Object), + uiAction: 'ui_clicked', + metric: 'onboarding_card_button', + }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + it('throws an error when incrementing fails', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); - await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); + await mockRouter.callRoute({ + body: { + product: 'enterprise_search', + action: 'error', + metric: 'error', + }, + }); expect(incrementUICounter).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); @@ -73,34 +106,50 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.internalError).toHaveBeenCalled(); expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual( expect.stringContaining( - 'App Search UI telemetry error: Error: Could not find Saved Objects service' + 'Enterprise Search UI telemetry error: Error: Could not find Saved Objects service' ) ); }); describe('validates', () => { it('correctly', () => { - const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + const request = { + body: { product: 'workplace_search', action: 'viewed', metric: 'setup_guide' }, + }; mockRouter.shouldValidate(request); }); + it('wrong product string', () => { + const request = { + body: { product: 'workspace_space_search', action: 'viewed', metric: 'setup_guide' }, + }; + mockRouter.shouldThrow(request); + }); + it('wrong action string', () => { - const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + const request = { + body: { product: 'app_search', action: 'invalid', metric: 'setup_guide' }, + }; mockRouter.shouldThrow(request); }); it('wrong metric type', () => { - const request = { body: { action: 'clicked', metric: true } }; + const request = { body: { product: 'enterprise_search', action: 'clicked', metric: true } }; + mockRouter.shouldThrow(request); + }); + + it('product is missing string', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; mockRouter.shouldThrow(request); }); it('action is missing', () => { - const request = { body: { metric: 'engines_overview' } }; + const request = { body: { product: 'app_search', metric: 'engines_overview' } }; mockRouter.shouldThrow(request); }); it('metric is missing', () => { - const request = { body: { action: 'error' } }; + const request = { body: { product: 'app_search', action: 'error' } }; mockRouter.shouldThrow(request); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts similarity index 55% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index 4cc9b64adc092..7ed1d7b17753c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -7,7 +7,15 @@ import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; +const productToTelemetryMap = { + app_search: AS_TELEMETRY_NAME, + workplace_search: WS_TELEMETRY_NAME, + enterprise_search: 'TODO', +}; export function registerTelemetryRoute({ router, @@ -16,9 +24,14 @@ export function registerTelemetryRoute({ }: IRouteDependencies) { router.put( { - path: '/api/app_search/telemetry', + path: '/api/enterprise_search/telemetry', validate: { body: schema.object({ + product: schema.oneOf([ + schema.literal('app_search'), + schema.literal('workplace_search'), + schema.literal('enterprise_search'), + ]), action: schema.oneOf([ schema.literal('viewed'), schema.literal('clicked'), @@ -29,21 +42,24 @@ export function registerTelemetryRoute({ }, }, async (ctx, request, response) => { - const { action, metric } = request.body; + const { product, action, metric } = request.body; try { if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); return response.ok({ body: await incrementUICounter({ + id: productToTelemetryMap[product], savedObjects: getSavedObjectsService(), uiAction: `ui_${action}`, metric, }), }); } catch (e) { - log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`); - return response.internalError({ body: 'App Search UI telemetry failed' }); + log.error( + `Enterprise Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}` + ); + return response.internalError({ body: 'Enterprise Search UI telemetry failed' }); } } ); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts new file mode 100644 index 0000000000000..b1b5539795357 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerWSOverviewRoute } from './overview'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +const ORG_ROUTE = 'http://localhost:3002/ws/org'; + +describe('engine routes', () => { + describe('GET /api/workplace_search/overview', () => { + const AUTH_HEADER = 'Basic 123'; + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: {}, + }; + + const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter.createRouter(); + + registerWSOverviewRoute({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + describe('when the underlying Workplace Search API returns a 200', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturn({ accountsCount: 1 }); + }); + + it('should return 200 with a list of overview from the Workplace Search API', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { accountsCount: 1 }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); + + describe('when the Workplace Search URL is invalid', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the Workplace Search API returns invalid data', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); + + const WorkplaceSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturn(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + andReturnInvalidData() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts new file mode 100644 index 0000000000000..d1e2f4f5f180d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fetch from 'node-fetch'; + +import { IRouteDependencies } from '../../plugin'; + +export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/overview', + validate: false, + }, + async (context, request, response) => { + try { + const entSearchUrl = config.host as string; + const url = `${encodeURI(entSearchUrl)}/ws/org`; + + const overviewResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const body = await overviewResponse.json(); + const hasValidData = typeof body?.accountsCount === 'number'; + + if (hasValidData) { + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data + throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`); + } + } catch (e) { + log.error(`Cannot connect to Workplace Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.notFound({ body: 'cannot-connect' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..86315a9d617e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; + +export const workplaceSearchTelemetryType: SavedObjectsType = { + name: WS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/event_log/server/es/context.mock.ts b/x-pack/plugins/event_log/server/es/context.mock.ts index 0c9f7b29b6411..8d5483b88c4fa 100644 --- a/x-pack/plugins/event_log/server/es/context.mock.ts +++ b/x-pack/plugins/event_log/server/es/context.mock.ts @@ -17,7 +17,7 @@ const createContextMock = () => { logger: loggingSystemMock.createLogger(), esNames: namesMock.create(), initialize: jest.fn(), - waitTillReady: jest.fn(), + waitTillReady: jest.fn(async () => true), esAdapter: clusterClientAdapterMock.create(), initialized: true, }; diff --git a/x-pack/plugins/event_log/server/es/context.test.ts b/x-pack/plugins/event_log/server/es/context.test.ts index a78e47446fef8..f30b71c99a043 100644 --- a/x-pack/plugins/event_log/server/es/context.test.ts +++ b/x-pack/plugins/event_log/server/es/context.test.ts @@ -7,9 +7,8 @@ import { createEsContext } from './context'; import { LegacyClusterClient, Logger } from '../../../../../src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; -jest.mock('../lib/../../../../package.json', () => ({ - version: '1.2.3', -})); +jest.mock('../lib/../../../../package.json', () => ({ version: '1.2.3' })); +jest.mock('./init'); type EsClusterClient = Pick, 'callAsInternalUser' | 'asScoped'>; let logger: Logger; @@ -92,4 +91,16 @@ describe('createEsContext', () => { ); expect(doesIndexTemplateExist).toBeTruthy(); }); + + test('should handled failed initialization', async () => { + jest.requireMock('./init').initializeEs.mockResolvedValue(false); + const context = createEsContext({ + logger, + clusterClientPromise: Promise.resolve(clusterClient), + indexNameRoot: 'test2', + }); + context.initialize(); + const success = await context.waitTillReady(); + expect(success).toBe(false); + }); }); diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index 16a460be1793b..8c967e68299b5 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -64,9 +64,9 @@ class EsContextImpl implements EsContext { setImmediate(async () => { try { - await this._initialize(); - this.logger.debug('readySignal.signal(true)'); - this.readySignal.signal(true); + const success = await this._initialize(); + this.logger.debug(`readySignal.signal(${success})`); + this.readySignal.signal(success); } catch (err) { this.logger.debug('readySignal.signal(false)'); this.readySignal.signal(false); @@ -74,11 +74,13 @@ class EsContextImpl implements EsContext { }); } + // waits till the ES initialization is done, returns true if it was successful, + // false if it was not successful async waitTillReady(): Promise { return await this.readySignal.wait(); } - private async _initialize() { - await initializeEs(this); + private async _initialize(): Promise { + return await initializeEs(this); } } diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index d4d3df3ef8267..fde3b2de8dd36 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -14,25 +14,52 @@ import { delay } from './lib/delay'; import { EVENT_LOGGED_PREFIX } from './event_logger'; const KIBANA_SERVER_UUID = '424-24-2424'; +const WRITE_LOG_WAIT_MILLIS = 3000; describe('EventLogger', () => { let systemLogger: ReturnType; - let esContext: EsContext; + let esContext: jest.Mocked; let service: IEventLogService; let eventLogger: IEventLogger; beforeEach(() => { + jest.resetAllMocks(); systemLogger = loggingSystemMock.createLogger(); esContext = contextMock.create(); service = new EventLogService({ esContext, systemLogger, - config: { enabled: true, logEntries: true, indexEntries: false }, + config: { enabled: true, logEntries: true, indexEntries: true }, kibanaUUID: KIBANA_SERVER_UUID, }); eventLogger = service.getLogger({}); }); + test('handles successful initialization', async () => { + service.registerProviderActions('test-provider', ['test-action-1']); + eventLogger = service.getLogger({ + event: { provider: 'test-provider', action: 'test-action-1' }, + }); + + eventLogger.logEvent({}); + await waitForLogEvent(systemLogger); + delay(WRITE_LOG_WAIT_MILLIS); // sleep a bit since event logging is async + expect(esContext.esAdapter.indexDocument).toHaveBeenCalled(); + }); + + test('handles failed initialization', async () => { + service.registerProviderActions('test-provider', ['test-action-1']); + eventLogger = service.getLogger({ + event: { provider: 'test-provider', action: 'test-action-1' }, + }); + esContext.waitTillReady.mockImplementation(async () => false); + + eventLogger.logEvent({}); + await waitForLogEvent(systemLogger); + delay(WRITE_LOG_WAIT_MILLIS); // sleep a bit longer since event logging is async + expect(esContext.esAdapter.indexDocument).not.toHaveBeenCalled(); + }); + test('method logEvent() writes expected default values', async () => { service.registerProviderActions('test-provider', ['test-action-1']); eventLogger = service.getLogger({ diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 1a710a6fa4865..8730870f9620b 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -183,7 +183,12 @@ function indexEventDoc(esContext: EsContext, doc: Doc): void { // whew, the thing that actually writes the event log document! async function indexLogEventDoc(esContext: EsContext, doc: unknown) { esContext.logger.debug(`writing to event log: ${JSON.stringify(doc)}`); - await esContext.waitTillReady(); + const success = await esContext.waitTillReady(); + if (!success) { + esContext.logger.debug(`event log did not initialize correctly, event not written`); + return; + } + await esContext.esAdapter.indexDocument(doc); esContext.logger.debug(`writing to event log complete`); } diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.js index cfa125fcc49ee..5cc06bad4c423 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.js @@ -107,7 +107,7 @@ function UnGroupOperation(parent, child) { // The main constructor for our GraphWorkspace function GraphWorkspace(options) { const self = this; - this.blacklistedNodes = []; + this.blocklistedNodes = []; this.options = options; this.undoLog = []; this.redoLog = []; @@ -379,7 +379,7 @@ function GraphWorkspace(options) { this.redoLog = []; this.nodesMap = {}; this.edgesMap = {}; - this.blacklistedNodes = []; + this.blocklistedNodes = []; this.selectedNodes = []; this.lastResponse = null; }; @@ -630,11 +630,11 @@ function GraphWorkspace(options) { self.runLayout(); }; - this.unblacklist = function (node) { - self.arrRemove(self.blacklistedNodes, node); + this.unblocklist = function (node) { + self.arrRemove(self.blocklistedNodes, node); }; - this.blacklistSelection = function () { + this.blocklistSelection = function () { const selection = self.getAllSelectedNodes(); const danglingEdges = []; self.edges.forEach(function (edge) { @@ -645,7 +645,7 @@ function GraphWorkspace(options) { }); selection.forEach((node) => { delete self.nodesMap[node.id]; - self.blacklistedNodes.push(node); + self.blocklistedNodes.push(node); node.isSelected = false; }); self.arrRemoveAll(self.nodes, selection); @@ -671,10 +671,10 @@ function GraphWorkspace(options) { } let step = {}; - //Add any blacklisted nodes to exclusion list + //Add any blocklisted nodes to exclusion list const excludeNodesByField = {}; const nots = []; - const avoidNodes = this.blacklistedNodes; + const avoidNodes = this.blocklistedNodes; for (let i = 0; i < avoidNodes.length; i++) { const n = avoidNodes[i]; let arr = excludeNodesByField[n.data.field]; @@ -914,8 +914,8 @@ function GraphWorkspace(options) { const nodesByField = {}; const excludeNodesByField = {}; - //Add any blacklisted nodes to exclusion list - const avoidNodes = this.blacklistedNodes; + //Add any blocklisted nodes to exclusion list + const avoidNodes = this.blocklistedNodes; for (let i = 0; i < avoidNodes.length; i++) { const n = avoidNodes[i]; let arr = excludeNodesByField[n.data.field]; @@ -1320,12 +1320,12 @@ function GraphWorkspace(options) { allExistingNodes.forEach((existingNode) => { addTermToFieldList(excludeNodesByField, existingNode.data.field, existingNode.data.term); }); - const blacklistedNodes = self.blacklistedNodes; - blacklistedNodes.forEach((blacklistedNode) => { + const blocklistedNodes = self.blocklistedNodes; + blocklistedNodes.forEach((blocklistedNode) => { addTermToFieldList( excludeNodesByField, - blacklistedNode.data.field, - blacklistedNode.data.term + blocklistedNode.data.field, + blocklistedNode.data.term ); }); diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js index fe6a782373eb2..65766cbefaad3 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js @@ -82,7 +82,7 @@ describe('graphui-workspace', function () { expect(workspace.nodes.length).toEqual(2); expect(workspace.edges.length).toEqual(1); expect(workspace.selectedNodes.length).toEqual(0); - expect(workspace.blacklistedNodes.length).toEqual(0); + expect(workspace.blocklistedNodes.length).toEqual(0); const nodeA = workspace.getNode(workspace.makeNodeId('field1', 'a')); expect(typeof nodeA).toBe('object'); @@ -124,7 +124,7 @@ describe('graphui-workspace', function () { expect(workspace.nodes.length).toEqual(2); expect(workspace.edges.length).toEqual(1); expect(workspace.selectedNodes.length).toEqual(0); - expect(workspace.blacklistedNodes.length).toEqual(0); + expect(workspace.blocklistedNodes.length).toEqual(0); mockedResult = { vertices: [ diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 939d92518e271..50385008d7b2b 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -124,7 +124,7 @@ diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index 08b13e9d5c541..fd2b96e0570f6 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -562,8 +562,8 @@ export function initGraphApp(angularModule, deps) { run: () => { const settingsObservable = asAngularSyncedObservable( () => ({ - blacklistedNodes: $scope.workspace ? [...$scope.workspace.blacklistedNodes] : undefined, - unblacklistNode: $scope.workspace ? $scope.workspace.unblacklist : undefined, + blocklistedNodes: $scope.workspace ? [...$scope.workspace.blocklistedNodes] : undefined, + unblocklistNode: $scope.workspace ? $scope.workspace.unblocklist : undefined, canEditDrillDownUrls: canEditDrillDownUrls, }), $scope.$digest.bind($scope) diff --git a/x-pack/plugins/graph/public/components/settings/blacklist_form.tsx b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx similarity index 72% rename from x-pack/plugins/graph/public/components/settings/blacklist_form.tsx rename to x-pack/plugins/graph/public/components/settings/blocklist_form.tsx index 68cdcc1fbb7b1..29ab7611fcee8 100644 --- a/x-pack/plugins/graph/public/components/settings/blacklist_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx @@ -20,16 +20,16 @@ import { SettingsProps } from './settings'; import { LegacyIcon } from '../legacy_icon'; import { useListKeys } from './use_list_keys'; -export function BlacklistForm({ - blacklistedNodes, - unblacklistNode, -}: Pick) { - const getListKey = useListKeys(blacklistedNodes || []); +export function BlocklistForm({ + blocklistedNodes, + unblocklistNode, +}: Pick) { + const getListKey = useListKeys(blocklistedNodes || []); return ( <> - {blacklistedNodes && blacklistedNodes.length > 0 ? ( + {blocklistedNodes && blocklistedNodes.length > 0 ? ( - {i18n.translate('xpack.graph.settings.blacklist.blacklistHelpText', { + {i18n.translate('xpack.graph.settings.blocklist.blocklistHelpText', { defaultMessage: 'These terms are not allowed in the graph.', })} @@ -37,7 +37,7 @@ export function BlacklistForm({ }} /> @@ -45,25 +45,25 @@ export function BlacklistForm({ /> )} - {blacklistedNodes && unblacklistNode && blacklistedNodes.length > 0 && ( + {blocklistedNodes && unblocklistNode && blocklistedNodes.length > 0 && ( <> - {blacklistedNodes.map((node) => ( + {blocklistedNodes.map((node) => ( } key={getListKey(node)} label={node.label} extraAction={{ iconType: 'trash', - 'aria-label': i18n.translate('xpack.graph.blacklist.removeButtonAriaLabel', { + 'aria-label': i18n.translate('xpack.graph.blocklist.removeButtonAriaLabel', { defaultMessage: 'Delete', }), - title: i18n.translate('xpack.graph.blacklist.removeButtonAriaLabel', { + title: i18n.translate('xpack.graph.blocklist.removeButtonAriaLabel', { defaultMessage: 'Delete', }), color: 'danger', onClick: () => { - unblacklistNode(node); + unblocklistNode(node); }, }} /> @@ -71,18 +71,18 @@ export function BlacklistForm({ { - blacklistedNodes.forEach((node) => { - unblacklistNode(node); + blocklistedNodes.forEach((node) => { + unblocklistNode(node); }); }} > - {i18n.translate('xpack.graph.settings.blacklist.clearButtonLabel', { + {i18n.translate('xpack.graph.settings.blocklist.clearButtonLabel', { defaultMessage: 'Delete all', })} diff --git a/x-pack/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/plugins/graph/public/components/settings/settings.test.tsx index 1efaead002b52..7d13249288d53 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.test.tsx @@ -46,7 +46,7 @@ describe('settings', () => { }; const angularProps: jest.Mocked = { - blacklistedNodes: [ + blocklistedNodes: [ { x: 0, y: 0, @@ -57,7 +57,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 1', + label: 'blocklisted node 1', icon: { class: 'test', code: '1', @@ -74,7 +74,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 2', + label: 'blocklisted node 2', icon: { class: 'test', code: '1', @@ -82,7 +82,7 @@ describe('settings', () => { }, }, ], - unblacklistNode: jest.fn(), + unblocklistNode: jest.fn(), canEditDrillDownUrls: true, }; @@ -201,15 +201,15 @@ describe('settings', () => { }); }); - describe('blacklist', () => { + describe('blocklist', () => { beforeEach(() => { toTab('Block list'); }); - it('should switch tab to blacklist', () => { + it('should switch tab to blocklist', () => { expect(instance.find(EuiListGroupItem).map((item) => item.prop('label'))).toEqual([ - 'blacklisted node 1', - 'blacklisted node 2', + 'blocklisted node 1', + 'blocklisted node 2', ]); }); @@ -217,7 +217,7 @@ describe('settings', () => { act(() => { subject.next({ ...angularProps, - blacklistedNodes: [ + blocklistedNodes: [ { x: 0, y: 0, @@ -228,7 +228,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 3', + label: 'blocklisted node 3', icon: { class: 'test', code: '1', @@ -242,21 +242,21 @@ describe('settings', () => { instance.update(); expect(instance.find(EuiListGroupItem).map((item) => item.prop('label'))).toEqual([ - 'blacklisted node 3', + 'blocklisted node 3', ]); }); it('should delete node', () => { instance.find(EuiListGroupItem).at(0).prop('extraAction')!.onClick!({} as any); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); }); it('should delete all nodes', () => { - instance.find('[data-test-subj="graphUnblacklistAll"]').find(EuiButton).simulate('click'); + instance.find('[data-test-subj="graphUnblocklistAll"]').find(EuiButton).simulate('click'); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![1]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![1]); }); }); diff --git a/x-pack/plugins/graph/public/components/settings/settings.tsx b/x-pack/plugins/graph/public/components/settings/settings.tsx index 3baf6b6a0a2e3..3a9ea6e96859b 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.tsx @@ -11,7 +11,7 @@ import * as Rx from 'rxjs'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { AdvancedSettingsForm } from './advanced_settings_form'; -import { BlacklistForm } from './blacklist_form'; +import { BlocklistForm } from './blocklist_form'; import { UrlTemplateList } from './url_template_list'; import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types'; import { @@ -33,9 +33,9 @@ const tabs = [ component: AdvancedSettingsForm, }, { - id: 'blacklist', - title: i18n.translate('xpack.graph.settings.blacklistTitle', { defaultMessage: 'Block list' }), - component: BlacklistForm, + id: 'blocklist', + title: i18n.translate('xpack.graph.settings.blocklistTitle', { defaultMessage: 'Block list' }), + component: BlocklistForm, }, { id: 'drillDowns', @@ -51,8 +51,8 @@ const tabs = [ * to catch update outside updates */ export interface AngularProps { - blacklistedNodes: WorkspaceNode[]; - unblacklistNode: (node: WorkspaceNode) => void; + blocklistedNodes: WorkspaceNode[]; + unblocklistNode: (node: WorkspaceNode) => void; canEditDrillDownUrls: boolean; } diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts index 3dda41fcdbdb6..e9f116b79f990 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts @@ -26,7 +26,7 @@ describe('deserialize', () => { { color: 'black', name: 'field1', selected: true, iconClass: 'a' }, { color: 'black', name: 'field2', selected: true, iconClass: 'b' }, ], - blacklist: [ + blocklist: [ { color: 'black', label: 'Z', @@ -192,7 +192,7 @@ describe('deserialize', () => { it('should deserialize nodes and edges', () => { callSavedWorkspaceToAppState(); - expect(workspace.blacklistedNodes.length).toEqual(1); + expect(workspace.blocklistedNodes.length).toEqual(1); expect(workspace.nodes.length).toEqual(5); expect(workspace.edges.length).toEqual(2); diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.ts index 6fd720a60edc0..324bf10cdd99c 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.ts @@ -128,11 +128,11 @@ function getFieldsWithWorkspaceSettings( return allFields; } -function getBlacklistedNodes( +function getBlocklistedNodes( serializedWorkspaceState: SerializedWorkspaceState, allFields: WorkspaceField[] ) { - return serializedWorkspaceState.blacklist.map((serializedNode) => { + return serializedWorkspaceState.blocklist.map((serializedNode) => { const currentField = allFields.find((field) => field.name === serializedNode.field)!; return { x: 0, @@ -235,9 +235,9 @@ export function savedWorkspaceToAppState( workspaceInstance.mergeGraph(graph); resolveGroups(persistedWorkspaceState.vertices, workspaceInstance); - // ================== blacklist ============================= - const blacklistedNodes = getBlacklistedNodes(persistedWorkspaceState, allFields); - workspaceInstance.blacklistedNodes.push(...blacklistedNodes); + // ================== blocklist ============================= + const blocklistedNodes = getBlocklistedNodes(persistedWorkspaceState, allFields); + workspaceInstance.blocklistedNodes.push(...blocklistedNodes); return { urlTemplates, diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts index a3942eccfdac3..0c9de0418a738 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts @@ -118,7 +118,7 @@ describe('serialize', () => { parent: null, }, ], - blacklistedNodes: [ + blocklistedNodes: [ { color: 'black', data: { field: 'field1', term: 'Z' }, @@ -165,7 +165,7 @@ describe('serialize', () => { const workspaceState = JSON.parse(savedWorkspace.wsState); expect(workspaceState).toMatchInlineSnapshot(` Object { - "blacklist": Array [ + "blocklist": Array [ Object { "color": "black", "field": "field1", diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.ts b/x-pack/plugins/graph/public/services/persistence/serialize.ts index 6cbebc995d84a..a3a76a8a08eba 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.ts @@ -96,8 +96,8 @@ export function appStateToSavedWorkspace( }, canSaveData: boolean ) { - const blacklist: SerializedNode[] = canSaveData - ? workspace.blacklistedNodes.map((node) => serializeNode(node)) + const blocklist: SerializedNode[] = canSaveData + ? workspace.blocklistedNodes.map((node) => serializeNode(node)) : []; const vertices: SerializedNode[] = canSaveData ? workspace.nodes.map((node) => serializeNode(node, workspace.nodes)) @@ -111,7 +111,7 @@ export function appStateToSavedWorkspace( const persistedWorkspaceState: SerializedWorkspaceState = { indexPattern: selectedIndex.title, selectedFields: selectedFields.map(serializeField), - blacklist, + blocklist, vertices, links, urlTemplates: mappedUrlTemplates, diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index 5a0269d691de2..d32bc9a175a47 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -46,7 +46,7 @@ export function createMockGraphStore({ nodes: [], edges: [], options: {}, - blacklistedNodes: [], + blocklistedNodes: [], } as unknown) as Workspace; const savedWorkspace = ({ diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index cd2c6680c1fd2..cf6566f0c5f86 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -198,7 +198,7 @@ function showModal( openSaveModal({ savePolicy: deps.savePolicy, - hasData: workspace.nodes.length > 0 || workspace.blacklistedNodes.length > 0, + hasData: workspace.nodes.length > 0 || workspace.blocklistedNodes.length > 0, workspace: savedWorkspace, showSaveModal: deps.showSaveModal, saveWorkspace: saveWorkspaceHandler, diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index 6847199d5878c..8e7e9c7e8878e 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -33,7 +33,7 @@ export interface GraphWorkspaceSavedObject { export interface SerializedWorkspaceState { indexPattern: string; selectedFields: SerializedField[]; - blacklist: SerializedNode[]; + blocklist: SerializedNode[]; vertices: SerializedNode[]; links: SerializedEdge[]; urlTemplates: SerializedUrlTemplate[]; diff --git a/x-pack/plugins/graph/public/types/workspace_state.ts b/x-pack/plugins/graph/public/types/workspace_state.ts index 8c4178eda890f..b5ee48311ddc8 100644 --- a/x-pack/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/plugins/graph/public/types/workspace_state.ts @@ -63,7 +63,7 @@ export interface Workspace { nodesMap: Record; nodes: WorkspaceNode[]; edges: WorkspaceEdge[]; - blacklistedNodes: WorkspaceNode[]; + blocklistedNodes: WorkspaceNode[]; getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject; getSelectedOrAllNodes(): WorkspaceNode[]; diff --git a/x-pack/plugins/graph/server/sample_data/ecommerce.ts b/x-pack/plugins/graph/server/sample_data/ecommerce.ts index 7543e9471f05c..b9b4e063cb28f 100644 --- a/x-pack/plugins/graph/server/sample_data/ecommerce.ts +++ b/x-pack/plugins/graph/server/sample_data/ecommerce.ts @@ -37,7 +37,7 @@ const wsState: any = { iconClass: 'fa-heart', }, ], - blacklist: [ + blocklist: [ { x: 491.3880229084531, y: 572.375603969653, diff --git a/x-pack/plugins/graph/server/sample_data/flights.ts b/x-pack/plugins/graph/server/sample_data/flights.ts index bca1d0d093a8e..209b7108266cf 100644 --- a/x-pack/plugins/graph/server/sample_data/flights.ts +++ b/x-pack/plugins/graph/server/sample_data/flights.ts @@ -37,7 +37,7 @@ const wsState: any = { iconClass: 'fa-cube', }, ], - blacklist: [], + blocklist: [], vertices: [ { x: 324.55695700802687, diff --git a/x-pack/plugins/graph/server/sample_data/logs.ts b/x-pack/plugins/graph/server/sample_data/logs.ts index 5ca810b397cd2..c3cc2ecd2fc65 100644 --- a/x-pack/plugins/graph/server/sample_data/logs.ts +++ b/x-pack/plugins/graph/server/sample_data/logs.ts @@ -45,7 +45,7 @@ const wsState: any = { iconClass: 'fa-key', }, ], - blacklist: [ + blocklist: [ { x: 349.9814471314239, y: 274.1259761174194, diff --git a/x-pack/plugins/graph/server/saved_objects/migrations.ts b/x-pack/plugins/graph/server/saved_objects/migrations.ts index beb31d548c670..34cd59e2220e9 100644 --- a/x-pack/plugins/graph/server/saved_objects/migrations.ts +++ b/x-pack/plugins/graph/server/saved_objects/migrations.ts @@ -37,4 +37,23 @@ export const graphMigrations = { }); return doc; }, + '7.10.0': (doc: SavedObjectUnsanitizedDoc) => { + const wsState = get(doc, 'attributes.wsState'); + if (typeof wsState !== 'string') { + return doc; + } + let state; + try { + state = JSON.parse(JSON.parse(wsState)); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + return doc; + } + if (state.blacklist) { + state.blocklist = state.blacklist; + delete state.blacklist; + } + doc.attributes.wsState = JSON.stringify(JSON.stringify(state)); + return doc; + }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js index cd690c768a326..d90ad9378efd4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js @@ -179,7 +179,7 @@ export const MinAgeInput = (props) => { return ( - + { /> - + = ({ value, onChan diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index aec25ee3247d6..6139ed5d2e6ad 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from '../../../../src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IngestManagerSetup } from '../../ingest_manager/public'; import { UIM_APP_NAME, PLUGIN } from '../common/constants'; @@ -51,7 +51,7 @@ export class IndexMgmtUIPlugin { notificationService.setup(notifications); this.uiMetricService.setup(usageCollection); - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: PLUGIN.id, title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), order: 0, diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index cbd89db97236f..a01042616a872 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -10,3 +10,4 @@ export * from './log_entry_category_examples'; export * from './log_entry_rate'; export * from './log_entry_examples'; export * from './log_entry_anomalies'; +export * from './log_entry_anomalies_datasets'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts index 639ac63f9b14d..62b76a0ae475e 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts @@ -128,6 +128,8 @@ export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ pagination: paginationRT, // Sort properties sort: sortRT, + // Dataset filters + datasets: rt.array(rt.string), }), ]), }); diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies_datasets.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies_datasets.ts new file mode 100644 index 0000000000000..56784dba1be44 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies_datasets.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_DATASETS_PATH = + '/api/infra/log_analysis/results/log_entry_anomalies_datasets'; + +/** + * request + */ + +export const getLogEntryAnomaliesDatasetsRequestPayloadRT = rt.type({ + data: rt.type({ + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the anomalies datasets from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryAnomaliesDatasetsRequestPayload = rt.TypeOf< + typeof getLogEntryAnomaliesDatasetsRequestPayloadRT +>; + +/** + * response + */ + +export const getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + datasets: rt.array(rt.string), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryAnomaliesDatasetsSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT +>; + +export const getLogEntryAnomaliesDatasetsResponsePayloadRT = rt.union([ + getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryAnomaliesDatasetsReponsePayload = rt.TypeOf< + typeof getLogEntryAnomaliesDatasetsResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts index b7e8a49735152..20a8e5c378cec 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate.ts @@ -16,11 +16,16 @@ export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH = */ export const getLogEntryRateRequestPayloadRT = rt.type({ - data: rt.type({ - bucketDuration: rt.number, - sourceId: rt.string, - timeRange: timeRangeRT, - }), + data: rt.intersection([ + rt.type({ + bucketDuration: rt.number, + sourceId: rt.string, + timeRange: timeRangeRT, + }), + rt.partial({ + datasets: rt.array(rt.string), + }), + ]), }); export type GetLogEntryRateRequestPayload = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts index b8fba7a14e243..680a2a0fef114 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts @@ -14,18 +14,10 @@ export type JobStatus = | 'finished' | 'failed'; -export type SetupStatusRequiredReason = - | 'missing' // jobs are missing - | 'reconfiguration' // the configurations don't match the source configurations - | 'update'; // the definitions don't match the module definitions - export type SetupStatus = | { type: 'initializing' } // acquiring job statuses to determine setup status | { type: 'unknown' } // job status could not be acquired (failed request etc) - | { - type: 'required'; - reason: SetupStatusRequiredReason; - } // setup required + | { type: 'required' } // setup required | { type: 'pending' } // In the process of setting up the module for the first time or retrying, waiting for response | { type: 'succeeded' } // setup succeeded, notifying user | { diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap index 4680414493a2c..d71e1feb575e4 100644 --- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap +++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap @@ -2,7 +2,7 @@ exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = ` Object { - "appLink": "/app/metrics", + "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)", "series": Object { "inboundTraffic": Object { "coordinates": Array [ @@ -203,6 +203,5 @@ Object { "value": 3, }, }, - "title": "Metrics", } `; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index e954cf21229ee..afad55dd22d43 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -5,4 +5,5 @@ */ export * from './log_analysis_job_problem_indicator'; +export * from './notices_section'; export * from './recreate_job_button'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx index 13b7d1927f676..a8a7ec4f5f44f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx @@ -11,19 +11,24 @@ import React from 'react'; import { RecreateJobCallout } from './recreate_job_callout'; export const JobConfigurationOutdatedCallout: React.FC<{ + moduleName: string; onRecreateMlJob: () => void; -}> = ({ onRecreateMlJob }) => ( - +}> = ({ moduleName, onRecreateMlJob }) => ( + ); - -const jobConfigurationOutdatedTitle = i18n.translate( - 'xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutTitle', - { - defaultMessage: 'ML job configuration outdated', - } -); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx index 5072fb09cdceb..7d876b91fc6b5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx @@ -11,19 +11,24 @@ import React from 'react'; import { RecreateJobCallout } from './recreate_job_callout'; export const JobDefinitionOutdatedCallout: React.FC<{ + moduleName: string; onRecreateMlJob: () => void; -}> = ({ onRecreateMlJob }) => ( - +}> = ({ moduleName, onRecreateMlJob }) => ( + ); - -const jobDefinitionOutdatedTitle = i18n.translate( - 'xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle', - { - defaultMessage: 'ML job definition outdated', - } -); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx index e7e89bb365e4f..9cdf4a667d140 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx @@ -16,6 +16,7 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ hasOutdatedJobDefinitions: boolean; hasStoppedJobs: boolean; isFirstUse: boolean; + moduleName: string; onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; }> = ({ @@ -23,16 +24,23 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ hasOutdatedJobDefinitions, hasStoppedJobs, isFirstUse, + moduleName, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate, }) => { return ( <> {hasOutdatedJobDefinitions ? ( - + ) : null} {hasOutdatedJobConfigurations ? ( - + ) : null} {hasStoppedJobs ? : null} {isFirstUse ? : null} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx similarity index 83% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx index 8f44b5b54c48f..aa72281b9fbdb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx @@ -5,8 +5,8 @@ */ import React from 'react'; -import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status'; -import { QualityWarning } from './quality_warnings'; +import { QualityWarning } from '../../../containers/logs/log_analysis/log_analysis_module_types'; +import { LogAnalysisJobProblemIndicator } from './log_analysis_job_problem_indicator'; import { CategoryQualityWarnings } from './quality_warning_notices'; export const CategoryJobNoticesSection: React.FC<{ @@ -14,6 +14,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions: boolean; hasStoppedJobs: boolean; isFirstUse: boolean; + moduleName: string; onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; qualityWarnings: QualityWarning[]; @@ -22,6 +23,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions, hasStoppedJobs, isFirstUse, + moduleName, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate, qualityWarnings, @@ -32,6 +34,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions={hasOutdatedJobDefinitions} hasStoppedJobs={hasStoppedJobs} isFirstUse={isFirstUse} + moduleName={moduleName} onRecreateMlJobForReconfiguration={onRecreateMlJobForReconfiguration} onRecreateMlJobForUpdate={onRecreateMlJobForUpdate} /> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx similarity index 96% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx index 73b6b88db873a..0d93ead5a82c6 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx @@ -8,7 +8,10 @@ import { EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { CategoryQualityWarningReason, QualityWarning } from './quality_warnings'; +import type { + CategoryQualityWarningReason, + QualityWarning, +} from '../../../containers/logs/log_analysis/log_analysis_module_types'; export const CategoryQualityWarnings: React.FC<{ qualityWarnings: QualityWarning[] }> = ({ qualityWarnings, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/datasets_selector.tsx similarity index 92% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_results/datasets_selector.tsx index ab938ff1d1374..2236dc9e45da6 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/datasets_selector.tsx @@ -8,7 +8,7 @@ import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; -import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; +import { getFriendlyNameForPartitionId } from '../../../../common/log_analysis'; type DatasetOptionProps = EuiComboBoxOptionOption; @@ -51,7 +51,7 @@ export const DatasetsSelector: React.FunctionComponent<{ }; const datasetFilterPlaceholder = i18n.translate( - 'xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder', + 'xpack.infra.logs.analysis.datasetFilterPlaceholder', { defaultMessage: 'Filter by datasets', } diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx index c9b14a1ffe47a..d4c3c727bd34e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx @@ -84,7 +84,7 @@ export const InitialConfigurationStep: React.FunctionComponent> = (props) => ( + + + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx index 3fa72fe8a07e7..a9c94b5983803 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx @@ -101,11 +101,10 @@ export const ProcessStep: React.FunctionComponent = ({ /> - ) : setupStatus.type === 'required' && - (setupStatus.reason === 'update' || setupStatus.reason === 'reconfiguration') ? ( - - ) : ( + ) : setupStatus.type === 'required' ? ( + ) : ( + )} ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx new file mode 100644 index 0000000000000..881996073871e --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './setup_flyout'; +export * from './setup_flyout_state'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx new file mode 100644 index 0000000000000..2bc5b08a1016a --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer, EuiSteps, EuiText, EuiTitle } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useLogEntryCategoriesSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { createInitialConfigurationStep } from '../initial_configuration_step'; +import { createProcessStep } from '../process_step'; + +export const LogEntryCategoriesSetupView: React.FC<{ + onClose: () => void; +}> = ({ onClose }) => { + const { + cleanUpAndSetUp, + endTime, + isValidating, + lastSetupErrorMessages, + moduleDescriptor, + setEndTime, + setStartTime, + setValidatedIndices, + setUp, + setupStatus, + startTime, + validatedIndices, + validationErrors, + viewResults, + } = useLogEntryCategoriesSetup(); + + const viewResultsAndClose = useCallback(() => { + viewResults(); + onClose(); + }, [viewResults, onClose]); + + const steps = useMemo( + () => [ + createInitialConfigurationStep({ + setStartTime, + setEndTime, + startTime, + endTime, + isValidating, + validatedIndices, + setupStatus, + setValidatedIndices, + validationErrors, + }), + createProcessStep({ + cleanUpAndSetUp, + errorMessages: lastSetupErrorMessages, + isConfigurationValid: validationErrors.length <= 0 && !isValidating, + setUp, + setupStatus, + viewResults: viewResultsAndClose, + }), + ], + [ + cleanUpAndSetUp, + endTime, + isValidating, + lastSetupErrorMessages, + setEndTime, + setStartTime, + setUp, + setValidatedIndices, + setupStatus, + startTime, + validatedIndices, + validationErrors, + viewResultsAndClose, + ] + ); + + return ( + <> + +

{moduleDescriptor.moduleName}

+
+ {moduleDescriptor.moduleDescription} + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx similarity index 50% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx index 0e9e34432f28b..0b7037e60de0b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx @@ -5,37 +5,20 @@ */ import React, { useMemo, useCallback } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiTitle, - EuiText, - EuiSpacer, - EuiSteps, -} from '@elastic/eui'; +import { EuiTitle, EuiText, EuiSpacer, EuiSteps } from '@elastic/eui'; +import { createInitialConfigurationStep } from '../initial_configuration_step'; +import { createProcessStep } from '../process_step'; +import { useLogEntryRateSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_rate'; -import { - createInitialConfigurationStep, - createProcessStep, -} from '../../../components/logging/log_analysis_setup'; -import { useLogEntryRateSetup } from './use_log_entry_rate_setup'; - -interface LogEntryRateSetupFlyoutProps { - isOpen: boolean; +export const LogEntryRateSetupView: React.FC<{ onClose: () => void; -} - -export const LogEntryRateSetupFlyout: React.FC = ({ - isOpen, - onClose, -}) => { +}> = ({ onClose }) => { const { cleanUpAndSetUp, endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, @@ -91,39 +74,14 @@ export const LogEntryRateSetupFlyout: React.FC = ( ] ); - if (!isOpen) { - return null; - } return ( - - - -

- -

-
-
- - -

- -

-
- - - - - -
-
+ <> + +

{moduleDescriptor.moduleName}

+
+ {moduleDescriptor.moduleDescription} + + + ); }; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx new file mode 100644 index 0000000000000..8239ab4a730ff --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { + logEntryCategoriesModule, + useLogEntryCategoriesModuleContext, +} from '../../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { + logEntryRateModule, + useLogEntryRateModuleContext, +} from '../../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { LogAnalysisModuleListCard } from './module_list_card'; +import type { ModuleId } from './setup_flyout_state'; + +export const LogAnalysisModuleList: React.FC<{ + onViewModuleSetup: (module: ModuleId) => void; +}> = ({ onViewModuleSetup }) => { + const { setupStatus: logEntryRateSetupStatus } = useLogEntryRateModuleContext(); + const { setupStatus: logEntryCategoriesSetupStatus } = useLogEntryCategoriesModuleContext(); + + const viewLogEntryRateSetupFlyout = useCallback(() => { + onViewModuleSetup('logs_ui_analysis'); + }, [onViewModuleSetup]); + const viewLogEntryCategoriesSetupFlyout = useCallback(() => { + onViewModuleSetup('logs_ui_categories'); + }, [onViewModuleSetup]); + + return ( + <> + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx new file mode 100644 index 0000000000000..17806dbe93797 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.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 { EuiCard, EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RecreateJobButton } from '../../log_analysis_job_status'; +import { SetupStatus } from '../../../../../common/log_analysis'; + +export const LogAnalysisModuleListCard: React.FC<{ + moduleDescription: string; + moduleName: string; + moduleStatus: SetupStatus; + onViewSetup: () => void; +}> = ({ moduleDescription, moduleName, moduleStatus, onViewSetup }) => { + const icon = + moduleStatus.type === 'required' ? ( + + ) : ( + + ); + const footerContent = + moduleStatus.type === 'required' ? ( + + + + ) : ( + + ); + + return ( + {footerContent}
} + icon={icon} + title={moduleName} + /> + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx new file mode 100644 index 0000000000000..8e00254431438 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { LogEntryRateSetupView } from './log_entry_rate_setup_view'; +import { LogEntryCategoriesSetupView } from './log_entry_categories_setup_view'; +import { LogAnalysisModuleList } from './module_list'; +import { useLogAnalysisSetupFlyoutStateContext } from './setup_flyout_state'; + +const FLYOUT_HEADING_ID = 'logAnalysisSetupFlyoutHeading'; + +export const LogAnalysisSetupFlyout: React.FC = () => { + const { + closeFlyout, + flyoutView, + showModuleList, + showModuleSetup, + } = useLogAnalysisSetupFlyoutStateContext(); + + if (flyoutView.view === 'hidden') { + return null; + } + + return ( + + + +

+ +

+
+
+ + {flyoutView.view === 'moduleList' ? ( + + ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_analysis' ? ( + + + + ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_categories' ? ( + + + + ) : null} + +
+ ); +}; + +const LogAnalysisSetupFlyoutSubPage: React.FC<{ + onViewModuleList: () => void; +}> = ({ children, onViewModuleList }) => ( + + + + + + + {children} + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts new file mode 100644 index 0000000000000..7a64584df4303 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import createContainer from 'constate'; +import { useState, useCallback } from 'react'; + +export type ModuleId = 'logs_ui_analysis' | 'logs_ui_categories'; + +type FlyoutView = + | { view: 'hidden' } + | { view: 'moduleList' } + | { view: 'moduleSetup'; module: ModuleId }; + +export const useLogAnalysisSetupFlyoutState = ({ + initialFlyoutView = { view: 'hidden' }, +}: { + initialFlyoutView?: FlyoutView; +}) => { + const [flyoutView, setFlyoutView] = useState(initialFlyoutView); + + const closeFlyout = useCallback(() => setFlyoutView({ view: 'hidden' }), []); + const showModuleList = useCallback(() => setFlyoutView({ view: 'moduleList' }), []); + const showModuleSetup = useCallback( + (module: ModuleId) => { + setFlyoutView({ view: 'moduleSetup', module }); + }, + [setFlyoutView] + ); + + return { + closeFlyout, + flyoutView, + setFlyoutView, + showModuleList, + showModuleSetup, + }; +}; + +export const [ + LogAnalysisSetupFlyoutStateProvider, + useLogAnalysisSetupFlyoutStateContext, +] = createContainer(useLogAnalysisSetupFlyoutState); diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx index adc1ce4d8c9fd..be140a810f164 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -6,7 +6,13 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { + EuiButton, + EuiIcon, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, +} from '@elastic/eui'; import { euiStyled } from '../../../../../observability/public'; import { LogEntryColumnContent } from './log_entry_column'; @@ -50,12 +56,15 @@ export const LogEntryContextMenu: React.FC = ({ const button = ( - + style={{ minWidth: 'auto' }} + > + + ); @@ -88,8 +97,5 @@ const AbsoluteWrapper = euiStyled.div` `; const ButtonWrapper = euiStyled.div` - background: ${(props) => props.theme.eui.euiColorPrimary}; - border-radius: 50%; - padding: 4px; - transform: translateY(-6px); + transform: translate(-6px, -6px); `; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx index a70758e3aefd7..79768302a7310 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx @@ -111,14 +111,6 @@ export const useLogAnalysisModule = ({ [cleanUpModule, dispatchModuleStatus, setUpModule] ); - const viewSetupForReconfiguration = useCallback(() => { - dispatchModuleStatus({ type: 'requestedJobConfigurationUpdate' }); - }, [dispatchModuleStatus]); - - const viewSetupForUpdate = useCallback(() => { - dispatchModuleStatus({ type: 'requestedJobDefinitionUpdate' }); - }, [dispatchModuleStatus]); - const viewResults = useCallback(() => { dispatchModuleStatus({ type: 'viewedResults' }); }, [dispatchModuleStatus]); @@ -143,7 +135,5 @@ export const useLogAnalysisModule = ({ setupStatus: moduleStatus.setupStatus, sourceConfiguration, viewResults, - viewSetupForReconfiguration, - viewSetupForUpdate, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx index a0046b630bfe1..84b5404fe96aa 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx @@ -43,8 +43,6 @@ type StatusReducerAction = payload: FetchJobStatusResponsePayload; } | { type: 'failedFetchingJobStatuses' } - | { type: 'requestedJobConfigurationUpdate' } - | { type: 'requestedJobDefinitionUpdate' } | { type: 'viewedResults' }; const createInitialState = ({ @@ -173,18 +171,6 @@ const createStatusReducer = (jobTypes: JobType[]) => ( ), }; } - case 'requestedJobConfigurationUpdate': { - return { - ...state, - setupStatus: { type: 'required', reason: 'reconfiguration' }, - }; - } - case 'requestedJobDefinitionUpdate': { - return { - ...state, - setupStatus: { type: 'required', reason: 'update' }, - }; - } case 'viewedResults': { return { ...state, @@ -251,7 +237,7 @@ const getSetupStatus = (everyJobStatus: Record Object.entries(everyJobStatus).reduce((setupStatus, [, jobStatus]) => { if (jobStatus === 'missing') { - return { type: 'required', reason: 'missing' }; + return { type: 'required' }; } else if (setupStatus.type === 'required' || setupStatus.type === 'succeeded') { return setupStatus; } else if (setupStatus.type === 'skipped' || isJobStatusWithResults(jobStatus)) { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts index cc9ef73019844..4930c8b478a9c 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DeleteJobsResponsePayload } from './api/ml_cleanup'; -import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; -import { GetMlModuleResponsePayload } from './api/ml_get_module'; -import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api'; import { - ValidationIndicesResponsePayload, ValidateLogEntryDatasetsResponsePayload, + ValidationIndicesResponsePayload, } from '../../../../common/http_api/log_analysis'; import { DatasetFilter } from '../../../../common/log_analysis'; +import { DeleteJobsResponsePayload } from './api/ml_cleanup'; +import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; +import { GetMlModuleResponsePayload } from './api/ml_get_module'; +import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api'; + +export { JobModelSizeStats, JobSummary } from './api/ml_get_jobs_summary_api'; export interface ModuleDescriptor { moduleId: string; + moduleName: string; + moduleDescription: string; jobTypes: JobType[]; bucketSpan: number; getJobIds: (spaceId: string, sourceId: string) => Record; @@ -46,3 +50,43 @@ export interface ModuleSourceConfiguration { spaceId: string; timestampField: string; } + +interface ManyCategoriesWarningReason { + type: 'manyCategories'; + categoriesDocumentRatio: number; +} + +interface ManyDeadCategoriesWarningReason { + type: 'manyDeadCategories'; + deadCategoriesRatio: number; +} + +interface ManyRareCategoriesWarningReason { + type: 'manyRareCategories'; + rareCategoriesRatio: number; +} + +interface NoFrequentCategoriesWarningReason { + type: 'noFrequentCategories'; +} + +interface SingleCategoryWarningReason { + type: 'singleCategory'; +} + +export type CategoryQualityWarningReason = + | ManyCategoriesWarningReason + | ManyDeadCategoriesWarningReason + | ManyRareCategoriesWarningReason + | NoFrequentCategoriesWarningReason + | SingleCategoryWarningReason; + +export type CategoryQualityWarningReasonType = CategoryQualityWarningReason['type']; + +export interface CategoryQualityWarning { + type: 'categoryQualityWarning'; + jobId: string; + reasons: CategoryQualityWarningReason[]; +} + +export type QualityWarning = CategoryQualityWarning; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts new file mode 100644 index 0000000000000..63f1025214331 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './module_descriptor'; +export * from './use_log_entry_categories_module'; +export * from './use_log_entry_categories_quality'; +export * from './use_log_entry_categories_setup'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts similarity index 77% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts index 8d9b9130f74a4..9682b3e74db3b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { bucketSpan, categoriesMessageField, @@ -12,19 +13,25 @@ import { LogEntryCategoriesJobType, logEntryCategoriesJobTypes, partitionField, -} from '../../../../common/log_analysis'; -import { - cleanUpJobsAndDatafeeds, - ModuleDescriptor, - ModuleSourceConfiguration, -} from '../../../containers/logs/log_analysis'; -import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; -import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; -import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; -import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; -import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; +} from '../../../../../../common/log_analysis'; +import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api'; +import { callGetMlModuleAPI } from '../../api/ml_get_module'; +import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../api/validate_datasets'; +import { callValidateIndicesAPI } from '../../api/validate_indices'; +import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup'; +import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types'; const moduleId = 'logs_ui_categories'; +const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryCategoriesModuleName', { + defaultMessage: 'Categorization', +}); +const moduleDescription = i18n.translate( + 'xpack.infra.logs.analysis.logEntryCategoriesModuleDescription', + { + defaultMessage: 'Use Machine Learning to automatically categorize log messages.', + } +); const getJobIds = (spaceId: string, sourceId: string) => logEntryCategoriesJobTypes.reduce( @@ -138,6 +145,8 @@ const validateSetupDatasets = async ( export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, + moduleName, + moduleDescription, jobTypes: logEntryCategoriesJobTypes, bucketSpan, getJobIds, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx similarity index 88% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx index fe832d3fe3a54..0b12d6834d522 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx @@ -6,12 +6,10 @@ import createContainer from 'constate'; import { useMemo } from 'react'; -import { - ModuleSourceConfiguration, - useLogAnalysisModule, - useLogAnalysisModuleConfiguration, - useLogAnalysisModuleDefinition, -} from '../../../containers/logs/log_analysis'; +import { useLogAnalysisModule } from '../../log_analysis_module'; +import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration'; +import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition'; +import { ModuleSourceConfiguration } from '../../log_analysis_module_types'; import { logEntryCategoriesModule } from './module_descriptor'; import { useLogEntryCategoriesQuality } from './use_log_entry_categories_quality'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts similarity index 92% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts index 51e049d576235..346281fa94e1b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts @@ -5,9 +5,12 @@ */ import { useMemo } from 'react'; - -import { JobModelSizeStats, JobSummary } from '../../../containers/logs/log_analysis'; -import { QualityWarning, CategoryQualityWarningReason } from './sections/notices/quality_warnings'; +import { + JobModelSizeStats, + JobSummary, + QualityWarning, + CategoryQualityWarningReason, +} from '../../log_analysis_module_types'; export const useLogEntryCategoriesQuality = ({ jobSummaries }: { jobSummaries: JobSummary[] }) => { const categoryQualityWarnings: QualityWarning[] = useMemo( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx similarity index 92% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx index c011230942d7c..399c30cf47e71 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAnalysisSetupState } from '../../../containers/logs/log_analysis'; +import { useAnalysisSetupState } from '../../log_analysis_setup_state'; import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; export const useLogEntryCategoriesSetup = () => { @@ -41,6 +41,7 @@ export const useLogEntryCategoriesSetup = () => { endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts new file mode 100644 index 0000000000000..7fc1e4558961a --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/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 './module_descriptor'; +export * from './use_log_entry_rate_module'; +export * from './use_log_entry_rate_setup'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts similarity index 76% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts index 6ca306f39e947..001174a2b7558 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { bucketSpan, DatasetFilter, @@ -11,19 +12,25 @@ import { LogEntryRateJobType, logEntryRateJobTypes, partitionField, -} from '../../../../common/log_analysis'; -import { - cleanUpJobsAndDatafeeds, - ModuleDescriptor, - ModuleSourceConfiguration, -} from '../../../containers/logs/log_analysis'; -import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; -import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; -import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; -import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; -import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; +} from '../../../../../../common/log_analysis'; +import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types'; +import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup'; +import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api'; +import { callGetMlModuleAPI } from '../../api/ml_get_module'; +import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../api/validate_datasets'; +import { callValidateIndicesAPI } from '../../api/validate_indices'; const moduleId = 'logs_ui_analysis'; +const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryRateModuleName', { + defaultMessage: 'Log rate', +}); +const moduleDescription = i18n.translate( + 'xpack.infra.logs.analysis.logEntryRateModuleDescription', + { + defaultMessage: 'Use Machine Learning to automatically detect anomalous log entry rates.', + } +); const getJobIds = (spaceId: string, sourceId: string) => logEntryRateJobTypes.reduce( @@ -126,6 +133,8 @@ const validateSetupDatasets = async ( export const logEntryRateModule: ModuleDescriptor = { moduleId, + moduleName, + moduleDescription, jobTypes: logEntryRateJobTypes, bucketSpan, getJobIds, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx similarity index 86% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx index 07bdb0249cd3d..f9832e2cdd7ec 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx @@ -6,12 +6,10 @@ import createContainer from 'constate'; import { useMemo } from 'react'; -import { - ModuleSourceConfiguration, - useLogAnalysisModule, - useLogAnalysisModuleConfiguration, - useLogAnalysisModuleDefinition, -} from '../../../containers/logs/log_analysis'; +import { ModuleSourceConfiguration } from '../../log_analysis_module_types'; +import { useLogAnalysisModule } from '../../log_analysis_module'; +import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration'; +import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition'; import { logEntryRateModule } from './module_descriptor'; export const useLogEntryRateModule = ({ diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx similarity index 82% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx index 3595b6bf830fc..f67ab1fef823e 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAnalysisSetupState } from '../../../containers/logs/log_analysis'; +import createContainer from 'constate'; +import { useAnalysisSetupState } from '../../log_analysis_setup_state'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; export const useLogEntryRateSetup = () => { @@ -41,6 +42,7 @@ export const useLogEntryRateSetup = () => { endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, @@ -52,3 +54,7 @@ export const useLogEntryRateSetup = () => { viewResults, }; }; + +export const [LogEntryRateSetupProvider, useLogEntryRateSetupContext] = createContainer( + useLogEntryRateSetup +); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 24c51598ad257..88bc426e9a0f7 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -53,12 +53,18 @@ describe('Metrics UI Observability Homepage Functions', () => { const { core, mockedGetStartServices } = setup(); core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE); const fetchData = createMetricsFetchData(mockedGetStartServices); - const endTime = moment(); + const endTime = moment('2020-07-02T13:25:11.629Z'); const startTime = endTime.clone().subtract(1, 'h'); const bucketSize = '300s'; const response = await fetchData({ - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), + absoluteTime: { + start: startTime.valueOf(), + end: endTime.valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize, }); expect(core.http.post).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 25b334d03c4f7..4eaf903e17608 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; -import { sum, isFinite, isNumber } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { MetricsFetchDataResponse, FetchDataParams } from '../../observability/public'; +import { isFinite, isNumber, sum } from 'lodash'; +import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public'; import { - SnapshotRequest, SnapshotMetricInput, SnapshotNode, SnapshotNodeResponse, + SnapshotRequest, } from '../common/http_api/snapshot_api'; import { SnapshotMetricType } from '../common/inventory_models/types'; import { InfraClientCoreSetup } from './types'; @@ -77,13 +75,12 @@ export const combineNodeTimeseriesBy = ( export const createMetricsFetchData = ( getStartServices: InfraClientCoreSetup['getStartServices'] -) => async ({ - startTime, - endTime, - bucketSize, -}: FetchDataParams): Promise => { +) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => { const [coreServices] = await getStartServices(); const { http } = coreServices; + + const { start, end } = absoluteTime; + const snapshotRequest: SnapshotRequest = { sourceId: 'default', metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[], @@ -91,8 +88,8 @@ export const createMetricsFetchData = ( nodeType: 'host', includeTimeseries: true, timerange: { - from: moment(startTime).valueOf(), - to: moment(endTime).valueOf(), + from: start, + to: end, interval: bucketSize, forceInterval: true, ignoreLookback: true, @@ -102,12 +99,8 @@ export const createMetricsFetchData = ( const results = await http.post('/api/metrics/snapshot', { body: JSON.stringify(snapshotRequest), }); - return { - title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', { - defaultMessage: 'Metrics', - }), - appLink: '/app/metrics', + appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`, stats: { hosts: { type: 'number', diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 26633cd190a07..2880b1b794443 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; import { @@ -17,10 +17,10 @@ import { import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; -import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; import { LogEntryCategoriesSetupFlyout } from './setup_flyout'; export const LogEntryCategoriesPageContent = () => { @@ -50,13 +50,6 @@ export const LogEntryCategoriesPageContent = () => { } }, [fetchJobStatus, hasLogAnalysisReadCapabilities]); - // Open flyout if there are no ML jobs - useEffect(() => { - if (setupStatus.type === 'required' && setupStatus.reason === 'missing') { - openFlyout(); - } - }, [setupStatus, openFlyout]); - if (isLoading || isUninitialized) { return ; } else if (hasFailedLoadingSource) { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx index cecea733b49e4..48ad156714ccf 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx @@ -5,10 +5,9 @@ */ import React from 'react'; - +import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; -import { LogEntryCategoriesModuleProvider } from './use_log_entry_categories_module'; export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 8ce582df7466e..5e602e1f63862 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -12,17 +12,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; import { useInterval } from '../../../hooks/use_interval'; -import { CategoryJobNoticesSection } from './sections/notices/notices_section'; +import { PageViewLogInContext } from '../stream/page_view_log_in_context'; import { TopCategoriesSection } from './sections/top_categories'; -import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; import { StringTimeRange, useLogEntryCategoriesResultsUrlState, } from './use_log_entry_categories_results_url_state'; -import { PageViewLogInContext } from '../stream/page_view_log_in_context'; -import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -39,9 +39,8 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { - viewSetupForReconfiguration(); - onOpenSetup(); - }, [onOpenSetup, viewSetupForReconfiguration]); - - const viewSetupFlyoutForUpdate = useCallback(() => { - viewSetupForUpdate(); - onOpenSetup(); - }, [onOpenSetup, viewSetupForUpdate]); - const hasResults = useMemo(() => topLogEntryCategories.length > 0, [ topLogEntryCategories.length, ]); @@ -210,8 +199,9 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent @@ -223,7 +213,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { +export const LogEntryRatePageContent = memo(() => { const { hasFailedLoadingSource, isLoading, @@ -38,24 +45,52 @@ export const LogEntryRatePageContent = () => { hasLogAnalysisSetupCapabilities, } = useLogAnalysisCapabilitiesContext(); - const { fetchJobStatus, setupStatus, jobStatus } = useLogEntryRateModuleContext(); + const { + fetchJobStatus: fetchLogEntryCategoriesJobStatus, + fetchModuleDefinition: fetchLogEntryCategoriesModuleDefinition, + jobStatus: logEntryCategoriesJobStatus, + setupStatus: logEntryCategoriesSetupStatus, + } = useLogEntryCategoriesModuleContext(); + const { + fetchJobStatus: fetchLogEntryRateJobStatus, + fetchModuleDefinition: fetchLogEntryRateModuleDefinition, + jobStatus: logEntryRateJobStatus, + setupStatus: logEntryRateSetupStatus, + } = useLogEntryRateModuleContext(); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - const openFlyout = useCallback(() => setIsFlyoutOpen(true), []); - const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + const { showModuleList } = useLogAnalysisSetupFlyoutStateContext(); + + const fetchAllJobStatuses = useCallback( + () => Promise.all([fetchLogEntryCategoriesJobStatus(), fetchLogEntryRateJobStatus()]), + [fetchLogEntryCategoriesJobStatus, fetchLogEntryRateJobStatus] + ); useEffect(() => { if (hasLogAnalysisReadCapabilities) { - fetchJobStatus(); + fetchAllJobStatuses(); } - }, [fetchJobStatus, hasLogAnalysisReadCapabilities]); + }, [fetchAllJobStatuses, hasLogAnalysisReadCapabilities]); - // Open flyout if there are no ML jobs useEffect(() => { - if (setupStatus.type === 'required' && setupStatus.reason === 'missing') { - openFlyout(); + if (hasLogAnalysisReadCapabilities) { + fetchLogEntryCategoriesModuleDefinition(); + } + }, [fetchLogEntryCategoriesModuleDefinition, hasLogAnalysisReadCapabilities]); + + useEffect(() => { + if (hasLogAnalysisReadCapabilities) { + fetchLogEntryRateModuleDefinition(); + } + }, [fetchLogEntryRateModuleDefinition, hasLogAnalysisReadCapabilities]); + + useInterval(() => { + if (logEntryCategoriesSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) { + fetchLogEntryCategoriesJobStatus(); + } + if (logEntryRateSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) { + fetchLogEntryRateJobStatus(); } - }, [setupStatus, openFlyout]); + }, JOB_STATUS_POLLING_INTERVAL); if (isLoading || isUninitialized) { return ; @@ -65,7 +100,10 @@ export const LogEntryRatePageContent = () => { return ; } else if (!hasLogAnalysisReadCapabilities) { return ; - } else if (setupStatus.type === 'initializing') { + } else if ( + logEntryCategoriesSetupStatus.type === 'initializing' || + logEntryRateSetupStatus.type === 'initializing' + ) { return ( { })} /> ); - } else if (setupStatus.type === 'unknown') { - return ; - } else if (isJobStatusWithResults(jobStatus['log-entry-rate'])) { + } else if ( + logEntryCategoriesSetupStatus.type === 'unknown' || + logEntryRateSetupStatus.type === 'unknown' + ) { + return ; + } else if ( + isJobStatusWithResults(logEntryCategoriesJobStatus['log-entry-categories-count']) || + isJobStatusWithResults(logEntryRateJobStatus['log-entry-rate']) + ) { return ( <> - - + + ); } else if (!hasLogAnalysisSetupCapabilities) { @@ -87,9 +131,9 @@ export const LogEntryRatePageContent = () => { } else { return ( <> - - + + ); } -}; +}); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index e91ef87bdf34a..ac11260d2075d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -5,10 +5,11 @@ */ import React from 'react'; - +import { LogAnalysisSetupFlyoutStateProvider } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; -import { LogEntryRateModuleProvider } from './use_log_entry_rate_module'; export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); @@ -21,7 +22,14 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) spaceId={spaceId} timestampField={sourceConfiguration?.configuration.fields.timestamp ?? ''} > - {children} + + {children} + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 21c3e3ec70029..fb1dc7717fed0 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -11,18 +11,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; -import { LogAnalysisJobProblemIndicator } from '../../../components/logging/log_analysis_job_status'; +import { + CategoryJobNoticesSection, + LogAnalysisJobProblemIndicator, +} from '../../../components/logging/log_analysis_job_status'; +import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useInterval } from '../../../hooks/use_interval'; import { AnomaliesResults } from './sections/anomalies'; -import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; -import { useLogEntryRateResults } from './use_log_entry_rate_results'; import { useLogEntryAnomaliesResults } from './use_log_entry_anomalies_results'; +import { useLogEntryRateResults } from './use_log_entry_rate_results'; import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; - -const JOB_STATUS_POLLING_INTERVAL = 30000; +import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -33,28 +38,29 @@ export const PAGINATION_DEFAULTS = { pageSize: 25, }; -interface LogEntryRateResultsContentProps { - onOpenSetup: () => void; -} - -export const LogEntryRateResultsContent: React.FunctionComponent = ({ - onOpenSetup, -}) => { +export const LogEntryRateResultsContent: React.FunctionComponent = () => { useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results' }); useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results', delay: 15000 }); + const { sourceId } = useLogSourceContext(); + const { - fetchJobStatus, - fetchModuleDefinition, - setupStatus, - viewSetupForReconfiguration, - viewSetupForUpdate, - hasOutdatedJobConfigurations, - hasOutdatedJobDefinitions, - hasStoppedJobs, - sourceConfiguration: { sourceId }, + hasOutdatedJobConfigurations: hasOutdatedLogEntryRateJobConfigurations, + hasOutdatedJobDefinitions: hasOutdatedLogEntryRateJobDefinitions, + hasStoppedJobs: hasStoppedLogEntryRateJobs, + moduleDescriptor: logEntryRateModuleDescriptor, + setupStatus: logEntryRateSetupStatus, } = useLogEntryRateModuleContext(); + const { + categoryQualityWarnings, + hasOutdatedJobConfigurations: hasOutdatedLogEntryCategoriesJobConfigurations, + hasOutdatedJobDefinitions: hasOutdatedLogEntryCategoriesJobDefinitions, + hasStoppedJobs: hasStoppedLogEntryCategoriesJobs, + moduleDescriptor: logEntryCategoriesModuleDescriptor, + setupStatus: logEntryCategoriesSetupStatus, + } = useLogEntryCategoriesModuleContext(); + const { timeRange: selectedTimeRange, setTimeRange: setSelectedTimeRange, @@ -75,11 +81,14 @@ export const LogEntryRateResultsContent: React.FunctionComponent([]); + const { getLogEntryRate, isLoading, logEntryRate } = useLogEntryRateResults({ sourceId, startTime: queryTimeRange.value.startTime, endTime: queryTimeRange.value.endTime, bucketDuration, + filteredDatasets: selectedDatasets, }); const { @@ -92,12 +101,15 @@ export const LogEntryRateResultsContent: React.FunctionComponent { - viewSetupForReconfiguration(); - onOpenSetup(); - }, [viewSetupForReconfiguration, onOpenSetup]); + const { showModuleList, showModuleSetup } = useLogAnalysisSetupFlyoutStateContext(); - const viewSetupFlyoutForUpdate = useCallback(() => { - viewSetupForUpdate(); - onOpenSetup(); - }, [viewSetupForUpdate, onOpenSetup]); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const hasResults = useMemo(() => (logEntryRate?.histogramBuckets?.length ?? 0) > 0, [ - logEntryRate, + const showLogEntryRateSetup = useCallback(() => showModuleSetup('logs_ui_analysis'), [ + showModuleSetup, + ]); + const showLogEntryCategoriesSetup = useCallback(() => showModuleSetup('logs_ui_categories'), [ + showModuleSetup, ]); + const hasLogRateResults = (logEntryRate?.histogramBuckets?.length ?? 0) > 0; + const hasAnomalyResults = logEntryAnomalies.length > 0; + const isFirstUse = useMemo( () => - ((setupStatus.type === 'skipped' && !!setupStatus.newlyCreated) || - setupStatus.type === 'succeeded') && - !hasResults, - [hasResults, setupStatus] + ((logEntryCategoriesSetupStatus.type === 'skipped' && + !!logEntryCategoriesSetupStatus.newlyCreated) || + logEntryCategoriesSetupStatus.type === 'succeeded' || + (logEntryRateSetupStatus.type === 'skipped' && !!logEntryRateSetupStatus.newlyCreated) || + logEntryRateSetupStatus.type === 'succeeded') && + !(hasLogRateResults || hasAnomalyResults), + [hasAnomalyResults, hasLogRateResults, logEntryCategoriesSetupStatus, logEntryRateSetupStatus] ); useEffect(() => { getLogEntryRate(); - }, [getLogEntryRate, queryTimeRange.lastChangedTime]); - - useEffect(() => { - fetchModuleDefinition(); - }, [fetchModuleDefinition]); - - useInterval(() => { - fetchJobStatus(); - }, JOB_STATUS_POLLING_INTERVAL); + }, [getLogEntryRate, selectedDatasets, queryTimeRange.lastChangedTime]); useInterval( () => { @@ -194,7 +198,15 @@ export const LogEntryRateResultsContent: React.FunctionComponent - + + + + + @@ -222,7 +245,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent void; timeRange: TimeRange; - viewSetupForReconfiguration: () => void; + onViewModuleList: () => void; page: Page; fetchNextPage?: FetchNextPage; fetchPreviousPage?: FetchPreviousPage; @@ -54,7 +54,7 @@ export const AnomaliesResults: React.FunctionComponent<{ logEntryRateResults, setTimeRange, timeRange, - viewSetupForReconfiguration, + onViewModuleList, anomalies, changeSortOptions, sortOptions, @@ -93,7 +93,7 @@ export const AnomaliesResults: React.FunctionComponent<{ - + diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts index d4a0eaae43ac0..21696df566ed9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts @@ -18,7 +18,8 @@ export const callGetLogEntryAnomaliesAPI = async ( startTime: number, endTime: number, sort: Sort, - pagination: Pagination + pagination: Pagination, + datasets?: string[] ) => { const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, { method: 'POST', @@ -32,6 +33,7 @@ export const callGetLogEntryAnomaliesAPI = async ( }, sort, pagination, + datasets, }, }) ), diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies_datasets.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies_datasets.ts new file mode 100644 index 0000000000000..24be5a646d103 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies_datasets.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 { npStart } from '../../../../legacy_singletons'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; +import { + getLogEntryAnomaliesDatasetsRequestPayloadRT, + getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_DATASETS_PATH, +} from '../../../../../common/http_api/log_analysis'; + +export const callGetLogEntryAnomaliesDatasetsAPI = async ( + sourceId: string, + startTime: number, + endTime: number +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_DATASETS_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryAnomaliesDatasetsRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + }); + + return decodeOrThrow(getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate.ts index 794139385f467..77111d279309d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate.ts @@ -19,7 +19,8 @@ export const callGetLogEntryRateAPI = async ( sourceId: string, startTime: number, endTime: number, - bucketDuration: number + bucketDuration: number, + datasets?: string[] ) => { const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_PATH, { method: 'POST', @@ -32,6 +33,7 @@ export const callGetLogEntryRateAPI = async ( endTime, }, bucketDuration, + datasets, }, }) ), diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts index cadb4c420c133..52632e54390a9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts @@ -5,11 +5,17 @@ */ import { useMemo, useState, useCallback, useEffect, useReducer } from 'react'; - -import { LogEntryAnomaly } from '../../../../common/http_api'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { useMount } from 'react-use'; +import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies'; -import { Sort, Pagination, PaginationCursor } from '../../../../common/http_api/log_analysis'; +import { callGetLogEntryAnomaliesDatasetsAPI } from './service_calls/get_log_entry_anomalies_datasets'; +import { + Sort, + Pagination, + PaginationCursor, + GetLogEntryAnomaliesDatasetsSuccessResponsePayload, + LogEntryAnomaly, +} from '../../../../common/http_api/log_analysis'; export type SortOptions = Sort; export type PaginationOptions = Pick; @@ -19,6 +25,7 @@ export type FetchPreviousPage = () => void; export type ChangeSortOptions = (sortOptions: Sort) => void; export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void; export type LogEntryAnomalies = LogEntryAnomaly[]; +type LogEntryAnomaliesDatasets = GetLogEntryAnomaliesDatasetsSuccessResponsePayload['data']['datasets']; interface PaginationCursors { previousPageCursor: PaginationCursor; nextPageCursor: PaginationCursor; @@ -35,6 +42,7 @@ interface ReducerState { start: number; end: number; }; + filteredDatasets?: string[]; } type ReducerStateDefaults = Pick< @@ -49,7 +57,8 @@ type ReducerAction = | { type: 'fetchPreviousPage' } | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } } | { type: 'changeLastReceivedCursors'; payload: { lastReceivedCursors: PaginationCursors } } - | { type: 'changeTimeRange'; payload: { timeRange: { start: number; end: number } } }; + | { type: 'changeTimeRange'; payload: { timeRange: { start: number; end: number } } } + | { type: 'changeFilteredDatasets'; payload: { filteredDatasets?: string[] } }; const stateReducer = (state: ReducerState, action: ReducerAction): ReducerState => { const resetPagination = { @@ -101,6 +110,12 @@ const stateReducer = (state: ReducerState, action: ReducerAction): ReducerState ...resetPagination, ...action.payload, }; + case 'changeFilteredDatasets': + return { + ...state, + ...resetPagination, + ...action.payload, + }; default: return state; } @@ -122,18 +137,23 @@ export const useLogEntryAnomaliesResults = ({ sourceId, defaultSortOptions, defaultPaginationOptions, + onGetLogEntryAnomaliesDatasetsError, + filteredDatasets, }: { endTime: number; startTime: number; sourceId: string; defaultSortOptions: Sort; defaultPaginationOptions: Pick; + onGetLogEntryAnomaliesDatasetsError?: (error: Error) => void; + filteredDatasets?: string[]; }) => { const initStateReducer = (stateDefaults: ReducerStateDefaults): ReducerState => { return { ...stateDefaults, paginationOptions: defaultPaginationOptions, sortOptions: defaultSortOptions, + filteredDatasets, timeRange: { start: startTime, end: endTime, @@ -154,6 +174,7 @@ export const useLogEntryAnomaliesResults = ({ sortOptions, paginationOptions, paginationCursor, + filteredDatasets: queryFilteredDatasets, } = reducerState; return await callGetLogEntryAnomaliesAPI( sourceId, @@ -163,7 +184,8 @@ export const useLogEntryAnomaliesResults = ({ { ...paginationOptions, cursor: paginationCursor, - } + }, + queryFilteredDatasets ); }, onResolve: ({ data: { anomalies, paginationCursors: requestCursors, hasMoreEntries } }) => { @@ -192,6 +214,7 @@ export const useLogEntryAnomaliesResults = ({ reducerState.sortOptions, reducerState.paginationOptions, reducerState.paginationCursor, + reducerState.filteredDatasets, ] ); @@ -220,6 +243,14 @@ export const useLogEntryAnomaliesResults = ({ }); }, [startTime, endTime]); + // Selected datasets have changed + useEffect(() => { + dispatch({ + type: 'changeFilteredDatasets', + payload: { filteredDatasets }, + }); + }, [filteredDatasets]); + useEffect(() => { getLogEntryAnomalies(); }, [getLogEntryAnomalies]); @@ -246,10 +277,53 @@ export const useLogEntryAnomaliesResults = ({ [getLogEntryAnomaliesRequest.state] ); + // Anomalies datasets + const [logEntryAnomaliesDatasets, setLogEntryAnomaliesDatasets] = useState< + LogEntryAnomaliesDatasets + >([]); + + const [getLogEntryAnomaliesDatasetsRequest, getLogEntryAnomaliesDatasets] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryAnomaliesDatasetsAPI(sourceId, startTime, endTime); + }, + onResolve: ({ data: { datasets } }) => { + setLogEntryAnomaliesDatasets(datasets); + }, + onReject: (error) => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetLogEntryAnomaliesDatasetsError + ) { + onGetLogEntryAnomaliesDatasetsError(error); + } + }, + }, + [endTime, sourceId, startTime] + ); + + const isLoadingDatasets = useMemo(() => getLogEntryAnomaliesDatasetsRequest.state === 'pending', [ + getLogEntryAnomaliesDatasetsRequest.state, + ]); + + const hasFailedLoadingDatasets = useMemo( + () => getLogEntryAnomaliesDatasetsRequest.state === 'rejected', + [getLogEntryAnomaliesDatasetsRequest.state] + ); + + useMount(() => { + getLogEntryAnomaliesDatasets(); + }); + return { logEntryAnomalies, getLogEntryAnomalies, isLoadingLogEntryAnomalies, + isLoadingDatasets, + hasFailedLoadingDatasets, + datasets: logEntryAnomaliesDatasets, hasFailedLoadingLogEntryAnomalies, changeSortOptions, sortOptions: reducerState.sortOptions, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts index 1cd27c64af53f..a52dab58cb018 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_results.ts @@ -41,11 +41,13 @@ export const useLogEntryRateResults = ({ startTime, endTime, bucketDuration = 15 * 60 * 1000, + filteredDatasets, }: { sourceId: string; startTime: number; endTime: number; bucketDuration: number; + filteredDatasets?: string[]; }) => { const [logEntryRate, setLogEntryRate] = useState(null); @@ -53,7 +55,13 @@ export const useLogEntryRateResults = ({ { cancelPreviousOn: 'resolution', createPromise: async () => { - return await callGetLogEntryRateAPI(sourceId, startTime, endTime, bucketDuration); + return await callGetLogEntryRateAPI( + sourceId, + startTime, + endTime, + bucketDuration, + filteredDatasets + ); }, onResolve: ({ data }) => { setLogEntryRate({ @@ -68,7 +76,7 @@ export const useLogEntryRateResults = ({ setLogEntryRate(null); }, }, - [sourceId, startTime, endTime, bucketDuration] + [sourceId, startTime, endTime, bucketDuration, filteredDatasets] ); const isLoading = useMemo(() => getLogEntryRateRequest.state === 'pending', [ diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index c5047dbdf3bb5..426ae8e9d05a8 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -42,10 +42,10 @@ export const LogsPageContent: React.FunctionComponent = () => { pathname: '/stream', }; - const logRateTab = { + const anomaliesTab = { app: 'logs', - title: logRateTabTitle, - pathname: '/log-rate', + title: anomaliesTabTitle, + pathname: '/anomalies', }; const logCategoriesTab = { @@ -77,7 +77,7 @@ export const LogsPageContent: React.FunctionComponent = () => { - + @@ -96,10 +96,11 @@ export const LogsPageContent: React.FunctionComponent = () => { - + - + + @@ -114,8 +115,8 @@ const streamTabTitle = i18n.translate('xpack.infra.logs.index.streamTabTitle', { defaultMessage: 'Stream', }); -const logRateTabTitle = i18n.translate('xpack.infra.logs.index.logRateBetaBadgeTitle', { - defaultMessage: 'Log Rate', +const anomaliesTabTitle = i18n.translate('xpack.infra.logs.index.anomaliesTabTitle', { + defaultMessage: 'Anomalies', }); const logCategoriesTabTitle = i18n.translate('xpack.infra.logs.index.logCategoriesBetaBadgeTitle', { diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 5a0a996287959..53f7e00a3354c 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -5,18 +5,17 @@ */ import { encode } from 'rison-node'; -import { i18n } from '@kbn/i18n'; import { SearchResponse } from 'src/plugins/data/public'; -import { DEFAULT_SOURCE_ID } from '../../common/constants'; -import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; import { FetchData, - LogsFetchDataResponse, - HasData, FetchDataParams, + HasData, + LogsFetchDataResponse, } from '../../../observability/public'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; interface StatsAggregation { buckets: Array<{ key: string; doc_count: number }>; @@ -69,15 +68,11 @@ export function getLogsOverviewDataFetcher( data ); - const timeSpanInMinutes = - (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); + const timeSpanInMinutes = (params.absoluteTime.end - params.absoluteTime.start) / (1000 * 60); return { - title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { - defaultMessage: 'Logs', - }), - appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( - params.startTime + appLink: `/app/logs/stream?logPosition=(end:${encode(params.relativeTime.end)},start:${encode( + params.relativeTime.start )})`, stats: normalizeStats(stats, timeSpanInMinutes), series: normalizeSeries(series), @@ -122,8 +117,8 @@ function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { return { range: { [logParams.timestampField]: { - gt: params.startTime, - lte: params.endTime, + gt: new Date(params.absoluteTime.start).toISOString(), + lte: new Date(params.absoluteTime.end).toISOString(), format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 6596e07ebaca5..c080618f2a563 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -19,6 +19,7 @@ import { initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, initGetLogEntryAnomaliesRoute, + initGetLogEntryAnomaliesDatasetsRoute, } from './routes/log_analysis'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; import { initMetadataRoute } from './routes/metadata'; @@ -53,6 +54,7 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initGetLogEntryCategoryExamplesRoute(libs); initGetLogEntryRateRoute(libs); initGetLogEntryAnomaliesRoute(libs); + initGetLogEntryAnomaliesDatasetsRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); initSourceRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts index 100260c499673..27eaeb8eee5ac 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -29,3 +29,5 @@ export const validateIsStringElasticsearchJSONFilter = (value: string) => { return errorMessage; } }; + +export const UNGROUPED_FACTORY_KEY = '*'; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 868ea5bfbffe1..c991e482a62e5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -20,6 +20,7 @@ import { parseFilterQuery } from '../../../utils/serialized_query'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; import { InfraSourceConfiguration } from '../../sources'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean | boolean[]; @@ -129,14 +130,14 @@ const getData = async ( const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': undefined }; + return { [UNGROUPED_FACTORY_KEY]: undefined }; } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 1ef86d9e7eac4..0a3910f2c5d7c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -29,10 +29,10 @@ interface InventoryMetricThresholdParams { alertOnNoData?: boolean; } -export const createInventoryMetricThresholdExecutor = ( - libs: InfraBackendLibs, - alertId: string -) => async ({ services, params }: AlertExecutorOptions) => { +export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) => async ({ + services, + params, +}: AlertExecutorOptions) => { const { criteria, filterQuery, @@ -54,7 +54,7 @@ export const createInventoryMetricThresholdExecutor = ( const inventoryItems = Object.keys(first(results) as any); for (const item of inventoryItems) { - const alertInstance = services.alertInstanceFactory(`${item}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${item}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => result[item].shouldFire); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index d7c4165d5a870..85b38f48d9f22 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -5,8 +5,6 @@ */ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; -import uuid from 'uuid'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -43,7 +41,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], producer: 'metrics', - executor: curry(createInventoryMetricThresholdExecutor)(libs, uuid.v4()), + executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ { diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index 4f1e81e0b2c40..940afd72f6c73 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -54,19 +54,19 @@ services.alertInstanceFactory.mockImplementation((instanceId: string) => { /* * Helper functions */ -function getAlertState(instanceId: string): AlertStates { - const alert = alertInstances.get(`${instanceId}-*`); +function getAlertState(): AlertStates { + const alert = alertInstances.get('*'); if (alert) { return alert.state.alertState; } else { - throw new Error('Could not find alert instance `' + instanceId + '`'); + throw new Error('Could not find alert instance'); } } /* * Executor instance (our test subject) */ -const executor = (createLogThresholdExecutor('test', libsMock) as unknown) as (opts: { +const executor = (createLogThresholdExecutor(libsMock) as unknown) as (opts: { params: LogDocumentCountAlertParams; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; @@ -109,30 +109,30 @@ describe('Ungrouped alerts', () => { describe('Comparators trigger alerts correctly', () => { it('does not alert when counts do not reach the threshold', async () => { await callExecutor([0, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([0, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT_OR_EQ, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); }); it('alerts when counts reach the threshold', async () => { await callExecutor([2, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.LT, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([2, Comparator.LT_OR_EQ, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a2fd01f859385..85bb18e199192 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -21,8 +21,8 @@ import { InfraBackendLibs } from '../../infra_types'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InfraSource } from '../../../../common/http_api/source_api'; import { decodeOrThrow } from '../../../../common/runtime_types'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; -const UNGROUPED_FACTORY_KEY = '*'; const COMPOSITE_GROUP_SIZE = 40; const checkValueAgainstComparatorMap: { @@ -34,7 +34,7 @@ const checkValueAgainstComparatorMap: { [Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b, }; -export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLibs) => +export const createLogThresholdExecutor = (libs: InfraBackendLibs) => async function ({ services, params }: AlertExecutorOptions) { const { alertInstanceFactory, savedObjectsClient, callCluster } = services; const { sources } = libs; @@ -42,7 +42,7 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); const indexPattern = sourceConfiguration.configuration.logAlias; - const alertInstance = alertInstanceFactory(alertId); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); try { const validatedParams = decodeOrThrow(LogDocumentCountAlertParamsRT)(params); @@ -60,15 +60,13 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi processGroupByResults( await getGroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } else { processUngroupedResults( await getUngroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } } catch (e) { @@ -83,12 +81,11 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const processUngroupedResults = ( results: UngroupedSearchQueryResponse, params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; - const alertInstance = alertInstanceFactory(`${alertId}-${UNGROUPED_FACTORY_KEY}`); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); const documentCount = results.hits.total.value; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { @@ -116,8 +113,7 @@ interface ReducedGroupByResults { const processGroupByResults = ( results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; @@ -128,7 +124,7 @@ const processGroupByResults = ( }, []); groupResults.forEach((group) => { - const alertInstance = alertInstanceFactory(`${alertId}-${group.name}`); + const alertInstance = alertInstanceFactory(group.name); const documentCount = group.documentCount; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index 43c298019b632..fbbb38da53929 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { PluginSetupContract } from '../../../../../alerts/server'; @@ -71,8 +70,6 @@ export async function registerLogThresholdAlertType( ); } - const alertUUID = uuid.v4(); - alertingPlugin.registerType({ id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, name: 'Log threshold', @@ -87,7 +84,7 @@ export async function registerLogThresholdAlertType( }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: createLogThresholdExecutor(alertUUID, libs), + executor: createLogThresholdExecutor(libs), actionVariables: { context: [ { name: 'matchingDocuments', description: documentCountActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index 7f6bf9551e2c1..d862f70c47cae 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -15,6 +15,7 @@ import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; import { DOCUMENT_COUNT_I18N } from '../../common/messages'; +import { UNGROUPED_FACTORY_KEY } from '../../common/utils'; import { MetricExpressionParams, Comparator, Aggregators } from '../types'; import { getElasticsearchMetricQuery } from './metric_query'; @@ -133,21 +134,21 @@ const getMetric: ( index, }); - return { '*': getValuesFromAggregations(result.aggregations, aggType) }; + return { [UNGROUPED_FACTORY_KEY]: getValuesFromAggregations(result.aggregations, aggType) }; } catch (e) { if (timeframe) { // This code should only ever be reached when previewing the alert, not executing it const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': NaN }; // Trigger an Error state + return { [UNGROUPED_FACTORY_KEY]: NaN }; // Trigger an Error state } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 003a6c3c20e98..9a46925a51762 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -24,7 +24,7 @@ let persistAlertInstances = false; // eslint-disable-line describe('The metric threshold alert type', () => { describe('querying the entire infrastructure', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[], sourceId: string = 'default') => executor({ services, @@ -120,8 +120,8 @@ describe('The metric threshold alert type', () => { ], }, }); - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); @@ -177,20 +177,20 @@ describe('The metric threshold alert type', () => { }, }); test('sends an alert when all criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID)).toBe(undefined); expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); @@ -198,7 +198,7 @@ describe('The metric threshold alert type', () => { expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends all criteria to the action context', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); const { action } = mostRecentAction(instanceID); const reasons = action.reason.split('\n'); @@ -212,7 +212,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the count aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -238,7 +238,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p99 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -264,7 +264,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p95 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -290,7 +290,7 @@ describe('The metric threshold alert type', () => { }); }); describe("querying a metric that hasn't reported data", () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (alertOnNoData: boolean) => executor({ services, @@ -319,9 +319,10 @@ describe('The metric threshold alert type', () => { }); // describe('querying a metric that later recovers', () => { - // const instanceID = '*::test'; + // const instanceID = '*'; // const execute = (threshold: number[]) => // executor({ + // // services, // params: { // criteria: [ @@ -379,7 +380,7 @@ const mockLibs: any = { configuration: createMockStaticConfiguration({}), }; -const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: { +const executor = createMetricThresholdExecutor(mockLibs) as (opts: { params: AlertExecutorOptions['params']; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index bc1cc24f65eeb..b4754a8624fd5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -17,7 +17,7 @@ import { import { AlertStates } from './types'; import { evaluateAlert } from './lib/evaluate_alert'; -export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => +export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => async function (options: AlertExecutorOptions) { const { services, params } = options; const { criteria } = params; @@ -36,7 +36,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s // Because each alert result has the same group definitions, just grap the groups from the first one. const groups = Object.keys(first(alertResults) as any); for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${group}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${group}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 02d9ca3e5f0c9..529a1d176c437 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; @@ -107,7 +105,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: curry(createMetricThresholdExecutor)(libs, uuid.v4()), + executor: createMetricThresholdExecutor(libs), actionVariables: { context: [ { name: 'group', description: groupActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts index 0c0b0a0f19982..218281d875a46 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/common.ts @@ -4,10 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import type { MlAnomalyDetectors } from '../../types'; -import { startTracingSpan } from '../../../common/performance_tracing'; +import type { MlAnomalyDetectors, MlSystem } from '../../types'; import { NoLogAnalysisMlJobError } from './errors'; +import { + CompositeDatasetKey, + createLogEntryDatasetsQuery, + LogEntryDatasetBucket, + logEntryDatasetsResponseRT, +} from './queries/log_entry_data_sets'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { NoLogAnalysisResultsIndexError } from './errors'; +import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; + export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); const { @@ -27,3 +36,63 @@ export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: }, }; } + +const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; + +// Finds datasets related to ML job ids +export async function getLogEntryDatasets( + mlSystem: MlSystem, + startTime: number, + endTime: number, + jobIds: string[] +) { + const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); + + let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + let esSearchSpans: TracingSpan[] = []; + + while (true) { + const finalizeEsSearchSpan = startTracingSpan('fetch log entry dataset batch from ES'); + + const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( + await mlSystem.mlAnomalySearch( + createLogEntryDatasetsQuery( + jobIds, + startTime, + endTime, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ) + ); + + if (logEntryDatasetsResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml indices for jobs: ${jobIds.join(', ')}.` + ); + } + + const { + after_key: afterKey, + buckets: latestBatchBuckets, + } = logEntryDatasetsResponse.aggregations.dataset_buckets; + + logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); + + return { + data: logEntryDatasetBuckets.map((logEntryDatasetBucket) => logEntryDatasetBucket.key.dataset), + timing: { + spans: [logEntryDatasetsSpan, ...esSearchSpans], + }, + }; +} 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 12ae516564d66..950de4261bda0 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 @@ -7,15 +7,19 @@ import { RequestHandlerContext } from 'src/core/server'; import { InfraRequestHandlerContext } from '../../types'; import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; -import { fetchMlJob } from './common'; +import { fetchMlJob, getLogEntryDatasets } from './common'; import { getJobId, logEntryCategoriesJobTypes, logEntryRateJobTypes, jobCustomSettingsRT, } from '../../../common/log_analysis'; -import { Sort, Pagination } from '../../../common/http_api/log_analysis'; -import type { MlSystem } from '../../types'; +import { + Sort, + Pagination, + GetLogEntryAnomaliesRequestPayload, +} from '../../../common/http_api/log_analysis'; +import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; import { InsufficientAnomalyMlJobsConfigured, @@ -43,22 +47,13 @@ interface MappedAnomalyHit { categoryId?: string; } -export async function getLogEntryAnomalies( - context: RequestHandlerContext & { infra: Required }, +async function getCompatibleAnomaliesJobIds( + spaceId: string, sourceId: string, - startTime: number, - endTime: number, - sort: Sort, - pagination: Pagination + mlAnomalyDetectors: MlAnomalyDetectors ) { - const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); - - const logRateJobId = getJobId(context.infra.spaceId, sourceId, logEntryRateJobTypes[0]); - const logCategoriesJobId = getJobId( - context.infra.spaceId, - sourceId, - logEntryCategoriesJobTypes[0] - ); + const logRateJobId = getJobId(spaceId, sourceId, logEntryRateJobTypes[0]); + const logCategoriesJobId = getJobId(spaceId, sourceId, logEntryCategoriesJobTypes[0]); const jobIds: string[] = []; let jobSpans: TracingSpan[] = []; @@ -66,7 +61,7 @@ export async function getLogEntryAnomalies( try { const { timing: { spans }, - } = await fetchMlJob(context.infra.mlAnomalyDetectors, logRateJobId); + } = await fetchMlJob(mlAnomalyDetectors, logRateJobId); jobIds.push(logRateJobId); jobSpans = [...jobSpans, ...spans]; } catch (e) { @@ -76,13 +71,39 @@ export async function getLogEntryAnomalies( try { const { timing: { spans }, - } = await fetchMlJob(context.infra.mlAnomalyDetectors, logCategoriesJobId); + } = await fetchMlJob(mlAnomalyDetectors, logCategoriesJobId); jobIds.push(logCategoriesJobId); jobSpans = [...jobSpans, ...spans]; } catch (e) { // Job wasn't found } + return { + jobIds, + timing: { spans: jobSpans }, + }; +} + +export async function getLogEntryAnomalies( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination, + datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] +) { + const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); + + const { + jobIds, + timing: { spans: jobSpans }, + } = await getCompatibleAnomaliesJobIds( + context.infra.spaceId, + sourceId, + context.infra.mlAnomalyDetectors + ); + if (jobIds.length === 0) { throw new InsufficientAnomalyMlJobsConfigured( 'Log rate or categorisation ML jobs need to be configured to search anomalies' @@ -100,16 +121,17 @@ export async function getLogEntryAnomalies( startTime, endTime, sort, - pagination + pagination, + datasets ); const data = anomalies.map((anomaly) => { const { jobId } = anomaly; - if (jobId === logRateJobId) { - return parseLogRateAnomalyResult(anomaly, logRateJobId); + if (!anomaly.categoryId) { + return parseLogRateAnomalyResult(anomaly, jobId); } else { - return parseCategoryAnomalyResult(anomaly, logCategoriesJobId); + return parseCategoryAnomalyResult(anomaly, jobId); } }); @@ -181,7 +203,8 @@ async function fetchLogEntryAnomalies( startTime: number, endTime: number, sort: Sort, - pagination: Pagination + pagination: Pagination, + datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] ) { // We'll request 1 extra entry on top of our pageSize to determine if there are // more entries to be fetched. This avoids scenarios where the client side can't @@ -193,7 +216,7 @@ async function fetchLogEntryAnomalies( const results = decodeOrThrow(logEntryAnomaliesResponseRT)( await mlSystem.mlAnomalySearch( - createLogEntryAnomaliesQuery(jobIds, startTime, endTime, sort, expandedPagination) + createLogEntryAnomaliesQuery(jobIds, startTime, endTime, sort, expandedPagination, datasets) ) ); @@ -396,3 +419,43 @@ export async function fetchLogEntryExamples( }, }; } + +export async function getLogEntryAnomaliesDatasets( + context: { + infra: { + mlSystem: MlSystem; + mlAnomalyDetectors: MlAnomalyDetectors; + spaceId: string; + }; + }, + sourceId: string, + startTime: number, + endTime: number +) { + const { + jobIds, + timing: { spans: jobSpans }, + } = await getCompatibleAnomaliesJobIds( + context.infra.spaceId, + sourceId, + context.infra.mlAnomalyDetectors + ); + + if (jobIds.length === 0) { + throw new InsufficientAnomalyMlJobsConfigured( + 'Log rate or categorisation ML jobs need to be configured to search for anomaly datasets' + ); + } + + const { + data: datasets, + timing: { spans: datasetsSpans }, + } = await getLogEntryDatasets(context.infra.mlSystem, startTime, endTime, jobIds); + + return { + datasets, + timing: { + spans: [...jobSpans, ...datasetsSpans], + }, + }; +} 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 6d00ba56e0e66..a455a03d936a5 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 @@ -12,7 +12,7 @@ import { jobCustomSettingsRT, logEntryCategoriesJobTypes, } from '../../../common/log_analysis'; -import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; +import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; import { @@ -33,20 +33,12 @@ import { createLogEntryCategoryHistogramsQuery, logEntryCategoryHistogramsResponseRT, } from './queries/log_entry_category_histograms'; -import { - CompositeDatasetKey, - createLogEntryDatasetsQuery, - LogEntryDatasetBucket, - logEntryDatasetsResponseRT, -} from './queries/log_entry_data_sets'; import { createTopLogEntryCategoriesQuery, topLogEntryCategoriesResponseRT, } from './queries/top_log_entry_categories'; import { InfraSource } from '../sources'; -import { fetchMlJob } from './common'; - -const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; +import { fetchMlJob, getLogEntryDatasets } from './common'; export async function getTopLogEntryCategories( context: { @@ -129,61 +121,15 @@ export async function getLogEntryCategoryDatasets( startTime: number, endTime: number ) { - const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); - const logEntryCategoriesCountJobId = getJobId( context.infra.spaceId, sourceId, logEntryCategoriesJobTypes[0] ); - let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; - let afterLatestBatchKey: CompositeDatasetKey | undefined; - let esSearchSpans: TracingSpan[] = []; - - while (true) { - const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); - - const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( - await context.infra.mlSystem.mlAnomalySearch( - createLogEntryDatasetsQuery( - logEntryCategoriesCountJobId, - startTime, - endTime, - COMPOSITE_AGGREGATION_BATCH_SIZE, - afterLatestBatchKey - ) - ) - ); - - if (logEntryDatasetsResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` - ); - } - - const { - after_key: afterKey, - buckets: latestBatchBuckets, - } = logEntryDatasetsResponse.aggregations.dataset_buckets; + const jobIds = [logEntryCategoriesCountJobId]; - logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; - afterLatestBatchKey = afterKey; - esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; - - if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { - break; - } - } - - const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); - - return { - data: logEntryDatasetBuckets.map((logEntryDatasetBucket) => logEntryDatasetBucket.key.dataset), - timing: { - spans: [logEntryDatasetsSpan, ...esSearchSpans], - }, - }; + return await getLogEntryDatasets(context.infra.mlSystem, startTime, endTime, jobIds); } export async function getLogEntryCategoryExamples( diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 0323980dcd013..7bfc85ba78a0e 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -30,7 +30,8 @@ export async function getLogEntryRateBuckets( sourceId: string, startTime: number, endTime: number, - bucketDuration: number + bucketDuration: number, + datasets?: string[] ) { const logRateJobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; @@ -44,7 +45,8 @@ export async function getLogEntryRateBuckets( endTime, bucketDuration, COMPOSITE_AGGREGATION_BATCH_SIZE, - afterLatestBatchKey + afterLatestBatchKey, + datasets ) ); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts index 87394028095de..63e39ef022392 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -55,3 +55,14 @@ export const createCategoryIdFilters = (categoryIds: number[]) => [ }, }, ]; + +export const createDatasetsFilters = (datasets?: string[]) => + datasets && datasets.length > 0 + ? [ + { + terms: { + partition_field_value: datasets, + }, + }, + ] + : []; 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 fc72776ea5cac..c722544c509aa 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 @@ -11,8 +11,13 @@ import { createTimeRangeFilters, createResultTypeFilters, defaultRequestParameters, + createDatasetsFilters, } from './common'; -import { Sort, Pagination } from '../../../../common/http_api/log_analysis'; +import { + Sort, + Pagination, + GetLogEntryAnomaliesRequestPayload, +} from '../../../../common/http_api/log_analysis'; // TODO: Reassess validity of this against ML docs const TIEBREAKER_FIELD = '_doc'; @@ -28,7 +33,8 @@ export const createLogEntryAnomaliesQuery = ( startTime: number, endTime: number, sort: Sort, - pagination: Pagination + pagination: Pagination, + datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] ) => { const { field } = sort; const { pageSize } = pagination; @@ -37,6 +43,7 @@ export const createLogEntryAnomaliesQuery = ( ...createJobIdsFilters(jobIds), ...createTimeRangeFilters(startTime, endTime), ...createResultTypeFilters(['record']), + ...createDatasetsFilters(datasets), ]; const sourceFields = [ diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts index dd22bedae8b2a..7627ccd8c4996 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -7,14 +7,14 @@ import * as rt from 'io-ts'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { - createJobIdFilters, + createJobIdsFilters, createResultTypeFilters, createTimeRangeFilters, defaultRequestParameters, } from './common'; export const createLogEntryDatasetsQuery = ( - logEntryAnalysisJobId: string, + jobIds: string[], startTime: number, endTime: number, size: number, @@ -25,7 +25,7 @@ export const createLogEntryDatasetsQuery = ( query: { bool: { filter: [ - ...createJobIdFilters(logEntryAnalysisJobId), + ...createJobIdsFilters(jobIds), ...createTimeRangeFilters(startTime, endTime), ...createResultTypeFilters(['model_plot']), ], diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 8d9c586b2ef67..52edcf09cdfc2 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -10,6 +10,7 @@ import { createResultTypeFilters, createTimeRangeFilters, defaultRequestParameters, + createDatasetsFilters, } from './common'; export const createLogEntryRateQuery = ( @@ -18,7 +19,8 @@ export const createLogEntryRateQuery = ( endTime: number, bucketDuration: number, size: number, - afterKey?: CompositeTimestampPartitionKey + afterKey?: CompositeTimestampPartitionKey, + datasets?: string[] ) => ({ ...defaultRequestParameters, body: { @@ -28,6 +30,7 @@ export const createLogEntryRateQuery = ( ...createJobIdFilters(logRateJobId), ...createTimeRangeFilters(startTime, endTime), ...createResultTypeFilters(['model_plot', 'record']), + ...createDatasetsFilters(datasets), { term: { detector_index: { diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 6fa7156240508..355dde9ec7c4a 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -11,6 +11,7 @@ import { createResultTypeFilters, createTimeRangeFilters, defaultRequestParameters, + createDatasetsFilters, } from './common'; export const createTopLogEntryCategoriesQuery = ( @@ -122,17 +123,6 @@ export const createTopLogEntryCategoriesQuery = ( size: 0, }); -const createDatasetsFilters = (datasets: string[]) => - datasets.length > 0 - ? [ - { - terms: { - partition_field_value: datasets, - }, - }, - ] - : []; - const metricAggregationRT = rt.type({ value: rt.union([rt.number, rt.null]), }); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 5b9fbc2829c72..7cd6383a9b2e5 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -152,12 +152,9 @@ export class InfraServerPlugin { core.http.registerRouteHandlerContext( 'infra', (context, request): InfraRequestHandlerContext => { - const mlSystem = - context.ml && - plugins.ml?.mlSystemProvider(context.ml?.mlClient.callAsCurrentUser, request); + const mlSystem = context.ml && plugins.ml?.mlSystemProvider(context.ml?.mlClient, request); const mlAnomalyDetectors = - context.ml && - plugins.ml?.anomalyDetectorsProvider(context.ml?.mlClient.callAsCurrentUser, request); + context.ml && plugins.ml?.anomalyDetectorsProvider(context.ml?.mlClient, request); const spaceId = plugins.spaces?.spacesService.getSpaceId(request) || 'default'; return { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index cbd89db97236f..a01042616a872 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -10,3 +10,4 @@ export * from './log_entry_category_examples'; export * from './log_entry_rate'; export * from './log_entry_examples'; export * from './log_entry_anomalies'; +export * from './log_entry_anomalies_datasets'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts index f4911658ea496..d79c9b9dd2c78 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts @@ -34,6 +34,7 @@ export const initGetLogEntryAnomaliesRoute = ({ framework }: InfraBackendLibs) = timeRange: { startTime, endTime }, sort: sortParam, pagination: paginationParam, + datasets, }, } = request.body; @@ -53,7 +54,8 @@ export const initGetLogEntryAnomaliesRoute = ({ framework }: InfraBackendLibs) = startTime, endTime, sort, - pagination + pagination, + datasets ); return response.ok({ diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts new file mode 100644 index 0000000000000..d3d0862eee9aa --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts @@ -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 Boom from 'boom'; +import { + getLogEntryAnomaliesDatasetsRequestPayloadRT, + getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_DATASETS_PATH, +} from '../../../../common/http_api/log_analysis'; +import { createValidationFunction } from '../../../../common/runtime_types'; +import type { InfraBackendLibs } from '../../../lib/infra_types'; +import { + getLogEntryAnomaliesDatasets, + NoLogAnalysisResultsIndexError, +} from '../../../lib/log_analysis'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; + +export const initGetLogEntryAnomaliesDatasetsRoute = ({ framework }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_DATASETS_PATH, + validate: { + body: createValidationFunction(getLogEntryAnomaliesDatasetsRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + }, + } = request.body; + + try { + assertHasInfraMlPlugins(requestContext); + + const { datasets, timing } = await getLogEntryAnomaliesDatasets( + requestContext, + sourceId, + startTime, + endTime + ); + + return response.ok({ + body: getLogEntryAnomaliesDatasetsSuccessReponsePayloadRT.encode({ + data: { + datasets, + }, + timing, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + if (error instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message: error.message } }); + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index ae86102980c16..3b05f6ed23aae 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -27,7 +27,7 @@ export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { const { - data: { sourceId, timeRange, bucketDuration }, + data: { sourceId, timeRange, bucketDuration, datasets }, } = request.body; try { @@ -38,7 +38,8 @@ export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { sourceId, timeRange.startTime, timeRange.endTime, - bucketDuration + bucketDuration, + datasets ); return response.ok({ diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index eebafc76a5e00..a523ddeb7c499 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -2,13 +2,13 @@ ## Plugin -- The plugin is disabled by default. See the TypeScript type for the [the available plugin configuration options](https://github.com/elastic/kibana/blob/master/x-pack/plugins/ingest_manager/common/types/index.ts#L9-L27) -- Setting `xpack.ingestManager.enabled=true` enables the plugin including the EPM and Fleet features. It also adds the `PACKAGE_CONFIG_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts) -- Adding `--xpack.ingestManager.epm.enabled=false` will disable the EPM API & UI +- The plugin is enabled by default. See the TypeScript type for the [the available plugin configuration options](https://github.com/elastic/kibana/blob/master/x-pack/plugins/ingest_manager/common/types/index.ts#L9-L27) +- Adding `xpack.ingestManager.enabled=false` will disable the plugin including the EPM and Fleet features. It will also remove the `PACKAGE_CONFIG_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts) - Adding `--xpack.ingestManager.fleet.enabled=false` will disable the Fleet API & UI - [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133) - [Integration tests](server/integration_tests/router.test.ts) - Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features. +- For Gold+ license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` ## Fleet Requirements diff --git a/x-pack/plugins/ingest_manager/common/mocks.ts b/x-pack/plugins/ingest_manager/common/mocks.ts index 131917af44595..e85364f2bb672 100644 --- a/x-pack/plugins/ingest_manager/common/mocks.ts +++ b/x-pack/plugins/ingest_manager/common/mocks.ts @@ -6,7 +6,7 @@ import { NewPackageConfig, PackageConfig } from './types/models/package_config'; -export const createNewPackageConfigMock = () => { +export const createNewPackageConfigMock = (): NewPackageConfig => { return { name: 'endpoint-1', description: '', @@ -20,10 +20,10 @@ export const createNewPackageConfigMock = () => { version: '0.9.0', }, inputs: [], - } as NewPackageConfig; + }; }; -export const createPackageConfigMock = () => { +export const createPackageConfigMock = (): PackageConfig => { const newPackageConfig = createNewPackageConfigMock(); return { ...newPackageConfig, @@ -37,7 +37,10 @@ export const createPackageConfigMock = () => { inputs: [ { config: {}, + enabled: true, + type: 'endpoint', + streams: [], }, ], - } as PackageConfig; + }; }; diff --git a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts index 7e03e4572f9ee..1fb6fead454ef 100644 --- a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts +++ b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts @@ -12,7 +12,7 @@ const CONFIG_KEYS_ORDER = [ 'revision', 'type', 'outputs', - 'settings', + 'agent', 'inputs', 'enabled', 'use_output', diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index ff08b8a925204..0fce5cfa6226f 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -8,10 +8,7 @@ export * from './rest_spec'; export interface IngestManagerConfigType { enabled: boolean; - epm: { - enabled: boolean; - registryUrl?: string; - }; + registryUrl?: string; fleet: { enabled: boolean; tlsCheckDisabled: boolean; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index a6040742e45fc..00ba51fc1843a 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -62,7 +62,7 @@ export interface FullAgentConfig { }; inputs: FullAgentConfigInput[]; revision?: number; - settings?: { + agent?: { monitoring: { use_output?: string; enabled: boolean; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx index 1e7a14e350229..03c70f71529c9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx @@ -38,50 +38,34 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => {

- - - - ), - forumLink: ( - - - - ), - }} - /> -

-

+ docsLink: ( + + + + ), + forumLink: ( + - + ), }} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx index f43419fc52ef0..ca4dfcb685e7b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx @@ -28,17 +28,20 @@ export const AlphaMessaging: React.FC<{}> = () => { {' – '} {' '} setIsAlphaFlyoutOpen(true)}> - View more details. +

diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 94d3379f35e05..0eaf785405590 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -59,7 +59,7 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => ( const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>( ({ history, ...rest }) => { - const { epm, fleet } = useConfig(); + const { fleet } = useConfig(); const { notifications } = useCore(); const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); @@ -186,11 +186,11 @@ const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basep - + - + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx index 1f356301b714a..09da96fac4462 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx @@ -41,7 +41,7 @@ export const DefaultLayout: React.FunctionComponent = ({ children, }) => { const { getHref } = useLink(); - const { epm, fleet } = useConfig(); + const { fleet } = useConfig(); const { uiSettings } = useCore(); const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false); @@ -71,11 +71,7 @@ export const DefaultLayout: React.FunctionComponent = ({ defaultMessage="Overview" /> - + ) : null} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index 795c46ec282c5..37fce340da6ea 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useState } from 'react'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,16 +18,24 @@ import { EuiButtonEmpty, EuiButton, EuiText, + EuiFlyoutProps, } from '@elastic/eui'; import { NewAgentConfig, AgentConfig } from '../../../../types'; import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks'; import { AgentConfigForm, agentConfigFormValidation } from '../../components'; -interface Props { +const FlyoutWithHigherZIndex = styled(EuiFlyout)` + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +interface Props extends EuiFlyoutProps { onClose: (createdAgentConfig?: AgentConfig) => void; } -export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => { +export const CreateAgentConfigFlyout: React.FunctionComponent = ({ + onClose, + ...restOfProps +}) => { const { notifications } = useCore(); const hasWriteCapabilites = useCapabilities().write; const [agentConfig, setAgentConfig] = useState({ @@ -147,10 +156,10 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos ); return ( - + {header} {body} {footer} - + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx index cb0664143bb34..f15b7d7f182a8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx @@ -7,16 +7,15 @@ import React from 'react'; import { HashRouter as Router, Switch, Route } from 'react-router-dom'; import { PAGE_ROUTING_PATHS } from '../../constants'; -import { useConfig, useBreadcrumbs } from '../../hooks'; +import { useBreadcrumbs } from '../../hooks'; import { CreatePackageConfigPage } from '../agent_config/create_package_config_page'; import { EPMHomePage } from './screens/home'; import { Detail } from './screens/detail'; export const EPMApp: React.FunctionComponent = () => { useBreadcrumbs('integrations'); - const { epm } = useConfig(); - return epm.enabled ? ( + return ( @@ -30,5 +29,5 @@ export const EPMApp: React.FunctionComponent = () => { - ) : null; + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index 15086879ce80b..ae9b1e1f6f433 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -86,7 +86,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { >
diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx index 6f53a237187e5..09b00240dc127 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -13,7 +13,7 @@ import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks'; import { AgentConfigPackageBadges } from '../agent_config_package_badges'; type Props = { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; onConfigChange?: (key: string) => void; } & ( | { @@ -37,9 +37,16 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { const [selectedState, setSelectedState] = useState<{ agentConfigId?: string; enrollmentAPIKeyId?: string; - }>({ - agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, - }); + }>({}); + + useEffect(() => { + if (agentConfigs && agentConfigs.length && !selectedState.agentConfigId) { + setSelectedState({ + ...selectedState, + agentConfigId: agentConfigs[0].id, + }); + } + }, [agentConfigs, selectedState]); useEffect(() => { if (onConfigChange && selectedState.agentConfigId) { @@ -110,7 +117,8 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { /> } - options={agentConfigs.map((config) => ({ + isLoading={!agentConfigs} + options={(agentConfigs || []).map((config) => ({ value: config.id, text: config.name, }))} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx index 5a9d3b7efe1bb..2c66001cc8c08 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -24,12 +24,12 @@ import { StandaloneInstructions } from './standalone_instructions'; interface Props { onClose: () => void; - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, - agentConfigs = [], + agentConfigs, }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index aabbd37e809a8..eefb7f1bb7b5f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -21,10 +21,10 @@ import { ManualInstructions } from '../../../../components/enrollment_instructio import { DownloadStep, AgentConfigSelectionStep } from './steps'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } -export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs }) => { const { getHref } = useLink(); const core = useCore(); const fleetStatus = useFleetStatus(); @@ -85,7 +85,7 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentConfi }} /> - )}{' '} + )} ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index 27f64059deb84..d5f79563f33c4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -25,12 +25,12 @@ import { DownloadStep, AgentConfigSelectionStep } from './steps'; import { configToYaml, agentConfigRouteService } from '../../../../services'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } const RUN_INSTRUCTIONS = './elastic-agent run'; -export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs }) => { const core = useCore(); const { notifications } = core; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx index 267f9027a094a..d01e207169920 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx @@ -46,7 +46,7 @@ export const AgentConfigSelectionStep = ({ setSelectedAPIKeyId, setSelectedConfigId, }: { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; setSelectedAPIKeyId?: (key: string) => void; setSelectedConfigId?: (configId: string) => void; }) => { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx index 6e61a55466e87..7e33589bffea1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx @@ -5,13 +5,13 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiFlexItem, } from '@elastic/eui'; import { OverviewPanel } from './overview_panel'; import { OverviewStats } from './overview_stats'; @@ -24,23 +24,19 @@ export const OverviewAgentSection = () => { return ( - -
- -

- -

-
- - - -
+ {agentStatusRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx index 5a5e901d629b5..56aaba1d43321 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -30,23 +30,18 @@ export const OverviewConfigurationSection: React.FC<{ agentConfigs: AgentConfig[ return ( - -
- -

- -

-
- - - -
+ {packageConfigsRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx index eab6cf087e127..41c011de2da5c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -45,23 +45,18 @@ export const OverviewDatastreamSection: React.FC = () => { return ( - -
- -

- -

-
- - - -
+ {datastreamRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx index b4669b0a0569b..ba16b47e73051 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -31,23 +31,19 @@ export const OverviewIntegrationSection: React.FC = () => { )?.length ?? 0; return ( - -
- -

- -

-
- - - -
+ {packagesRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx index 2e75d1e4690d6..65811261a6d6b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; import styled from 'styled-components'; -import { EuiPanel } from '@elastic/eui'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiIconTip, + EuiButtonEmpty, +} from '@elastic/eui'; -export const OverviewPanel = styled(EuiPanel).attrs((props) => ({ +const StyledPanel = styled(EuiPanel).attrs((props) => ({ paddingSize: 'm', }))` header { @@ -26,3 +34,40 @@ export const OverviewPanel = styled(EuiPanel).attrs((props) => ({ padding: ${(props) => props.theme.eui.paddingSizes.xs} 0; } `; + +interface OverviewPanelProps { + title: string; + tooltip: string; + linkToText: string; + linkTo: string; + children: React.ReactNode; +} + +export const OverviewPanel = ({ + title, + tooltip, + linkToText, + linkTo, + children, +}: OverviewPanelProps) => { + return ( + +
+ + + +

{title}

+
+
+ + + +
+ + {linkToText} + +
+ {children} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx index ca4151fa5c46f..f4b68f0c5107e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useState } from 'react'; -import styled from 'styled-components'; import { EuiButton, EuiBetaBadge, EuiText, + EuiTitle, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, @@ -23,11 +23,6 @@ import { OverviewConfigurationSection } from './components/configuration_section import { OverviewIntegrationSection } from './components/integration_section'; import { OverviewDatastreamSection } from './components/datastream_section'; -const AlphaBadge = styled(EuiBetaBadge)` - vertical-align: top; - margin-left: ${(props) => props.theme.eui.paddingSizes.s}; -`; - export const IngestManagerOverview: React.FunctionComponent = () => { useBreadcrumbs('overview'); @@ -46,26 +41,30 @@ export const IngestManagerOverview: React.FunctionComponent = () => { leftColumn={ - -

- - + + +

+ +

+
+
+ + + -

-
+
+
@@ -102,9 +101,7 @@ export const IngestManagerOverview: React.FunctionComponent = () => { - - diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index 172ad2df210c3..670e75f7a241b 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -39,7 +39,7 @@ export interface IngestManagerSetup {} */ export interface IngestManagerStart { registerPackageConfigComponent: typeof registerPackageConfigComponent; - success: Promise; + isInitialized: () => Promise; } export interface IngestManagerSetupDeps { @@ -100,27 +100,32 @@ export class IngestManagerPlugin } public async start(core: CoreStart): Promise { - let successPromise: IngestManagerStart['success']; - try { - const permissionsResponse = await core.http.get( - appRoutesService.getCheckPermissionsPath() - ); - - if (permissionsResponse?.success) { - successPromise = core.http - .post(setupRouteService.getSetupPath()) - .then(({ isInitialized }) => - isInitialized ? Promise.resolve(true) : Promise.reject(new Error('Unknown setup error')) - ); - } else { - throw new Error(permissionsResponse?.error || 'Unknown permissions error'); - } - } catch (error) { - successPromise = Promise.reject(error); - } + let successPromise: ReturnType; return { - success: successPromise, + isInitialized: () => { + if (!successPromise) { + successPromise = Promise.resolve().then(async () => { + const permissionsResponse = await core.http.get( + appRoutesService.getCheckPermissionsPath() + ); + + if (permissionsResponse?.success) { + return core.http + .post(setupRouteService.getSetupPath()) + .then(({ isInitialized }) => + isInitialized + ? Promise.resolve(true) + : Promise.reject(new Error('Unknown setup error')) + ); + } else { + throw new Error(permissionsResponse?.error || 'Unknown permissions error'); + } + }); + } + + return successPromise; + }, registerPackageConfigComponent, }; } diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 811ec8a3d0222..16c0b6449d1e8 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -20,11 +20,8 @@ export const config = { fleet: true, }, schema: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - epm: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - registryUrl: schema.maybe(schema.uri()), - }), + enabled: schema.boolean({ defaultValue: true }), + registryUrl: schema.maybe(schema.uri()), fleet: schema.object({ enabled: schema.boolean({ defaultValue: true }), tlsCheckDisabled: schema.boolean({ defaultValue: false }), diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index d1adbd8b2f65d..e32533dc907b9 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -215,12 +215,9 @@ export class IngestManagerPlugin registerOutputRoutes(router); registerSettingsRoutes(router); registerDataStreamRoutes(router); + registerEPMRoutes(router); // Conditional config routes - if (config.epm.enabled) { - registerEPMRoutes(router); - } - if (config.fleet.enabled) { const isESOUsingEphemeralEncryptionKey = deps.encryptedSavedObjects.usingEphemeralEncryptionKey; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 2aaf889296bd6..718aca89ea4fd 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -283,7 +283,7 @@ export const downloadFullAgentConfig: RequestHandler< const body = configToYaml(fullAgentConfig); const headers: ResponseHeaders = { 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-config-${fullAgentConfig.id}.yml"`, + 'content-disposition': `attachment; filename="elastic-agent.yml"`, }; return response.ok({ body, diff --git a/x-pack/plugins/ingest_manager/server/routes/app/index.ts b/x-pack/plugins/ingest_manager/server/routes/app/index.ts index 9d666efc7e9ce..ce2bf6fcdaf17 100644 --- a/x-pack/plugins/ingest_manager/server/routes/app/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/app/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { IRouter, RequestHandler } from 'src/core/server'; -import { PLUGIN_ID, APP_API_ROUTES } from '../../constants'; +import { APP_API_ROUTES } from '../../constants'; import { appContextService } from '../../services'; import { CheckPermissionsResponse } from '../../../common'; @@ -37,7 +37,7 @@ export const registerRoutes = (router: IRouter) => { { path: APP_API_ROUTES.CHECK_PERMISSIONS_PATTERN, validate: {}, - options: { tags: [`access:${PLUGIN_ID}-read`] }, + options: { tags: [] }, }, getCheckPermissionsHandler ); diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 6c360fdeda460..4c58ac57a54a2 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -67,7 +67,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { last_checkin_status: { type: 'keyword' }, config_revision: { type: 'integer' }, default_api_key_id: { type: 'keyword' }, - default_api_key: { type: 'binary', index: false }, + default_api_key: { type: 'binary' }, updated_at: { type: 'date' }, current_error_events: { type: 'text', index: false }, packages: { type: 'keyword' }, @@ -85,7 +85,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { agent_id: { type: 'keyword' }, type: { type: 'keyword' }, - data: { type: 'binary', index: false }, + data: { type: 'binary' }, sent_at: { type: 'date' }, created_at: { type: 'date' }, }, @@ -146,7 +146,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { name: { type: 'keyword' }, type: { type: 'keyword' }, - api_key: { type: 'binary', index: false }, + api_key: { type: 'binary' }, api_key_id: { type: 'keyword' }, config_id: { type: 'keyword' }, created_at: { type: 'date' }, @@ -170,8 +170,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { is_default: { type: 'boolean' }, hosts: { type: 'keyword' }, ca_sha256: { type: 'keyword', index: false }, - fleet_enroll_username: { type: 'binary', index: false }, - fleet_enroll_password: { type: 'binary', index: false }, + fleet_enroll_username: { type: 'binary' }, + fleet_enroll_password: { type: 'binary' }, config: { type: 'flattened' }, }, }, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts index c46e648ad088a..225251b061e58 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts @@ -61,7 +61,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { enabled: false, logs: false, @@ -90,7 +90,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, @@ -120,7 +120,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5f98c8881388d..c068b594318c1 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -417,7 +417,7 @@ class AgentConfigService { revision: config.revision, ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 ? { - settings: { + agent: { monitoring: { use_output: defaultOutput.name, enabled: true, @@ -427,7 +427,7 @@ class AgentConfigService { }, } : { - settings: { + agent: { monitoring: { enabled: false, logs: false, metrics: false }, }, }), diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts index d92d6faf8472e..47c9121808988 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts @@ -8,7 +8,7 @@ import { appContextService, licenseService } from '../../'; export const getRegistryUrl = (): string => { const license = licenseService.getLicenseInformation(); - const customUrl = appContextService.getConfig()?.epm.registryUrl; + const customUrl = appContextService.getConfig()?.registryUrl; if ( customUrl && @@ -20,5 +20,9 @@ export const getRegistryUrl = (): string => { return customUrl; } + if (customUrl) { + appContextService.getLogger().warn('Gold license is required to use a custom registry url.'); + } + return DEFAULT_REGISTRY_URL; }; diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts index f8dd1c65e3e72..e86e2608e252d 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { createPackageConfigMock } from '../../common/mocks'; import { packageConfigService } from './package_config'; -import { PackageInfo } from '../types'; +import { PackageInfo, PackageConfigSOAttributes } from '../types'; +import { SavedObjectsUpdateResponse } from 'src/core/server'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { @@ -161,4 +164,32 @@ describe('Package config service', () => { ]); }); }); + + describe('update', () => { + it('should fail to update on version conflict', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: createPackageConfigMock(), + }); + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string + ): Promise> => { + throw savedObjectsClient.errors.createConflictError('abc', '123'); + } + ); + await expect( + packageConfigService.update( + savedObjectsClient, + 'the-package-config-id', + createPackageConfigMock() + ) + ).rejects.toThrow('Saved object [abc/123] conflict'); + }); + }); }); diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index fa8c4f82c1b68..a5796c10f8d93 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,7 +6,6 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import React from 'react'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, @@ -35,10 +34,10 @@ const httpServiceSetupMock = new HttpService().setup({ fatalErrors: fatalErrorsServiceMock.createSetupContract(), }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); const appServices = { breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index 2c1ffdd31aafe..945e825c88fbd 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin } from 'src/core/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; import { Dependencies } from './types'; @@ -21,7 +20,7 @@ export class IngestPipelinesPlugin implements Plugin { uiMetricService.setup(usageCollection); apiService.setup(http, uiMetricService); - management.sections.getSection(ManagementSectionId.Ingest).registerApp({ + management.sections.section.ingest.registerApp({ id: PLUGIN_ID, order: 1, title: i18n.translate('xpack.ingestPipelines.appTitle', { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss index 261d6672df93a..a7c8e4dfc6baa 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss @@ -1,6 +1,7 @@ .lnsDataPanelWrapper { flex: 1 0 100%; overflow: hidden; + background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk); } .lnsDataPanelWrapper__switchSource { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss index 35c28595a59c0..c2e8d4f6c0049 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss @@ -22,7 +22,7 @@ // Leave out bottom padding so the suggestions scrollbar stays flush to window edge // Leave out left padding so the left sidebar's focus states are visible outside of content bounds // This also means needing to add same amount of margin to page content and suggestion items - padding: $euiSize $euiSize 0 0; + padding: $euiSize $euiSize 0; &:first-child { padding-left: $euiSize; @@ -40,9 +40,10 @@ .lnsFrameLayout__sidebar--right { @include euiScrollBar; - min-width: $lnsPanelMinWidth + $euiSize; + background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk); + min-width: $lnsPanelMinWidth + $euiSizeXL; overflow-x: hidden; overflow-y: scroll; - padding-top: $euiSize; + padding: $euiSize 0 $euiSize $euiSize; max-height: 100%; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss index 924f44a37c459..4e13fd95d1961 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss @@ -2,6 +2,10 @@ margin-bottom: $euiSizeS; } +.lnsLayerPanel__sourceFlexItem { + max-width: calc(100% - #{$euiSize * 3.625}); +} + .lnsLayerPanel__row { background: $euiColorLightestShade; padding: $euiSizeS; @@ -32,5 +36,6 @@ } .lnsLayerPanel__styleEditor { - width: $euiSize * 28; + width: $euiSize * 30; + padding: $euiSizeS; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx index cc8d97a445016..8d31e1bcc2e6a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx @@ -40,8 +40,7 @@ export function DimensionPopover({ }} button={trigger} anchorPosition="leftUp" - withTitle - panelPaddingSize="s" + panelPaddingSize="none" > {panel} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 36d5bfd965e26..e51a155a19935 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -103,7 +103,7 @@ export function LayerPanel( {layerDatasource && ( - + - - - + ), }, ]; @@ -194,7 +191,6 @@ export function LayerPanel( }), content: (
- - setIsOpen(!isOpen)} data-test-subj="lns_layer_settings" /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index ae4a7861b1d90..8a44d59ff1c0d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -5,15 +5,9 @@ } } -.lnsChartSwitch__triggerButton { - @include euiTitle('xs'); - background-color: $euiColorEmptyShade; - border-color: $euiColorLightShade; -} - .lnsChartSwitch__summaryIcon { margin-right: $euiSizeS; - transform: translateY(-2px); + transform: translateY(-1px); } // Targeting img as this won't target normal EuiIcon's only the custom svgs's diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 4c5a44ecc695e..fa87d80e5cf40 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import './chart_switch.scss'; import React, { useState, useMemo } from 'react'; import { EuiIcon, @@ -11,7 +12,6 @@ import { EuiPopoverTitle, EuiKeyPadMenu, EuiKeyPadMenuItem, - EuiButton, } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -19,6 +19,7 @@ import { Visualization, FramePublicAPI, Datasource } from '../../../types'; import { Action } from '../state_management'; import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { ToolbarButton } from '../../../toolbar_button'; interface VisualizationSelection { visualizationId: string; @@ -72,8 +73,6 @@ function VisualizationSummary(props: Props) { ); } -import './chart_switch.scss'; - export function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState(false); @@ -202,16 +201,13 @@ export function ChartSwitch(props: Props) { panelClassName="lnsChartSwitch__popoverPanel" panelPaddingSize="s" button={ - setFlyoutOpen(!flyoutOpen)} data-test-subj="lnsChartSwitchPopover" - iconSide="right" - iconType="arrowDown" - color="text" + fontWeight="bold" > - + } isOpen={flyoutOpen} closePopover={() => setFlyoutOpen(false)} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index beb6952556067..9f5b6665b31d3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -15,6 +15,7 @@ import { EuiText, EuiBetaBadge, EuiButtonEmpty, + EuiLink, } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; import { @@ -208,18 +209,20 @@ export function InnerWorkspacePanel({ />{' '}

- - - +

+ + + + + +

); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 94c0f4083dfee..5e2fe9d7bbc14 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -6,18 +6,13 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiPopover, - EuiPopoverTitle, - EuiSelectable, - EuiButtonEmptyProps, -} from '@elastic/eui'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui'; import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; import { IndexPatternRef } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; +import { ToolbarButtonProps, ToolbarButton } from '../toolbar_button'; -export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & { +export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { label: string; title?: string; }; @@ -40,29 +35,24 @@ export function ChangeIndexPattern({ const createTrigger = function () { const { label, title, ...rest } = trigger; return ( - setPopoverIsOpen(!isPopoverOpen)} + fullWidth {...rest} > {label} - + ); }; return ( <> setPopoverIsOpen(false)} - className="eui-textTruncate" - anchorClassName="eui-textTruncate" display="block" panelPaddingSize="s" ownFocus diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index 3e767502fae3b..70fb57ee79ee5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -7,13 +7,7 @@ .lnsInnerIndexPatternDataPanel__header { display: flex; align-items: center; - height: $euiSize * 3; - margin-top: -$euiSizeS; -} - -.lnsInnerIndexPatternDataPanel__triggerButton { - @include euiTitle('xs'); - line-height: $euiSizeXXL; + margin-bottom: $euiSizeS; } /** diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 91c068c2b4fab..6854452fd02a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -424,7 +424,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ label: currentIndexPattern.title, title: currentIndexPattern.title, 'data-test-subj': 'indexPattern-switch-link', - className: 'lnsInnerIndexPatternDataPanel__triggerButton', + fontWeight: 'bold', }} indexPatternId={currentIndexPatternId} indexPatternRefs={indexPatternRefs} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss index f619fa55f9ceb..b8986cea48d4e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss @@ -1,7 +1,6 @@ .lnsIndexPatternDimensionEditor { - flex-grow: 1; - line-height: 0; - overflow: hidden; + width: $euiSize * 30; + padding: $euiSizeS; } .lnsIndexPatternDimensionEditor__left, @@ -11,10 +10,7 @@ .lnsIndexPatternDimensionEditor__left { background-color: $euiPageBackgroundColor; -} - -.lnsIndexPatternDimensionEditor__right { - width: $euiSize * 20; + width: $euiSize * 8; } .lnsIndexPatternDimensionEditor__operation > button { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx index 5b84108b99dd9..2fb7382f992e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx @@ -299,25 +299,31 @@ export function PopoverEditor(props: PopoverEditorProps) {
{incompatibleSelectedOperationType && selectedColumn && ( - + <> + + + )} {incompatibleSelectedOperationType && !selectedColumn && ( - + <> + + + )} {!incompatibleSelectedOperationType && ParamEditor && ( <> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx index 1ae10e07b0c24..dac451013826e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx @@ -27,7 +27,8 @@ export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatter label: state.indexPatterns[layer.indexPatternId].title, title: state.indexPatterns[layer.indexPatternId].title, 'data-test-subj': 'lns_layerIndexPatternLabel', - size: 'xs', + size: 's', + fontWeight: 'normal', }} indexPatternId={layer.indexPatternId} indexPatternRefs={state.indexPatternRefs} diff --git a/x-pack/plugins/lens/public/toolbar_button/index.tsx b/x-pack/plugins/lens/public/toolbar_button/index.tsx new file mode 100644 index 0000000000000..ee6489726a0a7 --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/index.tsx @@ -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 { ToolbarButtonProps, ToolbarButton } from './toolbar_button'; diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss new file mode 100644 index 0000000000000..f36fdfdf02aba --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss @@ -0,0 +1,30 @@ +.lnsToolbarButton { + line-height: $euiButtonHeight; // Keeps alignment of text and chart icon + background-color: $euiColorEmptyShade; + border-color: $euiBorderColor; + + // Some toolbar buttons are just icons, but EuiButton comes with margin and min-width that need to be removed + min-width: 0; + + .lnsToolbarButton__text:empty { + margin: 0; + } + + // Toolbar buttons don't look good with centered text when fullWidth + &[class*='fullWidth'] { + text-align: left; + + .lnsToolbarButton__content { + justify-content: space-between; + } + } +} + +.lnsToolbarButton--bold { + font-weight: $euiFontWeightBold; +} + +.lnsToolbarButton--s { + box-shadow: none !important; // sass-lint:disable-line no-important + font-size: $euiFontSizeS; +} diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx new file mode 100644 index 0000000000000..0a63781818171 --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.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 './toolbar_button.scss'; +import React from 'react'; +import classNames from 'classnames'; +import { EuiButton, PropsOf, EuiButtonProps } from '@elastic/eui'; + +export type ToolbarButtonProps = PropsOf & { + /** + * Determines prominence + */ + fontWeight?: 'normal' | 'bold'; + /** + * Smaller buttons also remove extra shadow for less prominence + */ + size?: EuiButtonProps['size']; +}; + +export const ToolbarButton: React.FunctionComponent = ({ + children, + className, + fontWeight = 'normal', + size = 'm', + ...rest +}) => { + const classes = classNames( + 'lnsToolbarButton', + [`lnsToolbarButton--${fontWeight}`, `lnsToolbarButton--${size}`], + className + ); + return ( + + {children} + + ); +}; 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 84ea53fb4dc3d..d22b3ec0a44a6 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 @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import './xy_config_panel.scss'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; import { - EuiButtonEmpty, EuiButtonGroup, EuiFlexGroup, EuiFlexItem, @@ -32,8 +32,7 @@ import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; import { fittingFunctionDefinitions } from './fitting_functions'; - -import './xy_config_panel.scss'; +import { ToolbarButton } from '../toolbar_button'; type UnwrapArray = T extends Array ? P : T; @@ -101,17 +100,16 @@ export function XyToolbar(props: VisualizationToolbarProps) { { setOpen(!open); }} > {i18n.translate('xpack.lens.xyChart.settingsLabel', { defaultMessage: 'Settings' })} - + } isOpen={open} closePopover={() => { @@ -119,12 +117,9 @@ export function XyToolbar(props: VisualizationToolbarProps) { }} anchorPosition="downRight" > - ) { }) } > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; + props.setState({ ...props.state, fittingFunction: value })} - itemLayoutAlign="top" - hasDividers - /> - + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={props.state?.fittingFunction || 'None'} + onChange={(value) => props.setState({ ...props.state, fittingFunction: value })} + itemLayoutAlign="top" + hasDividers + /> +
+
@@ -183,12 +185,12 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) })} > + ); + return ( - + {colorPicker} ) : ( - + colorPicker )} ); diff --git a/x-pack/plugins/license_management/public/plugin.ts b/x-pack/plugins/license_management/public/plugin.ts index 2511337793fea..b99ea387121ee 100644 --- a/x-pack/plugins/license_management/public/plugin.ts +++ b/x-pack/plugins/license_management/public/plugin.ts @@ -7,7 +7,7 @@ import { first } from 'rxjs/operators'; import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; import { TelemetryPluginStart } from '../../../../src/plugins/telemetry/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../../plugins/licensing/public'; import { PLUGIN } from '../common/constants'; import { ClientConfigType } from './types'; @@ -50,7 +50,7 @@ export class LicenseManagementUIPlugin const { getStartServices } = coreSetup; const { management, licensing } = plugins; - management.sections.getSection(ManagementSectionId.Stack).registerApp({ + management.sections.section.stack.registerApp({ id: PLUGIN.id, title: PLUGIN.title, order: 0, diff --git a/x-pack/plugins/lists/README.md b/x-pack/plugins/lists/README.md index b6061368f6b13..dac6e8bb78fa5 100644 --- a/x-pack/plugins/lists/README.md +++ b/x-pack/plugins/lists/README.md @@ -57,7 +57,7 @@ which will: - Delete any existing exception list items you have - Delete any existing mapping, policies, and templates, you might have previously had. - Add the latest list and list item index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.lists.listIndex` and `xpack.lists.listItemIndex`. -- Posts the sample list from `./lists/new/list_ip.json` +- Posts the sample list from `./lists/new/ip_list.json` Now you can run @@ -69,7 +69,7 @@ You should see the new list created like so: ```sh { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", @@ -96,7 +96,7 @@ You should see the new list item created and attached to the above list like so: "value": "127.0.0.1", "created_at": "2020-05-28T19:15:49.790Z", "created_by": "yo", - "list_id": "list_ip", + "list_id": "ip_list", "tie_breaker_id": "a881bf2e-1e17-4592-bba8-d567cb07d234", "updated_at": "2020-05-28T19:15:49.790Z", "updated_by": "yo" @@ -195,7 +195,7 @@ You can then do find for each one like so: "cursor": "WzIwLFsiYzU3ZWZiYzQtNDk3Ny00YTMyLTk5NWYtY2ZkMjk2YmVkNTIxIl1d", "data": [ { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts b/x-pack/plugins/lists/common/index.ts similarity index 87% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts rename to x-pack/plugins/lists/common/index.ts index 41bc2aa258807..b55ca5db30a44 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts +++ b/x-pack/plugins/lists/common/index.ts @@ -3,3 +3,5 @@ * 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 './shared_exports'; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 6bb6ee05034cb..6199a5f16f109 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -273,7 +273,6 @@ export const cursorOrUndefined = t.union([cursor, t.undefined]); export type CursorOrUndefined = t.TypeOf; export const namespace_type = DefaultNamespace; -export type NamespaceType = t.TypeOf; export const operator = t.keyof({ excluded: null, included: null }); export type Operator = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index fb452ac89576d..4b7db3eee35bc 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ItemId, - NamespaceType, Tags, _Tags, _tags, @@ -23,7 +22,12 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; -import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; +import { + CreateCommentsArray, + DefaultCreateCommentsArray, + DefaultEntryArray, + NamespaceType, +} from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index a0aaa91c81427..66cca4ab9ca53 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ListId, - NamespaceType, Tags, _Tags, _tags, @@ -23,6 +22,7 @@ import { } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; import { DefaultUuid } from '../../siem_common_deps'; +import { NamespaceType } from '../types'; export const createExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts index 4c5b70d9a4073..909960c9fffc0 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts index 2577d867031f0..3bf5e7a4d0782 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts index 31eb4925eb6d6..826da972fe7a3 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts @@ -8,27 +8,26 @@ import * as t from 'io-ts'; -import { - NamespaceType, - filter, - list_id, - namespace_type, - sort_field, - sort_order, -} from '../common/schemas'; +import { sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { + DefaultNamespaceArray, + DefaultNamespaceArrayTypeDecoded, +} from '../types/default_namespace_array'; +import { NonEmptyStringArray } from '../types/non_empty_string_array'; +import { EmptyStringArray, EmptyStringArrayDecoded } from '../types/empty_string_array'; export const findExceptionListItemSchema = t.intersection([ t.exact( t.type({ - list_id, + list_id: NonEmptyStringArray, }) ), t.exact( t.partial({ - filter, // defaults to undefined if not set during decode - namespace_type, // defaults to 'single' if not set during decode + filter: EmptyStringArray, // defaults to undefined if not set during decode + namespace_type: DefaultNamespaceArray, // defaults to ['single'] if not set during decode page: StringToPositiveNumber, // defaults to undefined if not set during decode per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode @@ -37,14 +36,15 @@ export const findExceptionListItemSchema = t.intersection([ ), ]); -export type FindExceptionListItemSchemaPartial = t.TypeOf; +export type FindExceptionListItemSchemaPartial = t.OutputOf; // This type is used after a decode since some things are defaults after a decode. export type FindExceptionListItemSchemaPartialDecoded = Omit< - FindExceptionListItemSchemaPartial, - 'namespace_type' + t.TypeOf, + 'namespace_type' | 'filter' > & { - namespace_type: NamespaceType; + filter: EmptyStringArrayDecoded; + namespace_type: DefaultNamespaceArrayTypeDecoded; }; // This type is used after a decode since some things are defaults after a decode. diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts index fa00c5b0dafb1..8b9b08ed387b1 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts @@ -8,9 +8,10 @@ import * as t from 'io-ts'; -import { NamespaceType, filter, namespace_type, sort_field, sort_order } from '../common/schemas'; +import { filter, namespace_type, sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { NamespaceType } from '../types'; export const findExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts index 93a372ba383b0..d8864a6fc66e5 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts index 3947c88bf4c9c..613fb22a99d61 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 582fabdc160f9..20a63e0fc7dac 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -26,6 +25,7 @@ import { DefaultEntryArray, DefaultUpdateCommentsArray, EntriesArray, + NamespaceType, UpdateCommentsArray, } from '../types'; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index 76160c3419449..0b5f3a8a01794 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -21,6 +20,7 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const updateExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts index 8f8f8d105b624..ecc45d3c84313 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -8,23 +8,18 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; export const namespaceType = t.keyof({ agnostic: null, single: null }); - -type NamespaceType = t.TypeOf; - -export type DefaultNamespaceC = t.Type; +export type NamespaceType = t.TypeOf; /** * Types the DefaultNamespace as: * - If null or undefined, then a default string/enumeration of "single" will be used. */ -export const DefaultNamespace: DefaultNamespaceC = new t.Type< - NamespaceType, - NamespaceType, - unknown ->( +export const DefaultNamespace = new t.Type( 'DefaultNamespace', namespaceType.is, (input, context): Either => input == null ? t.success('single') : namespaceType.validate(input, context), t.identity ); + +export type DefaultNamespaceC = typeof DefaultNamespace; diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts new file mode 100644 index 0000000000000..055f93069950e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultNamespaceArray, DefaultNamespaceArrayTypeEncoded } from './default_namespace_array'; + +describe('default_namespace_array', () => { + test('it should validate "null" single item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = null; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should NOT validate a numeric value', () => { + const payload = 5; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate "undefined" item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = undefined; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should validate "single" as an array of a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "agnostic" as an array of a "agnostic" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "single,agnostic" as an array of 2 values of ["single", "agnostic"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic,single" as an array of 3 values of ["single", "agnostic", "single"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic, single" as an array of 3 values of ["single", "agnostic", "single"] values when there are spaces', () => { + const payload: DefaultNamespaceArrayTypeEncoded = ' single, agnostic, single '; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should not validate 3 elements of "single,agnostic,junk" since the 3rd value is junk', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,junk'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "junk" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts new file mode 100644 index 0000000000000..c4099a48ffbcc --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { namespaceType } from './default_namespace'; + +export const namespaceTypeArray = t.array(namespaceType); +export type NamespaceTypeArray = t.TypeOf; + +/** + * Types the DefaultNamespaceArray as: + * - If null or undefined, then a default string array of "single" will be used. + * - If it contains a string, then it is split along the commas and puts them into an array and validates it + */ +export const DefaultNamespaceArray = new t.Type< + NamespaceTypeArray, + string | undefined | null, + unknown +>( + 'DefaultNamespaceArray', + namespaceTypeArray.is, + (input, context): Either => { + if (input == null) { + return t.success(['single']); + } else if (typeof input === 'string') { + const commaSeparatedValues = input + .trim() + .split(',') + .map((value) => value.trim()); + return namespaceTypeArray.validate(commaSeparatedValues, context); + } + return t.failure(input, context); + }, + String +); + +export type DefaultNamespaceC = typeof DefaultNamespaceArray; + +export type DefaultNamespaceArrayTypeEncoded = t.OutputOf; +export type DefaultNamespaceArrayTypeDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts new file mode 100644 index 0000000000000..b14afab327fb0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { EmptyStringArray, EmptyStringArrayEncoded } from './empty_string_array'; + +describe('empty_string_array', () => { + test('it should validate "null" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = null; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate "undefined" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = undefined; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: EmptyStringArrayEncoded = 'a'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b,c'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "EmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: EmptyStringArrayEncoded = ' a, b, c '; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts new file mode 100644 index 0000000000000..389dc4a410cc9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the EmptyStringArray as: + * - A value that can be undefined, or null (which will be turned into an empty array) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: undefined -> [] + * - Example input converted to output: null -> [] + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const EmptyStringArray = new t.Type( + 'EmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (input == null) { + return t.success([]); + } else if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type EmptyStringArrayC = typeof EmptyStringArray; + +export type EmptyStringArrayEncoded = t.OutputOf; +export type EmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts new file mode 100644 index 0000000000000..6124487cdd7fb --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { NonEmptyStringArray, NonEmptyStringArrayEncoded } from './non_empty_string_array'; + +describe('non_empty_string_array', () => { + test('it should NOT validate "null"', () => { + const payload: NonEmptyStringArrayEncoded | null = null; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload: NonEmptyStringArrayEncoded | undefined = undefined; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a single value of an empty string ""', () => { + const payload: NonEmptyStringArrayEncoded = ''; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b,c'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: NonEmptyStringArrayEncoded = ' a, b, c '; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts new file mode 100644 index 0000000000000..c4a640e7cdbad --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the NonEmptyStringArray as: + * - A string that is not empty (which will be turned into an array of size 1) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const NonEmptyStringArray = new t.Type( + 'NonEmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type NonEmptyStringArrayC = typeof NonEmptyStringArray; + +export type NonEmptyStringArrayEncoded = t.OutputOf; +export type NonEmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts new file mode 100644 index 0000000000000..7bb565792969c --- /dev/null +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ListSchema, + CommentsArray, + CreateCommentsArray, + Comments, + CreateComments, + ExceptionListSchema, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntriesArray, + NamespaceType, + Operator, + OperatorEnum, + OperatorType, + OperatorTypeEnum, + ExceptionListTypeEnum, + exceptionListItemSchema, + exceptionListType, + createExceptionListItemSchema, + listSchema, + entry, + entriesNested, + entriesMatch, + entriesMatchAny, + entriesExists, + entriesList, + namespaceType, + ExceptionListType, + Type, +} from './schemas'; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts new file mode 100644 index 0000000000000..ad7c24b3db610 --- /dev/null +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + NonEmptyString, + DefaultUuid, + DefaultStringArray, + exactCheck, + getPaths, + foldLeftRight, + validate, + validateEither, + formatErrors, +} from '../../security_solution/common'; diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts index dccc548985e77..2b37e2b7bf106 100644 --- a/x-pack/plugins/lists/common/siem_common_deps.ts +++ b/x-pack/plugins/lists/common/siem_common_deps.ts @@ -4,10 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { NonEmptyString } from '../../security_solution/common/detection_engine/schemas/types/non_empty_string'; -export { DefaultUuid } from '../../security_solution/common/detection_engine/schemas/types/default_uuid'; -export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array'; -export { exactCheck } from '../../security_solution/common/exact_check'; -export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils'; -export { validate, validateEither } from '../../security_solution/common/validate'; -export { formatErrors } from '../../security_solution/common/format_errors'; +// DEPRECATED: Do not add exports to this file; please import from shared_imports instead + +export * from './shared_imports'; diff --git a/x-pack/plugins/lists/kibana.json b/x-pack/plugins/lists/kibana.json index b7aaac6d3fc76..1e25fd987552d 100644 --- a/x-pack/plugins/lists/kibana.json +++ b/x-pack/plugins/lists/kibana.json @@ -1,10 +1,12 @@ { "configPath": ["xpack", "lists"], + "extraPublicDirs": ["common"], "id": "lists", "kibanaVersion": "kibana", "requiredPlugins": [], "optionalPlugins": ["spaces", "security"], + "requiredBundles": ["securitySolution"], "server": true, - "ui": false, + "ui": true, "version": "8.0.0" } diff --git a/x-pack/plugins/lists/public/common/fp_utils.ts b/x-pack/plugins/lists/public/common/fp_utils.ts index 04e1033879476..196bfee0b501b 100644 --- a/x-pack/plugins/lists/public/common/fp_utils.ts +++ b/x-pack/plugins/lists/public/common/fp_utils.ts @@ -16,3 +16,5 @@ export const toPromise = async (taskEither: TaskEither): Promise (a) => Promise.resolve(a) ) ); + +export const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e))); diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts new file mode 100644 index 0000000000000..b8967086ef956 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { UseCursorProps, useCursor } from './use_cursor'; + +describe('useCursor', () => { + it('returns undefined cursor if no values have been set', () => { + const { result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + expect(result.current[0]).toBeUndefined(); + }); + + it('retrieves a cursor for the next page of a given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('returns undefined cursor for an unknown search', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + expect(result.current[0]).toBeUndefined(); + }); + + it('remembers cursor through rerenders', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 0, pageSize: 0 }); + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('remembers multiple cursors', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('another_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('another_cursor'); + }); + + it('returns the "nearest" cursor for the given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + act(() => { + result.current[1]('cursor1'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('cursor2'); + }); + rerender({ pageIndex: 3, pageSize: 2 }); + act(() => { + result.current[1]('cursor3'); + }); + + rerender({ pageIndex: 2, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor1'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor2'); + + rerender({ pageIndex: 4, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + + rerender({ pageIndex: 6, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts new file mode 100644 index 0000000000000..2409436ff3137 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useState } from 'react'; + +export interface UseCursorProps { + pageIndex: number; + pageSize: number; +} +type Cursor = string | undefined; +type SetCursor = (cursor: Cursor) => void; +type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor]; + +const hash = (props: UseCursorProps): string => JSON.stringify(props); + +export const useCursor: UseCursor = ({ pageIndex, pageSize }) => { + const [cache, setCache] = useState>({}); + + const setCursor = useCallback( + (cursor) => { + setCache({ + ...cache, + [hash({ pageIndex: pageIndex + 1, pageSize })]: cursor, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [pageIndex, pageSize] + ); + + let cursor: Cursor; + for (let i = pageIndex; i >= 0; i--) { + const currentProps = { pageIndex: i, pageSize }; + cursor = cache[hash(currentProps)]; + if (cursor) { + break; + } + } + + return [cursor, setCursor]; +}; diff --git a/x-pack/plugins/lists/public/index.ts b/x-pack/plugins/lists/public/index.ts new file mode 100644 index 0000000000000..2cff5af613d9a --- /dev/null +++ b/x-pack/plugins/lists/public/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './shared_exports'; + +import { PluginInitializerContext } from '../../../../src/core/public'; + +import { Plugin } from './plugin'; +import { PluginSetup, PluginStart } from './types'; + +export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context); + +export { Plugin, PluginSetup, PluginStart }; diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts index 38556e2eabc18..d79dc86802399 100644 --- a/x-pack/plugins/lists/public/lists/api.test.ts +++ b/x-pack/plugins/lists/public/lists/api.test.ts @@ -6,10 +6,19 @@ import { HttpFetchOptions } from '../../../../../src/core/public'; import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../common/schemas/response/acknowledge_schema.mock'; import { getListResponseMock } from '../../common/schemas/response/list_schema.mock'; +import { getListItemIndexExistSchemaResponseMock } from '../../common/schemas/response/list_item_index_exist_schema.mock'; import { getFoundListSchemaMock } from '../../common/schemas/response/found_list_schema.mock'; -import { deleteList, exportList, findLists, importList } from './api'; +import { + createListIndex, + deleteList, + exportList, + findLists, + importList, + readListIndex, +} from './api'; import { ApiPayload, DeleteListParams, @@ -60,7 +69,7 @@ describe('Value Lists API', () => { ...((payload as unknown) as ApiPayload), signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "23" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "23" supplied to "id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -76,7 +85,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); }); }); @@ -105,6 +114,7 @@ describe('Value Lists API', () => { it('sends pagination as query parameters', async () => { const abortCtrl = new AbortController(); await findLists({ + cursor: 'cursor', http: httpMock, pageIndex: 1, pageSize: 10, @@ -114,14 +124,21 @@ describe('Value Lists API', () => { expect(httpMock.fetch).toHaveBeenCalledWith( '/api/lists/_find', expect.objectContaining({ - query: { page: 1, per_page: 10 }, + query: { + cursor: 'cursor', + page: 1, + per_page: 10, + }, }) ); }); it('rejects with an error if request payload is invalid (and does not make API call)', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 10, pageSize: 0 }; + const payload: ApiPayload = { + pageIndex: 10, + pageSize: 0, + }; await expect( findLists({ @@ -129,13 +146,16 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "0" supplied to "per_page"'); + ).rejects.toEqual(new Error('Invalid value "0" supplied to "per_page"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); it('rejects with an error if response payload is invalid', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 1, pageSize: 10 }; + const payload: ApiPayload = { + pageIndex: 1, + pageSize: 10, + }; const badResponse = { ...getFoundListSchemaMock(), cursor: undefined }; httpMock.fetch.mockResolvedValue(badResponse); @@ -145,7 +165,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "cursor"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "cursor"')); }); }); @@ -214,7 +234,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "file"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "file"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -233,7 +253,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "other" supplied to "type"'); + ).rejects.toEqual(new Error('Invalid value "other" supplied to "type"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -254,13 +274,13 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); }); }); describe('exportList', () => { beforeEach(() => { - httpMock.fetch.mockResolvedValue(getListResponseMock()); + httpMock.fetch.mockResolvedValue({}); }); it('POSTs to the export endpoint', async () => { @@ -307,25 +327,96 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "23" supplied to "list_id"'); + ).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); + }); + + describe('readListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + }); + + it('GETs the list index', async () => { + const abortCtrl = new AbortController(); + await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); + }); it('rejects with an error if response payload is invalid', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { - listId: 'list-id', - }; - const badResponse = { ...getListResponseMock(), id: undefined }; + const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; httpMock.fetch.mockResolvedValue(badResponse); await expect( - exportList({ + readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }) + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); + }); + }); + + describe('createListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('GETs the list index', async () => { + const abortCtrl = new AbortController(); + await createListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await createListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(result).toEqual(getAcknowledgeSchemaResponseMock()); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const badResponse = { acknowledged: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + createListIndex({ http: httpMock, - ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "acknowledged"')); }); }); }); diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts index d615239f4eb01..606109f1910c4 100644 --- a/x-pack/plugins/lists/public/lists/api.ts +++ b/x-pack/plugins/lists/public/lists/api.ts @@ -9,24 +9,28 @@ import { flow } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { + AcknowledgeSchema, DeleteListSchemaEncoded, ExportListItemQuerySchemaEncoded, FindListSchemaEncoded, FoundListSchema, ImportListItemQuerySchemaEncoded, ImportListItemSchemaEncoded, + ListItemIndexExistSchema, ListSchema, + acknowledgeSchema, deleteListSchema, exportListItemQuerySchema, findListSchema, foundListSchema, importListItemQuerySchema, importListItemSchema, + listItemIndexExistSchema, listSchema, } from '../../common/schemas'; -import { LIST_ITEM_URL, LIST_URL } from '../../common/constants'; +import { LIST_INDEX, LIST_ITEM_URL, LIST_PRIVILEGES_URL, LIST_URL } from '../../common/constants'; import { validateEither } from '../../common/siem_common_deps'; -import { toPromise } from '../common/fp_utils'; +import { toError, toPromise } from '../common/fp_utils'; import { ApiParams, @@ -55,6 +59,7 @@ const findLists = async ({ }; const findListsWithValidation = async ({ + cursor, http, pageIndex, pageSize, @@ -62,11 +67,12 @@ const findListsWithValidation = async ({ }: FindListsParams): Promise => pipe( { - page: String(pageIndex), - per_page: String(pageSize), + cursor: cursor?.toString(), + page: pageIndex?.toString(), + per_page: pageSize?.toString(), }, (payload) => fromEither(validateEither(findListSchema, payload)), - chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(foundListSchema, response))), flow(toPromise) ); @@ -113,7 +119,7 @@ const importListWithValidation = async ({ map((body) => ({ ...body, ...query })) ) ), - chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); @@ -139,7 +145,7 @@ const deleteListWithValidation = async ({ pipe( { id }, (payload) => fromEither(validateEither(deleteListSchema, payload)), - chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); @@ -165,9 +171,51 @@ const exportListWithValidation = async ({ pipe( { list_id: listId }, (payload) => fromEither(validateEither(exportListItemQuerySchema, payload)), - chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)), - chain((response) => fromEither(validateEither(listSchema, response))), + chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)), flow(toPromise) ); export { exportListWithValidation as exportList }; + +const readListIndex = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_INDEX, { + method: 'GET', + signal, + }); + +const readListIndexWithValidation = async ({ + http, + signal, +}: ApiParams): Promise => + flow( + () => tryCatch(() => readListIndex({ http, signal }), toError), + chain((response) => fromEither(validateEither(listItemIndexExistSchema, response))), + flow(toPromise) + )(); + +export { readListIndexWithValidation as readListIndex }; + +// TODO add types and validation +export const readListPrivileges = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_PRIVILEGES_URL, { + method: 'GET', + signal, + }); + +const createListIndex = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_INDEX, { + method: 'POST', + signal, + }); + +const createListIndexWithValidation = async ({ + http, + signal, +}: ApiParams): Promise => + flow( + () => tryCatch(() => createListIndex({ http, signal }), toError), + chain((response) => fromEither(validateEither(acknowledgeSchema, response))), + flow(toPromise) + )(); + +export { createListIndexWithValidation as createListIndex }; diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts new file mode 100644 index 0000000000000..9f784dd8790bf --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.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 { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock'; + +import { useCreateListIndex } from './use_create_list_index'; + +jest.mock('../api'); + +describe('useCreateListIndex', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.createListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('invokes Api.createListIndex', async () => { + const { result, waitForNextUpdate } = renderHook(() => useCreateListIndex()); + act(() => { + result.current.start({ http: httpMock }); + }); + await waitForNextUpdate(); + + expect(Api.createListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock })); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts new file mode 100644 index 0000000000000..18df26c2ecfd7 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_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 { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { createListIndex } from '../api'; + +const createListIndexWithOptionalSignal = withOptionalSignal(createListIndex); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useCreateListIndex = () => useAsync(createListIndexWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts new file mode 100644 index 0000000000000..9f4e41f1cdc9e --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.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 { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock'; + +import { useReadListIndex } from './use_read_list_index'; + +jest.mock('../api'); + +describe('useReadListIndex', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.readListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('invokes Api.readListIndex', async () => { + const { result, waitForNextUpdate } = renderHook(() => useReadListIndex()); + act(() => { + result.current.start({ http: httpMock }); + }); + await waitForNextUpdate(); + + expect(Api.readListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock })); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts new file mode 100644 index 0000000000000..7d15a0b1e08c9 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_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 { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { readListIndex } from '../api'; + +const readListIndexWithOptionalSignal = withOptionalSignal(readListIndex); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useReadListIndex = () => useAsync(readListIndexWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts new file mode 100644 index 0000000000000..313f17a3bac4b --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.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 { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { readListPrivileges } from '../api'; + +const readListPrivilegesWithOptionalSignal = withOptionalSignal(readListPrivileges); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useReadListPrivileges = () => useAsync(readListPrivilegesWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/types.ts b/x-pack/plugins/lists/public/lists/types.ts index 6421ad174d4d9..95a21820536e4 100644 --- a/x-pack/plugins/lists/public/lists/types.ts +++ b/x-pack/plugins/lists/public/lists/types.ts @@ -14,6 +14,7 @@ export interface ApiParams { export type ApiPayload = Omit; export interface FindListsParams extends ApiParams { + cursor?: string | undefined; pageSize: number | undefined; pageIndex: number | undefined; } diff --git a/x-pack/plugins/lists/public/plugin.ts b/x-pack/plugins/lists/public/plugin.ts new file mode 100644 index 0000000000000..717e5d2885910 --- /dev/null +++ b/x-pack/plugins/lists/public/plugin.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. + */ + +import { + CoreSetup, + CoreStart, + Plugin as IPlugin, + PluginInitializerContext, +} from '../../../../src/core/public'; + +import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types'; + +export class Plugin implements IPlugin { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(initializerContext: PluginInitializerContext) {} // eslint-disable-line @typescript-eslint/no-useless-constructor + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public start(core: CoreStart, plugins: StartPlugins): PluginStart { + return {}; + } +} diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/shared_exports.ts similarity index 73% rename from x-pack/plugins/lists/public/index.tsx rename to x-pack/plugins/lists/public/shared_exports.ts index 72bd46d6e2ce8..57fb2f90b6404 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -5,6 +5,7 @@ */ // Exports to be shared with plugins +export { useIsMounted } from './common/hooks/use_is_mounted'; export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; @@ -12,7 +13,12 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list'; export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; +export { exportList } from './lists/api'; +export { useCursor } from './common/hooks/use_cursor'; export { useExportList } from './lists/hooks/use_export_list'; +export { useReadListIndex } from './lists/hooks/use_read_list_index'; +export { useCreateListIndex } from './lists/hooks/use_create_list_index'; +export { useReadListPrivileges } from './lists/hooks/use_read_list_privileges'; export { addExceptionListItem, updateExceptionListItem, diff --git a/x-pack/plugins/lists/public/types.ts b/x-pack/plugins/lists/public/types.ts new file mode 100644 index 0000000000000..0a9b0460614bd --- /dev/null +++ b/x-pack/plugins/lists/public/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginStart {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SetupPlugins {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StartPlugins {} diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index a6c2a18bb8c8a..a318d653450c7 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -44,26 +44,34 @@ export const findExceptionListItemRoute = (router: IRouter): void => { sort_field: sortField, sort_order: sortOrder, } = request.query; - const exceptionListItems = await exceptionLists.findExceptionListItem({ - filter, - listId, - namespaceType, - page, - perPage, - sortField, - sortOrder, - }); - if (exceptionListItems == null) { + + if (listId.length !== namespaceType.length) { return siemResponse.error({ - body: `list id: "${listId}" does not exist`, - statusCode: 404, + body: `list_id and namespace_id need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal namespace_type length: ${namespaceType.length}`, + statusCode: 400, }); - } - const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); - if (errors != null) { - return siemResponse.error({ body: errors, statusCode: 500 }); } else { - return response.ok({ body: validated ?? {} }); + const exceptionListItems = await exceptionLists.findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + sortField, + sortOrder, + }); + if (exceptionListItems == null) { + return siemResponse.error({ + body: `list id: "${listId}" does not exist`, + statusCode: 404, + }); + } + const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } } } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh index bb431800c56c3..3241bb8411916 100755 --- a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh +++ b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh @@ -7,7 +7,7 @@ set -e ./check_env_variables.sh -# Example: ./delete_all_alerts.sh +# Example: ./delete_all_exception_lists.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html curl -s -k \ -H "Content-Type: application/json" \ diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json index 520bc4ddf1e09..19027ac189a47 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json @@ -1,8 +1,8 @@ { - "list_id": "endpoint_list", + "list_id": "simple_list", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], - "type": "endpoint", + "type": "detection", "description": "This is a sample endpoint type exception", "name": "Sample Endpoint Exception List" } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index 8663be5d649e5..eede855aab199 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -1,6 +1,6 @@ { - "list_id": "endpoint_list", - "item_id": "endpoint_list_item", + "list_id": "simple_list", + "item_id": "simple_list_item", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json index 3d6253fcb58ad..e0d401eff9269 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json @@ -18,7 +18,7 @@ "field": "source.ip", "operator": "excluded", "type": "list", - "list": { "id": "list-ip", "type": "ip" } + "list": { "id": "ip_list", "type": "ip" } } ] } diff --git a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh index 5efad01e9a68e..ba8f1cd0477a1 100755 --- a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh +++ b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh @@ -21,6 +21,6 @@ pushd ${FOLDER} > /dev/null curl -s -k -OJ \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=list-ip" + -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=ip_list" popd > /dev/null diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh index e3f21da56d1b7..ff720afba4157 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh @@ -9,12 +9,23 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} NAMESPACE_TYPE=${2-single} -# Example: ./find_exception_list_items.sh {list-id} -# Example: ./find_exception_list_items.sh {list-id} single -# Example: ./find_exception_list_items.sh {list-id} agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json +# +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Querying a single list item aginst each type +# Example: ./find_exception_list_items.sh simple_list +# Example: ./find_exception_list_items.sh simple_list single +# Example: ./find_exception_list_items.sh endpoint_list agnostic +# +# Finding multiple list id's across multiple spaces +# Example: ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh index 57313275ccd0e..79e66be42e441 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} FILTER=${2:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'} NAMESPACE_TYPE=${3-single} @@ -17,13 +17,23 @@ NAMESPACE_TYPE=${3-single} # The %22 is just an encoded quote of " # Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json # -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.entries.field:actingProcess.file.signer -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.field:actingProcess.file.signe*" -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.match:Elastic*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.entries.field:actingProcess.file.signer +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*" +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# +# Example with multiplie lists, and multiple filters +# Example: ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items.sh b/x-pack/plugins/lists/server/scripts/find_list_items.sh index 9c8bfd2d5a490..d475da3db61f1 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items.sh @@ -9,11 +9,11 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} -# Example: ./find_list_items.sh list-ip 1 20 +# Example: ./find_list_items.sh ip_list 1 20 curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh index 8924012cf62cf..38cef7c98994b 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} CURSOR=${4-invalid} @@ -17,7 +17,7 @@ CURSOR=${4-invalid} # Example: # ./find_list_items.sh 1 20 | jq .cursor # Copy the cursor into the argument below like so -# ./find_list_items_with_cursor.sh list-ip 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== +# ./find_list_items_with_cursor.sh ip_list 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh index 37d80c3dd3f28..eb4b23236b7d4 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh @@ -9,13 +9,13 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${4-asc} -# Example: ./find_list_items_with_sort.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh index 27d8deb2fc95a..289f9be82f209 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh @@ -9,14 +9,14 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${5-asc} CURSOR=${6-invalid} -# Example: ./find_list_items_with_sort_cursor.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort_cursor.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/import_list_items.sh b/x-pack/plugins/lists/server/scripts/import_list_items.sh index a39409cd08267..2ef01fdeed343 100755 --- a/x-pack/plugins/lists/server/scripts/import_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/import_list_items.sh @@ -10,10 +10,10 @@ set -e ./check_env_variables.sh # Uses a defaults if no argument is specified -LIST_ID=${1:-list-ip} +LIST_ID=${1:-ip_list} FILE=${2:-./lists/files/ips.txt} -# ./import_list_items.sh list-ip ./lists/files/ips.txt +# ./import_list_items.sh ip_list ./lists/files/ips.txt curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json deleted file mode 100644 index d150cfaecc202..0000000000000 --- a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "hand_inserted_item_id", - "list_id": "list-ip", - "value": "10.4.3.11" -} diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index a731371a6ffac..1acc880c851a6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -82,5 +82,5 @@ export const createExceptionListItem = async ({ type, updated_by: user, }); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 73c52fb8b3ec9..62afda52bd79d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -21,6 +21,7 @@ import { DeleteExceptionListOptions, FindExceptionListItemOptions, FindExceptionListOptions, + FindExceptionListsItemOptions, GetExceptionListItemOptions, GetExceptionListOptions, UpdateExceptionListItemOptions, @@ -36,6 +37,7 @@ import { deleteExceptionList } from './delete_exception_list'; import { deleteExceptionListItem } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; export class ExceptionListClient { private readonly user: string; @@ -229,6 +231,28 @@ export class ExceptionListClient { }); }; + public findExceptionListsItem = async ({ + listId, + filter, + perPage, + page, + sortField, + sortOrder, + namespaceType, + }: FindExceptionListsItemOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + public findExceptionList = async ({ filter, perPage, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 3eff2c7e202e7..b3070f2d4a70d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -6,6 +6,9 @@ import { SavedObjectsClientContract } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; import { CreateCommentsArray, Description, @@ -127,6 +130,16 @@ export interface FindExceptionListItemOptions { sortOrder: SortOrderOrUndefined; } +export interface FindExceptionListsItemOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + export interface FindExceptionListOptions { namespaceType: NamespaceType; filter: FilterOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts index 1c3103ad1db7e..e997ff5f9adf1 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts @@ -7,7 +7,6 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - ExceptionListSoSchema, FilterOrUndefined, FoundExceptionListItemSchema, ListId, @@ -17,10 +16,8 @@ import { SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../common/schemas'; -import { SavedObjectType } from '../../saved_objects'; -import { getSavedObjectType, transformSavedObjectsToFoundExceptionListItem } from './utils'; -import { getExceptionList } from './get_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; interface FindExceptionListItemOptions { listId: ListId; @@ -43,43 +40,14 @@ export const findExceptionListItem = async ({ sortField, sortOrder, }: FindExceptionListItemOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType }); - const exceptionList = await getExceptionList({ - id: undefined, - listId, - namespaceType, + return findExceptionListsItem({ + filter: filter != null ? [filter] : [], + listId: [listId], + namespaceType: [namespaceType], + page, + perPage, savedObjectsClient, + sortField, + sortOrder, }); - if (exceptionList == null) { - return null; - } else { - const savedObjectsFindResponse = await savedObjectsClient.find({ - filter: getExceptionListItemFilter({ filter, listId, savedObjectType }), - page, - perPage, - sortField, - sortOrder, - type: savedObjectType, - }); - return transformSavedObjectsToFoundExceptionListItem({ - namespaceType, - savedObjectsFindResponse, - }); - } -}; - -export const getExceptionListItemFilter = ({ - filter, - listId, - savedObjectType, -}: { - listId: ListId; - filter: FilterOrUndefined; - savedObjectType: SavedObjectType; -}): string => { - if (filter == null) { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId}`; - } else { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId} AND ${filter}`; - } }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts new file mode 100644 index 0000000000000..a2fbb39103769 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LIST_ID } from '../../../common/constants.mock'; + +import { getExceptionListsItemFilter } from './find_exception_list_items'; + +describe('find_exception_list_items', () => { + describe('getExceptionListsItemFilter', () => { + test('It should create a filter with a single listId with an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id)' + ); + }); + + test('It should create a filter with a single listId with a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id) AND exception-list.attributes.name: "Sample Endpoint Exception List")' + ); + }); + + test('It should create a filter with 2 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 2 listIds and a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 3 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and a single filter for the first item', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and 3 filters for each', () => { + const filter = getExceptionListsItemFilter({ + filter: [ + 'exception-list.attributes.name: "Sample Endpoint Exception List 1"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 2"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 3"', + ], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3) AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' + ); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts new file mode 100644 index 0000000000000..47a0d809cce67 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsClientContract } from 'kibana/server'; + +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { + ExceptionListSoSchema, + FoundExceptionListItemSchema, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; +import { SavedObjectType } from '../../saved_objects'; + +import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils'; +import { getExceptionList } from './get_exception_list'; + +interface FindExceptionListItemsOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + savedObjectsClient: SavedObjectsClientContract; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export const findExceptionListsItem = async ({ + listId, + namespaceType, + savedObjectsClient, + filter, + page, + perPage, + sortField, + sortOrder, +}: FindExceptionListItemsOptions): Promise => { + const savedObjectType = getSavedObjectTypes({ namespaceType }); + const exceptionLists = ( + await Promise.all( + listId.map((singleListId, index) => { + return getExceptionList({ + id: undefined, + listId: singleListId, + namespaceType: namespaceType[index], + savedObjectsClient, + }); + }) + ) + ).filter((list) => list != null); + if (exceptionLists.length === 0) { + return null; + } else { + const savedObjectsFindResponse = await savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }), + page, + perPage, + sortField, + sortOrder, + type: savedObjectType, + }); + return transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); + } +}; + +export const getExceptionListsItemFilter = ({ + filter, + listId, + savedObjectType, +}: { + listId: NonEmptyStringArrayDecoded; + filter: EmptyStringArrayDecoded; + savedObjectType: SavedObjectType[]; +}): string => { + return listId.reduce((accum, singleListId, index) => { + const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: ${singleListId})`; + const listItemAppendWithFilter = + filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; + if (accum === '') { + return listItemAppendWithFilter; + } else { + return `${accum} OR ${listItemAppendWithFilter}`; + } + }, ''); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts index d7efdc054c48c..d68863c02148f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts @@ -35,7 +35,7 @@ export const getExceptionListItem = async ({ if (id != null) { try { const savedObject = await savedObjectsClient.get(savedObjectType, id); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return null; @@ -55,7 +55,6 @@ export const getExceptionListItem = async ({ }); if (savedObject.saved_objects[0] != null) { return transformSavedObjectToExceptionListItem({ - namespaceType, savedObject: savedObject.saved_objects[0], }); } else { diff --git a/x-pack/plugins/lists/server/services/exception_lists/index.ts b/x-pack/plugins/lists/server/services/exception_lists/index.ts index a66f00819605b..510b2c70c6c94 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/index.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './create_exception_list_item'; export * from './create_exception_list'; -export * from './delete_exception_list_item'; +export * from './create_exception_list_item'; export * from './delete_exception_list'; +export * from './delete_exception_list_item'; +export * from './delete_exception_list_items_by_list'; export * from './find_exception_list'; export * from './find_exception_list_item'; -export * from './get_exception_list_item'; +export * from './find_exception_list_items'; export * from './get_exception_list'; -export * from './update_exception_list_item'; +export * from './get_exception_list_item'; export * from './update_exception_list'; +export * from './update_exception_list_item'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index ab54647430b9b..ad1e1a3439d7c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,6 +6,7 @@ import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; import { ErrorWithStatusCode } from '../../error_with_status_code'; import { Comments, @@ -42,6 +43,28 @@ export const getSavedObjectType = ({ } }; +export const getExceptionListType = ({ + savedObjectType, +}: { + savedObjectType: string; +}): NamespaceType => { + if (savedObjectType === exceptionListAgnosticSavedObjectType) { + return 'agnostic'; + } else { + return 'single'; + } +}; + +export const getSavedObjectTypes = ({ + namespaceType, +}: { + namespaceType: NamespaceTypeArray; +}): SavedObjectType[] => { + return namespaceType.map((singleNamespaceType) => + getSavedObjectType({ namespaceType: singleNamespaceType }) + ); +}; + export const transformSavedObjectToExceptionList = ({ savedObject, namespaceType, @@ -126,10 +149,8 @@ export const transformSavedObjectUpdateToExceptionList = ({ export const transformSavedObjectToExceptionListItem = ({ savedObject, - namespaceType, }: { savedObject: SavedObject; - namespaceType: NamespaceType; }): ExceptionListItemSchema => { const dateNow = new Date().toISOString(); const { @@ -167,7 +188,7 @@ export const transformSavedObjectToExceptionListItem = ({ list_id, meta, name, - namespace_type: namespaceType, + namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), tags, tie_breaker_id, type: exceptionListItemType.is(type) ? type : 'simple', @@ -229,14 +250,12 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ export const transformSavedObjectsToFoundExceptionListItem = ({ savedObjectsFindResponse, - namespaceType, }: { savedObjectsFindResponse: SavedObjectsFindResponse; - namespaceType: NamespaceType; }): FoundExceptionListItemSchema => { return { data: savedObjectsFindResponse.saved_objects.map((savedObject) => - transformSavedObjectToExceptionListItem({ namespaceType, savedObject }) + transformSavedObjectToExceptionListItem({ savedObject }) ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, diff --git a/x-pack/plugins/logstash/public/plugin.ts b/x-pack/plugins/logstash/public/plugin.ts index ade6abdb63f43..59f92ee0a7ffc 100644 --- a/x-pack/plugins/logstash/public/plugin.ts +++ b/x-pack/plugins/logstash/public/plugin.ts @@ -14,7 +14,7 @@ import { HomePublicPluginSetup, FeatureCatalogueCategory, } from '../../../../src/plugins/home/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../licensing/public'; // @ts-ignore @@ -35,22 +35,20 @@ export class LogstashPlugin implements Plugin { map((license) => new LogstashLicenseService(license)) ); - const managementApp = plugins.management.sections - .getSection(ManagementSectionId.Ingest) - .registerApp({ - id: 'pipelines', - title: i18n.translate('xpack.logstash.managementSection.pipelinesTitle', { - defaultMessage: 'Logstash Pipelines', - }), - order: 1, - mount: async (params) => { - const [coreStart] = await core.getStartServices(); - const { renderApp } = await import('./application'); - const isMonitoringEnabled = 'monitoring' in plugins; + const managementApp = plugins.management.sections.section.ingest.registerApp({ + id: 'pipelines', + title: i18n.translate('xpack.logstash.managementSection.pipelinesTitle', { + defaultMessage: 'Logstash Pipelines', + }), + order: 1, + mount: async (params) => { + const [coreStart] = await core.getStartServices(); + const { renderApp } = await import('./application'); + const isMonitoringEnabled = 'monitoring' in plugins; - return renderApp(coreStart, params, isMonitoringEnabled, logstashLicense$); - }, - }); + return renderApp(coreStart, params, isMonitoringEnabled, logstashLicense$); + }, + }); this.licenseSubscription = logstashLicense$.subscribe((license: any) => { if (license.enableLinks) { diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index 1bd8c5401eb1d..35b33da12d384 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -5,7 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants'; +import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants'; import { MapExtent, MapQuery } from './map_descriptor'; import { Filter, TimeRange } from '../../../../../src/plugins/data/common'; @@ -26,12 +26,10 @@ type ESSearchSourceSyncMeta = { scalingType: SCALING_TYPES; topHitsSplitField: string; topHitsSize: number; - sourceType: SOURCE_TYPES.ES_SEARCH; }; type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; - sourceType: SOURCE_TYPES.ES_GEO_GRID; }; export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e422efb31cb0d..fbf45aee02125 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -21,7 +21,6 @@ "server": true, "extraPublicDirs": ["common/constants"], "requiredBundles": [ - "charts", "kibanaReact", "kibanaUtils", "savedObjects" diff --git a/x-pack/plugins/maps/public/api/index.ts b/x-pack/plugins/maps/public/api/index.ts index 8b45d31b41d44..ec5aa124fb7f9 100644 --- a/x-pack/plugins/maps/public/api/index.ts +++ b/x-pack/plugins/maps/public/api/index.ts @@ -5,3 +5,5 @@ */ export { MapsStartApi } from './start_api'; +export { createSecurityLayerDescriptors } from './create_security_layer_descriptors'; +export { registerLayerWizard, registerSource } from './register'; diff --git a/x-pack/plugins/maps/public/api/register.ts b/x-pack/plugins/maps/public/api/register.ts new file mode 100644 index 0000000000000..4846b6a198c71 --- /dev/null +++ b/x-pack/plugins/maps/public/api/register.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; +import { lazyLoadMapModules } from '../lazy_load_bundle'; + +export async function registerLayerWizard(layerWizard: LayerWizard): Promise { + const mapModules = await lazyLoadMapModules(); + return mapModules.registerLayerWizard(layerWizard); +} + +export async function registerSource(entry: SourceRegistryEntry): Promise { + const mapModules = await lazyLoadMapModules(); + return mapModules.registerSource(entry); +} diff --git a/x-pack/plugins/maps/public/api/start_api.ts b/x-pack/plugins/maps/public/api/start_api.ts index d45b0df63c839..32db3bc771a3b 100644 --- a/x-pack/plugins/maps/public/api/start_api.ts +++ b/x-pack/plugins/maps/public/api/start_api.ts @@ -5,10 +5,14 @@ */ import { LayerDescriptor } from '../../common/descriptor_types'; +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; export interface MapsStartApi { createSecurityLayerDescriptors: ( indexPatternId: string, indexPatternTitle: string ) => Promise; + registerLayerWizard(layerWizard: LayerWizard): Promise; + registerSource(entry: SourceRegistryEntry): Promise; } diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 26a0ffc1b1a37..5388a82e5924d 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -11,7 +11,6 @@ import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_de import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; import { IStyleProperty } from '../../styles/vector/properties/style_property'; import { - SOURCE_TYPES, COUNT_PROP_LABEL, COUNT_PROP_NAME, LAYER_TYPE, @@ -41,6 +40,10 @@ import { IVectorSource } from '../../sources/vector_source'; const ACTIVE_COUNT_DATA_ID = 'ACTIVE_COUNT_DATA_ID'; +interface CountData { + isSyncClustered: boolean; +} + function getAggType(dynamicProperty: IDynamicStyleProperty): AGG_TYPE { return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS; } @@ -187,14 +190,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { this._clusterStyle = new VectorStyle(clusterStyleDescriptor, this._clusterSource, this); let isClustered = false; - const sourceDataRequest = this.getSourceDataRequest(); - if (sourceDataRequest) { - const requestMeta = sourceDataRequest.getMeta(); - if ( - requestMeta && - requestMeta.sourceMeta && - requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID - ) { + const countDataRequest = this.getDataRequest(ACTIVE_COUNT_DATA_ID); + if (countDataRequest) { + const requestData = countDataRequest.getData() as CountData; + if (requestData && requestData.isSyncClustered) { isClustered = true; } } @@ -284,7 +283,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const resp = await searchSource.fetch(); const maxResultWindow = await this._documentSource.getMaxResultWindow(); isSyncClustered = resp.hits.total > maxResultWindow; - syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); + const countData = { isSyncClustered } as CountData; + syncContext.stopLoading(dataRequestId, requestToken, countData, searchFilters); } catch (error) { if (!(error instanceof DataRequestAbortError)) { syncContext.onLoadError(dataRequestId, requestToken, error.message); diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 49d262cbad1a1..5cc2a1225bbd7 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { EuiPanel } from '@elastic/eui'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; // @ts-ignore import { EMSTMSSource, sourceTitle } from './ems_tms_source'; @@ -32,7 +33,11 @@ export const emsBaseMapLayerWizardConfig: LayerWizard = { previewLayers([layerDescriptor]); }; - return ; + return ( + + + + ); }, title: sourceTitle, }; diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js index 2b54e00cae739..1eff4bf3786f4 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/tile_service_select.js @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiSelect, EuiFormRow, EuiPanel } from '@elastic/eui'; +import { EuiSelect, EuiFormRow } from '@elastic/eui'; import { getEmsTmsServices } from '../../../meta'; import { getEmsUnavailableMessage } from '../../../components/ems_unavailable_message'; @@ -71,25 +71,23 @@ export class TileServiceSelect extends React.Component { } return ( - - - - - + + + ); } } diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/update_source_editor.js b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/update_source_editor.js index 4d567b8dbb32a..f5ef7096d48dd 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/update_source_editor.js +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/update_source_editor.js @@ -26,9 +26,7 @@ export function UpdateSourceEditor({ onChange, config }) { /> - - diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index 715c16b22dc51..ee97fdd0a2bf6 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -28,7 +28,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; export const clustersLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], @@ -57,7 +57,7 @@ export const clustersLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, type: COLOR_MAP_TYPE.ORDINAL, }, }, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index 1be74140fe1bf..3902709eeb841 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -63,7 +63,6 @@ export class ESGeoGridSource extends AbstractESAggSource { getSyncMeta() { return { requestType: this._descriptor.requestType, - sourceType: SOURCE_TYPES.ES_GEO_GRID, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index ae7414b827c8d..fee84d0208978 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -18,7 +18,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; @@ -50,7 +50,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, }, }, [VECTOR_STYLES.LINE_WIDTH]: { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index 330fa6e8318ed..256becf70ffb0 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -540,7 +540,6 @@ export class ESSearchSource extends AbstractESSource { scalingType: this._descriptor.scalingType, topHitsSplitField: this._descriptor.topHitsSplitField, topHitsSize: this._descriptor.topHitsSize, - sourceType: SOURCE_TYPES.ES_SEARCH, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/source_registry.ts b/x-pack/plugins/maps/public/classes/sources/source_registry.ts index 3b334d45092ad..462624dfa6ec9 100644 --- a/x-pack/plugins/maps/public/classes/sources/source_registry.ts +++ b/x-pack/plugins/maps/public/classes/sources/source_registry.ts @@ -7,7 +7,7 @@ import { ISource } from './source'; -type SourceRegistryEntry = { +export type SourceRegistryEntry = { ConstructorFunction: new ( sourceDescriptor: any, // this is the source-descriptor that corresponds specifically to the particular ISource instance inspectorAdapters?: object diff --git a/x-pack/plugins/maps/public/classes/styles/_index.scss b/x-pack/plugins/maps/public/classes/styles/_index.scss index 3ee713ffc1a02..bd1467bed9d4e 100644 --- a/x-pack/plugins/maps/public/classes/styles/_index.scss +++ b/x-pack/plugins/maps/public/classes/styles/_index.scss @@ -1,4 +1,4 @@ -@import 'components/color_gradient'; +@import 'heatmap/components/legend/color_gradient'; @import 'vector/components/style_prop_editor'; @import 'vector/components/color/color_stops'; @import 'vector/components/symbol/icon_select'; diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts new file mode 100644 index 0000000000000..b964ecf6d6b63 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + getColorRampCenterColor, + getOrdinalMbColorRampStops, + getColorPalette, +} from './color_palettes'; + +describe('getColorPalette', () => { + it('Should create RGB color ramp', () => { + expect(getColorPalette('Blues')).toEqual([ + '#ecf1f7', + '#d9e3ef', + '#c5d5e7', + '#b2c7df', + '#9eb9d8', + '#8bacd0', + '#769fc8', + '#6092c0', + ]); + }); +}); + +describe('getColorRampCenterColor', () => { + it('Should get center color from color ramp', () => { + expect(getColorRampCenterColor('Blues')).toBe('#9eb9d8'); + }); +}); + +describe('getOrdinalMbColorRampStops', () => { + it('Should create color stops for custom range', () => { + expect(getOrdinalMbColorRampStops('Blues', 0, 1000)).toEqual([ + 0, + '#ecf1f7', + 125, + '#d9e3ef', + 250, + '#c5d5e7', + 375, + '#b2c7df', + 500, + '#9eb9d8', + 625, + '#8bacd0', + 750, + '#769fc8', + 875, + '#6092c0', + ]); + }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalMbColorRampStops('Blues', 23, 23)).toEqual([23, '#6092c0']); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts new file mode 100644 index 0000000000000..e7574b4e7b3e4 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts @@ -0,0 +1,172 @@ +/* + * 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 tinycolor from 'tinycolor2'; +import { + // @ts-ignore + euiPaletteForStatus, + // @ts-ignore + euiPaletteForTemperature, + // @ts-ignore + euiPaletteCool, + // @ts-ignore + euiPaletteWarm, + // @ts-ignore + euiPaletteNegative, + // @ts-ignore + euiPalettePositive, + // @ts-ignore + euiPaletteGray, + // @ts-ignore + euiPaletteColorBlind, +} from '@elastic/eui/lib/services'; +import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; + +export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; + +export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); +export const DEFAULT_LINE_COLORS: string[] = [ + ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), + // Explicitly add black & white as border color options + '#000', + '#FFF', +]; + +const COLOR_PALETTES: EuiColorPalettePickerPaletteProps[] = [ + { + value: 'Blues', + palette: euiPaletteCool(8), + type: 'gradient', + }, + { + value: 'Greens', + palette: euiPalettePositive(8), + type: 'gradient', + }, + { + value: 'Greys', + palette: euiPaletteGray(8), + type: 'gradient', + }, + { + value: 'Reds', + palette: euiPaletteNegative(8), + type: 'gradient', + }, + { + value: 'Yellow to Red', + palette: euiPaletteWarm(8), + type: 'gradient', + }, + { + value: 'Green to Red', + palette: euiPaletteForStatus(8), + type: 'gradient', + }, + { + value: 'Blue to Red', + palette: euiPaletteForTemperature(8), + type: 'gradient', + }, + { + value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, + palette: [ + 'rgb(65, 105, 225)', // royalblue + 'rgb(0, 256, 256)', // cyan + 'rgb(0, 256, 0)', // lime + 'rgb(256, 256, 0)', // yellow + 'rgb(256, 0, 0)', // red + ], + type: 'gradient', + }, + { + value: 'palette_0', + palette: euiPaletteColorBlind(), + type: 'fixed', + }, + { + value: 'palette_20', + palette: euiPaletteColorBlind({ rotations: 2 }), + type: 'fixed', + }, + { + value: 'palette_30', + palette: euiPaletteColorBlind({ rotations: 3 }), + type: 'fixed', + }, +]; + +export const NUMERICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'gradient'; + } +); + +export const CATEGORICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'fixed'; + } +); + +export function getColorPalette(colorPaletteId: string): string[] { + const colorPalette = COLOR_PALETTES.find(({ value }: EuiColorPalettePickerPaletteProps) => { + return value === colorPaletteId; + }); + return colorPalette ? (colorPalette.palette as string[]) : []; +} + +export function getColorRampCenterColor(colorPaletteId: string): string | null { + if (!colorPaletteId) { + return null; + } + const palette = getColorPalette(colorPaletteId); + return palette.length === 0 ? null : palette[Math.floor(palette.length / 2)]; +} + +// Returns an array of color stops +// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] +export function getOrdinalMbColorRampStops( + colorPaletteId: string, + min: number, + max: number +): Array | null { + if (!colorPaletteId) { + return null; + } + + if (min > max) { + return null; + } + + const palette = getColorPalette(colorPaletteId); + if (palette.length === 0) { + return null; + } + + if (max === min) { + // just return single stop value + return [max, palette[palette.length - 1]]; + } + + const delta = max - min; + return palette.reduce( + (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, + [] + ); +} + +export function getLinearGradient(colorStrings: string[]): string { + const intervals = colorStrings.length; + let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; + for (let i = 1; i < intervals - 1; i++) { + linearGradient = `${linearGradient} ${colorStrings[i]} \ + ${Math.floor((100 * i) / (intervals - 1))}%,`; + } + return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; +} diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts b/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts deleted file mode 100644 index ed7cafd53a6fc..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - COLOR_GRADIENTS, - getColorRampCenterColor, - getOrdinalMbColorRampStops, - getHexColorRangeStrings, - getLinearGradient, - getRGBColorRangeStrings, -} from './color_utils'; - -jest.mock('ui/new_platform'); - -describe('COLOR_GRADIENTS', () => { - it('Should contain EuiSuperSelect options list of color ramps', () => { - expect(COLOR_GRADIENTS.length).toBe(6); - const colorGradientOption = COLOR_GRADIENTS[0]; - expect(colorGradientOption.value).toBe('Blues'); - }); -}); - -describe('getRGBColorRangeStrings', () => { - it('Should create RGB color ramp', () => { - expect(getRGBColorRangeStrings('Blues', 8)).toEqual([ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]); - }); -}); - -describe('getHexColorRangeStrings', () => { - it('Should create HEX color ramp', () => { - expect(getHexColorRangeStrings('Blues')).toEqual([ - '#f7faff', - '#ddeaf7', - '#c5daee', - '#9dc9e0', - '#6aadd5', - '#4191c5', - '#2070b4', - '#072f6b', - ]); - }); -}); - -describe('getColorRampCenterColor', () => { - it('Should get center color from color ramp', () => { - expect(getColorRampCenterColor('Blues')).toBe('rgb(106,173,213)'); - }); -}); - -describe('getColorRampStops', () => { - it('Should create color stops for custom range', () => { - expect(getOrdinalMbColorRampStops('Blues', 0, 1000, 8)).toEqual([ - 0, - '#f7faff', - 125, - '#ddeaf7', - 250, - '#c5daee', - 375, - '#9dc9e0', - 500, - '#6aadd5', - 625, - '#4191c5', - 750, - '#2070b4', - 875, - '#072f6b', - ]); - }); - - it('Should snap to end of color stops for identical range', () => { - expect(getOrdinalMbColorRampStops('Blues', 23, 23, 8)).toEqual([23, '#072f6b']); - }); -}); - -describe('getLinearGradient', () => { - it('Should create linear gradient from color ramp', () => { - const colorRamp = [ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]; - expect(getLinearGradient(colorRamp)).toBe( - 'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)' - ); - }); -}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx deleted file mode 100644 index 0192a9d7ca68f..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx +++ /dev/null @@ -1,174 +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 tinycolor from 'tinycolor2'; -import chroma from 'chroma-js'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; -import { ColorGradient } from './components/color_gradient'; -import { RawColorSchema, vislibColorMaps } from '../../../../../../src/plugins/charts/public'; - -export const GRADIENT_INTERVALS = 8; - -export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); -export const DEFAULT_LINE_COLORS: string[] = [ - ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), - // Explicitly add black & white as border color options - '#000', - '#FFF', -]; - -function getRGBColors(colorRamp: Array<[number, number[]]>, numLegendColors: number = 4): string[] { - const colors = []; - colors[0] = getRGBColor(colorRamp, 0); - for (let i = 1; i < numLegendColors - 1; i++) { - colors[i] = getRGBColor(colorRamp, Math.floor((colorRamp.length * i) / numLegendColors)); - } - colors[numLegendColors - 1] = getRGBColor(colorRamp, colorRamp.length - 1); - return colors; -} - -function getRGBColor(colorRamp: Array<[number, number[]]>, i: number): string { - const rgbArray = colorRamp[i][1]; - const red = Math.floor(rgbArray[0] * 255); - const green = Math.floor(rgbArray[1] * 255); - const blue = Math.floor(rgbArray[2] * 255); - return `rgb(${red},${green},${blue})`; -} - -function getColorSchema(colorRampName: string): RawColorSchema { - const colorSchema = vislibColorMaps[colorRampName]; - if (!colorSchema) { - throw new Error( - `${colorRampName} not found. Expected one of following values: ${Object.keys( - vislibColorMaps - )}` - ); - } - return colorSchema; -} - -export function getRGBColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - const colorSchema = getColorSchema(colorRampName); - return getRGBColors(colorSchema.value, numberColors); -} - -export function getHexColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - return getRGBColorRangeStrings(colorRampName, numberColors).map((rgbColor) => - chroma(rgbColor).hex() - ); -} - -export function getColorRampCenterColor(colorRampName: string): string | null { - if (!colorRampName) { - return null; - } - const colorSchema = getColorSchema(colorRampName); - const centerIndex = Math.floor(colorSchema.value.length / 2); - return getRGBColor(colorSchema.value, centerIndex); -} - -// Returns an array of color stops -// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalMbColorRampStops( - colorRampName: string, - min: number, - max: number, - numberColors: number -): Array | null { - if (!colorRampName) { - return null; - } - - if (min > max) { - return null; - } - - const hexColors = getHexColorRangeStrings(colorRampName, numberColors); - if (max === min) { - // just return single stop value - return [max, hexColors[hexColors.length - 1]]; - } - - const delta = max - min; - return hexColors.reduce( - (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { - const stopNumber = min + (delta * idx) / srcArr.length; - return [...accu, stopNumber, stopColor]; - }, - [] - ); -} - -export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map((colorRampName) => ({ - value: colorRampName, - inputDisplay: , -})); - -export const COLOR_RAMP_NAMES = Object.keys(vislibColorMaps); - -export function getLinearGradient(colorStrings: string[]): string { - const intervals = colorStrings.length; - let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; - for (let i = 1; i < intervals - 1; i++) { - linearGradient = `${linearGradient} ${colorStrings[i]} \ - ${Math.floor((100 * i) / (intervals - 1))}%,`; - } - return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; -} - -export interface ColorPalette { - id: string; - colors: string[]; -} - -const COLOR_PALETTES_CONFIGS: ColorPalette[] = [ - { - id: 'palette_0', - colors: euiPaletteColorBlind(), - }, - { - id: 'palette_20', - colors: euiPaletteColorBlind({ rotations: 2 }), - }, - { - id: 'palette_30', - colors: euiPaletteColorBlind({ rotations: 3 }), - }, -]; - -export function getColorPalette(paletteId: string): string[] | null { - const palette = COLOR_PALETTES_CONFIGS.find(({ id }: ColorPalette) => id === paletteId); - return palette ? palette.colors : null; -} - -export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map((palette) => { - const paletteDisplay = palette.colors.map((color) => { - const style: React.CSSProperties = { - backgroundColor: color, - width: `${100 / palette.colors.length}%`, - position: 'relative', - height: '100%', - display: 'inline-block', - }; - return ( -
-   -
- ); - }); - return { - value: palette.id, - inputDisplay:
{paletteDisplay}
, - }; -}); diff --git a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx deleted file mode 100644 index b29146062e46d..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx +++ /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 React from 'react'; -import { - COLOR_RAMP_NAMES, - GRADIENT_INTERVALS, - getRGBColorRangeStrings, - getLinearGradient, -} from '../color_utils'; - -interface Props { - colorRamp?: string[]; - colorRampName?: string; -} - -export const ColorGradient = ({ colorRamp, colorRampName }: Props) => { - if (!colorRamp && (!colorRampName || !COLOR_RAMP_NAMES.includes(colorRampName))) { - return null; - } - - const rgbColorStrings = colorRampName - ? getRGBColorRangeStrings(colorRampName, GRADIENT_INTERVALS) - : colorRamp!; - const background = getLinearGradient(rgbColorStrings); - return
; -}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap index 9d07b9c641e0f..7c42b78fdc552 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap @@ -10,66 +10,120 @@ exports[`HeatmapStyleEditor is rendered 1`] = ` label="Color range" labelType="label" > - , - "text": "theclassic", - "value": "theclassic", - }, - Object { - "inputDisplay": , + "palette": Array [ + "#ecf1f7", + "#d9e3ef", + "#c5d5e7", + "#b2c7df", + "#9eb9d8", + "#8bacd0", + "#769fc8", + "#6092c0", + ], + "type": "gradient", "value": "Blues", }, Object { - "inputDisplay": , + "palette": Array [ + "#e6f1ee", + "#cce4de", + "#b3d6cd", + "#9ac8bd", + "#80bbae", + "#65ad9e", + "#47a08f", + "#209280", + ], + "type": "gradient", "value": "Greens", }, Object { - "inputDisplay": , + "palette": Array [ + "#e0e4eb", + "#c2c9d5", + "#a6afbf", + "#8c95a5", + "#757c8b", + "#5e6471", + "#494d58", + "#343741", + ], + "type": "gradient", "value": "Greys", }, Object { - "inputDisplay": , + "palette": Array [ + "#fdeae5", + "#f9d5cc", + "#f4c0b4", + "#eeab9c", + "#e79685", + "#df816e", + "#d66c58", + "#cc5642", + ], + "type": "gradient", "value": "Reds", }, Object { - "inputDisplay": , + "palette": Array [ + "#f9eac5", + "#f6d9af", + "#f3c89a", + "#efb785", + "#eba672", + "#e89361", + "#e58053", + "#e7664c", + ], + "type": "gradient", "value": "Yellow to Red", }, Object { - "inputDisplay": , + "palette": Array [ + "#209280", + "#3aa38d", + "#54b399", + "#95b978", + "#df9352", + "#e7664c", + "#da5e47", + "#cc5642", + ], + "type": "gradient", "value": "Green to Red", }, + Object { + "palette": Array [ + "#6092c0", + "#84a9cd", + "#a8bfda", + "#cad7e8", + "#f0d3b0", + "#ecb385", + "#ea8d69", + "#e7664c", + ], + "type": "gradient", + "value": "Blue to Red", + }, + Object { + "palette": Array [ + "rgb(65, 105, 225)", + "rgb(0, 256, 256)", + "rgb(0, 256, 0)", + "rgb(256, 256, 0)", + "rgb(256, 0, 0)", + ], + "type": "gradient", + "value": "theclassic", + }, ] } valueOfSelected="Blues" diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts index 583c78e56581b..b043c2791b146 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts @@ -6,17 +6,6 @@ import { i18n } from '@kbn/i18n'; -// Color stops from default Mapbox heatmap-color -export const DEFAULT_RGB_HEATMAP_COLOR_RAMP = [ - 'rgb(65, 105, 225)', // royalblue - 'rgb(0, 256, 256)', // cyan - 'rgb(0, 256, 0)', // lime - 'rgb(256, 256, 0)', // yellow - 'rgb(256, 0, 0)', // red -]; - -export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; - export const HEATMAP_COLOR_RAMP_LABEL = i18n.translate('xpack.maps.heatmap.colorRampLabel', { defaultMessage: 'Color range', }); diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx index d15fdbd79de75..48713f1ddfd4b 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx @@ -6,14 +6,9 @@ import React from 'react'; -import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; -import { COLOR_GRADIENTS } from '../../color_utils'; -import { ColorGradient } from '../../components/color_gradient'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from './heatmap_constants'; +import { EuiFormRow, EuiColorPalettePicker } from '@elastic/eui'; +import { NUMERICAL_COLOR_PALETTES } from '../../color_palettes'; +import { HEATMAP_COLOR_RAMP_LABEL } from './heatmap_constants'; interface Props { colorRampName: string; @@ -21,28 +16,18 @@ interface Props { } export function HeatmapStyleEditor({ colorRampName, onHeatmapColorChange }: Props) { - const onColorRampChange = (selectedColorRampName: string) => { + const onColorRampChange = (selectedPaletteId: string) => { onHeatmapColorChange({ - colorRampName: selectedColorRampName, + colorRampName: selectedPaletteId, }); }; - const colorRampOptions = [ - { - value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - text: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - inputDisplay: , - }, - ...COLOR_GRADIENTS, - ]; - return ( - diff --git a/x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss rename to x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx new file mode 100644 index 0000000000000..b4a241f625683 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { getColorPalette, getLinearGradient } from '../../../color_palettes'; + +interface Props { + colorPaletteId: string; +} + +export const ColorGradient = ({ colorPaletteId }: Props) => { + const palette = getColorPalette(colorPaletteId); + return palette.length ? ( +
+ ) : null; +}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js index 1d8dfe9c7bdbf..5c3600a149afe 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js @@ -7,13 +7,9 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ColorGradient } from '../../../components/color_gradient'; +import { ColorGradient } from './color_gradient'; import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from '../heatmap_constants'; +import { HEATMAP_COLOR_RAMP_LABEL } from '../heatmap_constants'; export class HeatmapLegend extends React.Component { constructor() { @@ -41,17 +37,9 @@ export class HeatmapLegend extends React.Component { } render() { - const colorRampName = this.props.colorRampName; - const header = - colorRampName === DEFAULT_HEATMAP_COLOR_RAMP_NAME ? ( - - ) : ( - - ); - return ( } minLabel={i18n.translate('xpack.maps.heatmapLegend.coldLabel', { defaultMessage: 'cold', })} diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js index 5f920d0ba52d3..55bbbc9319dfb 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js @@ -8,15 +8,15 @@ import React from 'react'; import { AbstractStyle } from '../style'; import { HeatmapStyleEditor } from './components/heatmap_style_editor'; import { HeatmapLegend } from './components/legend/heatmap_legend'; -import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants'; +import { DEFAULT_HEATMAP_COLOR_RAMP_NAME, getOrdinalMbColorRampStops } from '../color_palettes'; import { LAYER_STYLE_TYPE, GRID_RESOLUTION } from '../../../../common/constants'; -import { getOrdinalMbColorRampStops, GRADIENT_INTERVALS } from '../color_utils'; + import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; //The heatmap range chosen hear runs from 0 to 1. It is arbitrary. //Weighting is on the raw count/sum values. -const MIN_RANGE = 0; +const MIN_RANGE = 0.1; // 0 to 0.1 is displayed as transparent color stop const MAX_RANGE = 1; export class HeatmapStyle extends AbstractStyle { @@ -83,40 +83,19 @@ export class HeatmapStyle extends AbstractStyle { property: propertyName, }); - const { colorRampName } = this._descriptor; - if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalMbColorRampStops( - colorRampName, - MIN_RANGE, - MAX_RANGE, - GRADIENT_INTERVALS - ); - // TODO handle null - mbMap.setPaintProperty(layerId, 'heatmap-color', [ - 'interpolate', - ['linear'], - ['heatmap-density'], - 0, - 'rgba(0, 0, 255, 0)', - ...colorStops.slice(2), // remove first stop from colorStops to avoid conflict with transparent stop at zero - ]); - } else { + const colorStops = getOrdinalMbColorRampStops( + this._descriptor.colorRampName, + MIN_RANGE, + MAX_RANGE + ); + if (colorStops) { mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0, 0, 255, 0)', - 0.1, - 'royalblue', - 0.3, - 'cyan', - 0.5, - 'lime', - 0.7, - 'yellow', - 1, - 'red', + ...colorStops, ]); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js index fe2f302504a15..69cdb00a01c9c 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js @@ -6,10 +6,17 @@ import React, { Component, Fragment } from 'react'; -import { EuiSpacer, EuiSelect, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSpacer, + EuiSelect, + EuiColorPalettePicker, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { ColorStopsOrdinal } from './color_stops_ordinal'; import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; import { ColorStopsCategorical } from './color_stops_categorical'; +import { CATEGORICAL_COLOR_PALETTES, NUMERICAL_COLOR_PALETTES } from '../../../color_palettes'; import { i18n } from '@kbn/i18n'; const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP'; @@ -65,10 +72,10 @@ export class ColorMapSelect extends Component { ); } - _onColorMapSelect = (selectedValue) => { - const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP; + _onColorPaletteSelect = (selectedPaletteId) => { + const useCustomColorMap = selectedPaletteId === CUSTOM_COLOR_MAP; this.props.onChange({ - color: useCustomColorMap ? null : selectedValue, + color: useCustomColorMap ? null : selectedPaletteId, useCustomColorMap, type: this.props.colorMapType, }); @@ -126,41 +133,44 @@ export class ColorMapSelect extends Component { return null; } - const colorMapOptionsWithCustom = [ + const palettes = + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? NUMERICAL_COLOR_PALETTES + : CATEGORICAL_COLOR_PALETTES; + + const palettesWithCustom = [ { value: CUSTOM_COLOR_MAP, - inputDisplay: this.props.customOptionLabel, + title: + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? i18n.translate('xpack.maps.style.customColorRampLabel', { + defaultMessage: 'Custom color ramp', + }) + : i18n.translate('xpack.maps.style.customColorPaletteLabel', { + defaultMessage: 'Custom color palette', + }), + type: 'text', 'data-test-subj': `colorMapSelectOption_${CUSTOM_COLOR_MAP}`, }, - ...this.props.colorMapOptions, + ...palettes, ]; - let valueOfSelected; - if (this.props.useCustomColorMap) { - valueOfSelected = CUSTOM_COLOR_MAP; - } else { - valueOfSelected = this.props.colorMapOptions.find( - (option) => option.value === this.props.color - ) - ? this.props.color - : ''; - } - const toggle = this.props.showColorMapTypeToggle ? ( {this._renderColorMapToggle()} ) : null; return ( - + {toggle} - diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js index 90070343a1b48..1034e8f5d6525 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js @@ -10,8 +10,6 @@ import { FieldSelect } from '../field_select'; import { ColorMapSelect } from './color_map_select'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants'; -import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils'; -import { i18n } from '@kbn/i18n'; export function DynamicColorForm({ fields, @@ -91,14 +89,10 @@ export function DynamicColorForm({ return ( { fieldMetaOptions, } as ColorDynamicOptions, } as ColorDynamicStylePropertyDescriptor; - expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe( - 'rgb(106,173,213)' - ); + expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe('#9eb9d8'); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts index dadb3f201fa33..4a3f45a929fd1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -import { getColorRampCenterColor, getColorPalette } from '../../../color_utils'; +import { getColorRampCenterColor, getColorPalette } from '../../../color_palettes'; import { COLOR_MAP_TYPE, STYLE_TYPE } from '../../../../../../common/constants'; import { ColorDynamicOptions, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js index 6528648eff552..53a3fc95adbeb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js @@ -15,7 +15,7 @@ import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { OrientationEditor } from './orientation/orientation_editor'; import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; -import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; +import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 29eb52897a50e..402eab355406b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -175,7 +175,7 @@ exports[`ordinal Should render only single band of last color when delta is 0 1` key="0" > { - const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / GRADIENT_INTERVALS); + const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / colors.length); return { color, stop: dynamicRound(rawStopValue), diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js index 1879b260da2e2..7992ee5b3aeaf 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js @@ -323,21 +323,21 @@ describe('get mapbox color expression (via internal _getMbColor)', () => { -1, 'rgba(0,0,0,0)', 0, - '#f7faff', + '#ecf1f7', 12.5, - '#ddeaf7', + '#d9e3ef', 25, - '#c5daee', + '#c5d5e7', 37.5, - '#9dc9e0', + '#b2c7df', 50, - '#6aadd5', + '#9eb9d8', 62.5, - '#4191c5', + '#8bacd0', 75, - '#2070b4', + '#769fc8', 87.5, - '#072f6b', + '#6092c0', ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts index a6878a0d760c7..a3ae80e0a5935 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts @@ -12,11 +12,11 @@ import { STYLE_TYPE, } from '../../../../common/constants'; import { - COLOR_GRADIENTS, - COLOR_PALETTES, DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS, -} from '../color_utils'; + NUMERICAL_COLOR_PALETTES, + CATEGORICAL_COLOR_PALETTES, +} from '../color_palettes'; import { VectorStylePropertiesDescriptor } from '../../../../common/descriptor_types'; // @ts-ignore import { getUiSettings } from '../../../kibana_services'; @@ -28,8 +28,8 @@ export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; export const DEFAULT_LABEL_SIZE = 14; export const DEFAULT_ICON_SIZE = 6; -export const DEFAULT_COLOR_RAMP = COLOR_GRADIENTS[0].value; -export const DEFAULT_COLOR_PALETTE = COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_RAMP = NUMERICAL_COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_PALETTE = CATEGORICAL_COLOR_PALETTES[0].value; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; export const POLYGON_STYLES = [ diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap index ef11f9958d8db..f8803d6339d9c 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap @@ -3,6 +3,7 @@ exports[`LayerWizardSelect Should render layer select after layer wizards are loaded 1`] = ` diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index f0195bc5dee2f..6f3a88ce905ce 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -115,7 +115,7 @@ export class LayerWizardSelect extends Component { }); return ( - + { { return ( <> {this._renderCategoryFacets()} + {wizardCards} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap index 00d7f44d6273f..92330c1d1ddce 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap @@ -85,7 +85,7 @@ exports[`Should render join editor 1`] = ` > diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx index c589604e85112..2065668858e22 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx @@ -85,7 +85,7 @@ export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDispla ); globalFilterCheckbox = ( - + + + ); } diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index ca4098ebfa805..12d6d75ac57ba 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -14,6 +14,8 @@ import { MapStore, MapStoreState } from '../reducers/store'; import { EventHandlers } from '../reducers/non_serializable_instances'; import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; import { MapEmbeddableConfig, MapEmbeddableInput, MapEmbeddableOutput } from '../embeddable/types'; +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; let loadModulesPromise: Promise; @@ -42,6 +44,8 @@ interface LazyLoadedMapModules { indexPatternId: string, indexPatternTitle: string ) => LayerDescriptor[]; + registerLayerWizard(layerWizard: LayerWizard): void; + registerSource(entry: SourceRegistryEntry): void; } export async function lazyLoadMapModules(): Promise { @@ -65,6 +69,8 @@ export async function lazyLoadMapModules(): Promise { // @ts-expect-error renderApp, createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, } = await import('./lazy'); resolve({ @@ -80,6 +86,8 @@ export async function lazyLoadMapModules(): Promise { mergeInputWithSavedMap, renderApp, createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, }); }); return loadModulesPromise; diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts index 4f9f01f8a1b37..c839122ab90b1 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts @@ -19,3 +19,5 @@ export * from '../../embeddable/merge_input_with_saved_map'; // @ts-expect-error export * from '../../routing/maps_router'; export * from '../../classes/layers/solution_layers/security'; +export { registerLayerWizard } from '../../classes/layers/layer_wizard_registry'; +export { registerSource } from '../../classes/sources/source_registry'; diff --git a/x-pack/plugins/maps/public/meta.test.js b/x-pack/plugins/maps/public/meta.test.js index 5c04a57c00058..3486bf003aee0 100644 --- a/x-pack/plugins/maps/public/meta.test.js +++ b/x-pack/plugins/maps/public/meta.test.js @@ -36,6 +36,11 @@ describe('getGlyphUrl', () => { beforeAll(() => { require('./kibana_services').getIsEmsEnabled = () => true; require('./kibana_services').getEmsFontLibraryUrl = () => EMS_FONTS_URL_MOCK; + require('./kibana_services').getHttp = () => ({ + basePath: { + prepend: (url) => url, // No need to actually prepend a dev basepath for test + }, + }); }); describe('EMS proxy enabled', () => { diff --git a/x-pack/plugins/maps/public/meta.ts b/x-pack/plugins/maps/public/meta.ts index 54c5eac7fe1b0..34c5f004fd7f3 100644 --- a/x-pack/plugins/maps/public/meta.ts +++ b/x-pack/plugins/maps/public/meta.ts @@ -30,8 +30,6 @@ import { getKibanaVersion, } from './kibana_services'; -const GIS_API_RELATIVE = `../${GIS_API_PATH}`; - export function getKibanaRegionList(): unknown[] { return getRegionmapLayers(); } @@ -69,10 +67,14 @@ export function getEMSClient(): EMSClient { const proxyElasticMapsServiceInMaps = getProxyElasticMapsServiceInMaps(); const proxyPath = ''; const tileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_TILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}`) + ) : getEmsTileApiUrl(); const fileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_FILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_FILES_CATALOGUE_PATH}`) + ) : getEmsFileApiUrl(); emsClient = new EMSClient({ @@ -101,8 +103,11 @@ export function getGlyphUrl(): string { return getHttp().basePath.prepend(`/${FONTS_API_PATH}/{fontstack}/{range}`); } return getProxyElasticMapsServiceInMaps() - ? relativeToAbsolute(`../${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`) + - `/{fontstack}/{range}` + ? relativeToAbsolute( + getHttp().basePath.prepend( + `/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}` + ) + ) + `/{fontstack}/{range}` : getEmsFontLibraryUrl(); } diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 412e8832453bc..8428a31d8b408 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -55,7 +55,7 @@ import { getAppTitle } from '../common/i18n_getters'; import { ILicense } from '../../licensing/common/types'; import { lazyLoadMapModules } from './lazy_load_bundle'; import { MapsStartApi } from './api'; -import { createSecurityLayerDescriptors } from './api/create_security_layer_descriptors'; +import { createSecurityLayerDescriptors, registerLayerWizard, registerSource } from './api'; export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; @@ -170,6 +170,8 @@ export class MapsPlugin bindStartCoreAndPlugins(core, plugins); return { createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, }; } } diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index dbcce50ac2b9a..7d091099c1aaa 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -26,12 +26,14 @@ import { initRoutes } from './routes'; import { ILicense } from '../../licensing/common/types'; import { LicensingPluginSetup } from '../../licensing/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; +import { MapsLegacyPluginSetup } from '../../../../src/plugins/maps_legacy/server'; interface SetupDeps { features: FeaturesPluginSetupContract; usageCollection: UsageCollectionSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; + mapsLegacy: MapsLegacyPluginSetup; } export class MapsPlugin implements Plugin { @@ -129,9 +131,10 @@ export class MapsPlugin implements Plugin { // @ts-ignore async setup(core: CoreSetup, plugins: SetupDeps) { - const { usageCollection, home, licensing, features } = plugins; + const { usageCollection, home, licensing, features, mapsLegacy } = plugins; // @ts-ignore const config$ = this._initializerContext.config.create(); + const mapsLegacyConfig = await mapsLegacy.config$.pipe(take(1)).toPromise(); const currentConfig = await config$.pipe(take(1)).toPromise(); // @ts-ignore @@ -150,7 +153,7 @@ export class MapsPlugin implements Plugin { initRoutes( core.http.createRouter(), license.uid, - currentConfig, + mapsLegacyConfig, this.kibanaVersion, this._logger ); diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index ad66712eb3ad6..1876c0de19c56 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -73,9 +73,10 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { validate: { query: schema.object({ id: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -111,9 +112,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`, validate: false, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } if ( @@ -138,7 +139,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url, contentType: 'image/png' }, { ok, badRequest }); + return await proxyResource({ url, contentType: 'image/png' }, response); } ); @@ -203,7 +204,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { }); //rewrite return ok({ - body: layers, + body: { + layers, + }, }); } ); @@ -293,7 +296,11 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -302,11 +309,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id) { - logger.warn('Must supply id parameter to retrieve EMS vector style'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -342,8 +344,12 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), sourceId: schema.maybe(schema.string()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -352,11 +358,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id || !request.query.sourceId) { - logger.warn('Must supply id and sourceId parameter to retrieve EMS vector source'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -381,28 +382,21 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), - sourceId: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + id: schema.string(), + sourceId: schema.string(), + x: schema.number(), + y: schema.number(), + z: schema.number(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if ( - !request.query.id || - !request.query.sourceId || - typeof parseInt(request.query.x, 10) !== 'number' || - typeof parseInt(request.query.y, 10) !== 'number' || - typeof parseInt(request.query.z, 10) !== 'number' - ) { - logger.warn('Must supply id/sourceId/x/y/z parameters to retrieve EMS vector tile'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -417,24 +411,29 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); router.get( { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`, - validate: false, + validate: { + params: schema.object({ + fontstack: schema.string(), + range: schema.string(), + }), + }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const url = mapConfig.emsFontLibraryUrl .replace('{fontstack}', request.params.fontstack) .replace('{range}', request.params.range); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); @@ -442,19 +441,22 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`, validate: { + query: schema.object({ + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), + }), params: schema.object({ id: schema.string(), + scaling: schema.maybe(schema.string()), + extension: schema.string(), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if (!request.params.id) { - logger.warn('Must supply id parameter to retrieve EMS vector source sprite'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -479,7 +481,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { url: proxyPathUrl, contentType: request.params.extension === 'png' ? 'image/png' : '', }, - { ok, badRequest } + response ); } ); @@ -570,25 +572,23 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return proxyEMSInMaps; } - async function proxyResource({ url, contentType }, { ok, badRequest }) { + async function proxyResource({ url, contentType }, response) { try { const resource = await fetch(url); const arrayBuffer = await resource.arrayBuffer(); - const bufferedResponse = Buffer.from(arrayBuffer); - const headers = { - 'Content-Disposition': 'inline', - }; - if (contentType) { - headers['Content-type'] = contentType; - } - - return ok({ - body: bufferedResponse, - headers, + const buffer = Buffer.from(arrayBuffer); + + return response.ok({ + body: buffer, + headers: { + 'content-disposition': 'inline', + 'content-length': buffer.length, + ...(contentType ? { 'Content-type': contentType } : {}), + }, }); } catch (e) { logger.warn(`Cannot connect to EMS for resource, error: ${e.message}`); - return badRequest(`Cannot connect to EMS`); + return response.badRequest(`Cannot connect to EMS`); } } } diff --git a/x-pack/plugins/ml/common/constants/field_histograms.ts b/x-pack/plugins/ml/common/constants/field_histograms.ts new file mode 100644 index 0000000000000..5c86c00ac666f --- /dev/null +++ b/x-pack/plugins/ml/common/constants/field_histograms.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index f2b8159b6b83d..b46dd87eec15f 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -5,6 +5,7 @@ */ import { KibanaRequest } from 'kibana/server'; +import { PLUGIN_ID } from '../constants/app'; export const userMlCapabilities = { canAccessML: false, @@ -69,16 +70,31 @@ export function getDefaultCapabilities(): MlCapabilities { export function getPluginPrivileges() { const userMlCapabilitiesKeys = Object.keys(userMlCapabilities); const adminMlCapabilitiesKeys = Object.keys(adminMlCapabilities); - const allMlCapabilities = [...adminMlCapabilitiesKeys, ...userMlCapabilitiesKeys]; + const allMlCapabilitiesKeys = [...adminMlCapabilitiesKeys, ...userMlCapabilitiesKeys]; + // TODO: include ML in base privileges for the `8.0` release: https://github.com/elastic/kibana/issues/71422 + const privilege = { + app: [PLUGIN_ID, 'kibana'], + excludeFromBasePrivileges: true, + management: { + insightsAndAlerting: ['jobsListLink'], + }, + catalogue: [PLUGIN_ID], + savedObject: { + all: [], + read: ['index-pattern', 'search'], + }, + }; return { + admin: { + ...privilege, + api: allMlCapabilitiesKeys.map((k) => `ml:${k}`), + ui: allMlCapabilitiesKeys, + }, user: { - ui: userMlCapabilitiesKeys, + ...privilege, api: userMlCapabilitiesKeys.map((k) => `ml:${k}`), - }, - admin: { - ui: allMlCapabilities, - api: allMlCapabilities.map((k) => `ml:${k}`), + ui: userMlCapabilitiesKeys, }, }; } diff --git a/x-pack/plugins/ml/common/types/kibana.ts b/x-pack/plugins/ml/common/types/kibana.ts index 4a2edfebd1bac..f88b843015f17 100644 --- a/x-pack/plugins/ml/common/types/kibana.ts +++ b/x-pack/plugins/ml/common/types/kibana.ts @@ -11,8 +11,6 @@ import { IndexPatternAttributes } from 'src/plugins/data/common'; export type IndexPatternTitle = string; -export type callWithRequestType = (action: string, params?: any) => Promise; - export interface Route { id: string; k7Breadcrumbs: () => any; diff --git a/x-pack/plugins/ml/jsconfig.json b/x-pack/plugins/ml/jsconfig.json deleted file mode 100644 index 22e52d752250b..0000000000000 --- a/x-pack/plugins/ml/jsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "baseUrl": "../../../.", - "paths": { - "ui/*": ["src/legacy/ui/public/*"], - "plugins/ml/*": ["x-pack/plugins/ml/public/*"] - } - }, - "exclude": ["node_modules", "build"] -} diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index a08b9b6d97116..c61db9fb1ad8d 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -30,7 +30,6 @@ "esUiShared", "kibanaUtils", "kibanaReact", - "management", "dashboard", "savedObjects" ] diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 9d5125532e5b8..cf645404860f5 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -20,7 +20,7 @@ import { MlRouter } from './routing'; import { mlApiServicesProvider } from './services/ml_api_service'; import { HttpService } from './services/http_service'; -type MlDependencies = MlSetupDependencies & MlStartDependencies; +export type MlDependencies = Omit & MlStartDependencies; interface AppProps { coreStart: CoreStart; 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 65ea03caef526..56b372ff39919 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -16,8 +16,8 @@ let _capabilities: MlCapabilities = getDefaultCapabilities(); export function checkGetManagementMlJobsResolver() { return new Promise<{ mlFeatureEnabledInSpace: boolean }>((resolve, reject) => { - getManageMlCapabilities().then( - ({ capabilities, isPlatinumOrTrialLicense, mlFeatureEnabledInSpace }) => { + getManageMlCapabilities() + .then(({ capabilities, isPlatinumOrTrialLicense, mlFeatureEnabledInSpace }) => { _capabilities = capabilities; // Loop through all capabilities to ensure they are all set to true. const isManageML = Object.values(_capabilities).every((p) => p === true); @@ -28,62 +28,80 @@ export function checkGetManagementMlJobsResolver() { window.location.href = ACCESS_DENIED_PATH; return reject(); } - } - ); + }) + .catch((e) => { + window.location.href = ACCESS_DENIED_PATH; + return reject(); + }); }); } export function checkGetJobsCapabilitiesResolver(): Promise { return new Promise((resolve, reject) => { - getCapabilities().then(({ 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. - // 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 - // a basic feature - if (_capabilities.canGetJobs || isPlatinumOrTrialLicense === false) { - return resolve(_capabilities); - } else { + getCapabilities() + .then(({ 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. + // 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 + // a basic feature + if (_capabilities.canGetJobs || isPlatinumOrTrialLicense === false) { + return resolve(_capabilities); + } else { + window.location.href = '#/access-denied'; + return reject(); + } + }) + .catch((e) => { window.location.href = '#/access-denied'; return reject(); - } - }); + }); }); } export function checkCreateJobsCapabilitiesResolver(): Promise { return new Promise((resolve, reject) => { - getCapabilities().then(({ 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 - // a basic feature - if (_capabilities.canCreateJob || isPlatinumOrTrialLicense === false) { - return resolve(_capabilities); - } else { - // if the user has no permission to create a job, - // redirect them back to the Transforms Management page + getCapabilities() + .then(({ 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 + // a basic feature + if (_capabilities.canCreateJob || isPlatinumOrTrialLicense === false) { + 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'; + return reject(); + } + }) + .catch((e) => { window.location.href = '#/jobs'; return reject(); - } - }); + }); }); } export function checkFindFileStructurePrivilegeResolver(): Promise { return new Promise((resolve, reject) => { - getCapabilities().then(({ 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 { + getCapabilities() + .then(({ 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'; + return reject(); + } + }) + .catch((e) => { window.location.href = '#/access-denied'; return reject(); - } - }); + }); }); } 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 9af7a869e0e56..d4be2eab13d26 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 @@ -20,10 +20,13 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../common/constants/field_histograms'; + import { INDEX_STATUS } from '../../data_frame_analytics/common'; import { euiDataGridStyle, euiDataGridToolbarSettings } from './common'; @@ -193,21 +196,31 @@ export const DataGrid: FC = memo( ...(chartsButtonVisible ? { additionalControls: ( - - {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { - defaultMessage: 'Histogram charts', + + > + + {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { + defaultMessage: 'Histogram charts', + })} + + ), } : {}), diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 80bc6b861f742..4bbd3595e5a7e 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -12,7 +12,7 @@ export { showDataGridColumnChartErrorMessageToast, useRenderCellValue, } from './common'; -export { fetchChartsData, ChartData } from './use_column_chart'; +export { getFieldType, ChartData } from './use_column_chart'; export { useDataGrid } from './use_data_grid'; export { DataGrid } from './data_grid'; export { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts new file mode 100644 index 0000000000000..1b35ef238d09e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.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 { getFieldType } from './use_column_chart'; + +describe('getFieldType()', () => { + it('should return the Kibana field type for a given EUI data grid schema', () => { + expect(getFieldType('text')).toBe('string'); + expect(getFieldType('datetime')).toBe('date'); + expect(getFieldType('numeric')).toBe('number'); + expect(getFieldType('boolean')).toBe('boolean'); + expect(getFieldType('json')).toBe('object'); + expect(getFieldType('non-aggregatable')).toBe(undefined); + }); +}); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx index 6b207a999eb52..a762c44e243bf 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -16,8 +16,6 @@ import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; -import { stringHash } from '../../../../common/util/string_utils'; - import { NON_AGGREGATABLE } from './common'; export const hoveredRow$ = new BehaviorSubject(null); @@ -40,7 +38,7 @@ const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => } }; -const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { +export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { if (schema === NON_AGGREGATABLE) { return undefined; } @@ -67,188 +65,6 @@ const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | un return fieldType; }; -interface NumericColumnStats { - interval: number; - min: number; - max: number; -} -type NumericColumnStatsMap = Record; -const getAggIntervals = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const numericColumns = columnTypes.filter((cT) => { - const fieldType = getFieldType(cT.schema); - return fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE; - }); - - if (numericColumns.length === 0) { - return {}; - } - - const minMaxAggs = numericColumns.reduce((aggs, c) => { - const id = stringHash(c.id); - aggs[id] = { - stats: { - field: c.id, - }, - }; - return aggs; - }, {} as Record); - - const respStats = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: minMaxAggs, - size: 0, - }, - }); - - return Object.keys(respStats.aggregations).reduce((p, aggName) => { - const stats = [respStats.aggregations[aggName].min, respStats.aggregations[aggName].max]; - if (!stats.includes(null)) { - const delta = respStats.aggregations[aggName].max - respStats.aggregations[aggName].min; - - let aggInterval = 1; - - if (delta > MAX_CHART_COLUMNS) { - aggInterval = Math.round(delta / MAX_CHART_COLUMNS); - } - - if (delta <= 1) { - aggInterval = delta / MAX_CHART_COLUMNS; - } - - p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; - } - - return p; - }, {} as NumericColumnStatsMap); -}; - -interface AggHistogram { - histogram: { - field: string; - interval: number; - }; -} - -interface AggCardinality { - cardinality: { - field: string; - }; -} - -interface AggTerms { - terms: { - field: string; - size: number; - }; -} - -type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; - -export const fetchChartsData = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const aggIntervals = await getAggIntervals(indexPatternTitle, esSearch, query, columnTypes); - - const chartDataAggs = columnTypes.reduce((aggs, c) => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] !== undefined) { - aggs[`${id}_histogram`] = { - histogram: { - field: c.id, - interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, - }, - }; - } - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - if (fieldType === KBN_FIELD_TYPES.STRING) { - aggs[`${id}_cardinality`] = { - cardinality: { - field: c.id, - }, - }; - } - aggs[`${id}_terms`] = { - terms: { - field: c.id, - size: MAX_CHART_COLUMNS, - }, - }; - } - return aggs; - }, {} as Record); - - if (Object.keys(chartDataAggs).length === 0) { - return []; - } - - const respChartsData = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: chartDataAggs, - size: 0, - }, - }); - - const chartsData: ChartData[] = columnTypes.map( - (c): ChartData => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] === undefined) { - return { - type: 'numeric', - data: [], - interval: 0, - stats: [0, 0], - id: c.id, - }; - } - - return { - data: respChartsData.aggregations[`${id}_histogram`].buckets, - interval: aggIntervals[id].interval, - stats: [aggIntervals[id].min, aggIntervals[id].max], - type: 'numeric', - id: c.id, - }; - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - return { - type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', - cardinality: - fieldType === KBN_FIELD_TYPES.STRING - ? respChartsData.aggregations[`${id}_cardinality`].value - : 2, - data: respChartsData.aggregations[`${id}_terms`].buckets, - id: c.id, - }; - } - - return { - type: 'unsupported', - id: c.id, - }; - } - ); - - return chartsData; -}; - interface NumericDataItem { key: number; key_as_string?: string; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx index 803281bcd0ce9..62a74ed142ccf 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -193,7 +193,6 @@ export const JobSelectorFlyout: FC = ({ ref={flyoutEl} onClose={onFlyoutClose} aria-labelledby="jobSelectorFlyout" - size="l" data-test-subj="mlFlyoutJobSelector" > diff --git a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts index 74c238a0895ca..0717348d1db22 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts +++ b/x-pack/plugins/ml/public/application/components/job_selector/use_job_selection.ts @@ -5,16 +5,16 @@ */ import { difference } from 'lodash'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../util/dependency_cache'; import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; import { useUrlState } from '../../util/url_state'; import { getTimeRangeFromSelection } from './job_select_service_utils'; +import { useNotifications } from '../../contexts/kibana'; // check that the ids read from the url exist by comparing them to the // jobs loaded via mlJobsService. @@ -25,49 +25,53 @@ function getInvalidJobIds(jobs: MlJobWithTimeRange[], ids: string[]) { }); } -function warnAboutInvalidJobIds(invalidIds: string[]) { - if (invalidIds.length > 0) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { - defaultMessage: `Requested -{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, - values: { - invalidIdsLength: invalidIds.length, - invalidIds: invalidIds.join(), - }, - }) - ); - } -} - export interface JobSelection { jobIds: string[]; selectedGroups: string[]; } -export const useJobSelection = (jobs: MlJobWithTimeRange[], dateFormatTz: string) => { +export const useJobSelection = (jobs: MlJobWithTimeRange[]) => { const [globalState, setGlobalState] = useUrlState('_g'); + const { toasts: toastNotifications } = useNotifications(); - const jobSelection: JobSelection = { jobIds: [], selectedGroups: [] }; + const tmpIds = useMemo(() => { + const ids = globalState?.ml?.jobIds || []; + return (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); + }, [globalState?.ml?.jobIds]); - const ids = globalState?.ml?.jobIds || []; - const tmpIds = (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); - const invalidIds = getInvalidJobIds(jobs, tmpIds); - const validIds = difference(tmpIds, invalidIds); - validIds.sort(); + const invalidIds = useMemo(() => { + return getInvalidJobIds(jobs, tmpIds); + }, [tmpIds]); - jobSelection.jobIds = validIds; - jobSelection.selectedGroups = globalState?.ml?.groups ?? []; + const validIds = useMemo(() => { + const res = difference(tmpIds, invalidIds); + res.sort(); + return res; + }, [tmpIds, invalidIds]); + + const jobSelection: JobSelection = useMemo(() => { + const selectedGroups = globalState?.ml?.groups ?? []; + return { jobIds: validIds, selectedGroups }; + }, [validIds, globalState?.ml?.groups]); useEffect(() => { - warnAboutInvalidJobIds(invalidIds); + if (invalidIds.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { + defaultMessage: `Requested +{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, + values: { + invalidIdsLength: invalidIds.length, + invalidIds: invalidIds.join(), + }, + }) + ); + } }, [invalidIds]); useEffect(() => { // if there are no valid ids, warn and then select the first job if (validIds.length === 0 && jobs.length > 0) { - const toastNotifications = getToastNotifications(); toastNotifications.addWarning( i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { defaultMessage: 'No jobs selected, auto selecting first job', 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 618ea5184007d..06254f0de092e 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 @@ -339,6 +339,7 @@ export interface UpdateDataFrameAnalyticsConfig { allow_lazy_start?: string; description?: string; model_memory_limit?: string; + max_num_threads?: number; } export interface DataFrameAnalyticsConfig { @@ -358,6 +359,7 @@ export interface DataFrameAnalyticsConfig { excludes: string[]; }; model_memory_limit: string; + max_num_threads?: number; create_time: number; version: string; allow_lazy_start?: boolean; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx index a9c8b6d4040ad..875590d0f9ee4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx @@ -45,6 +45,7 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ jobType, lambda, method, + maxNumThreads, maxTrees, modelMemoryLimit, nNeighbors, @@ -214,6 +215,15 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ ); } + if (maxNumThreads !== undefined) { + advancedFirstCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.maxNumThreads', { + defaultMessage: 'Maximum number of threads', + }), + description: `${maxNumThreads}`, + }); + } + return ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 21b0d3d7dd89e..11184afb0e715 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -9,7 +9,7 @@ import { EuiAccordion, EuiFieldNumber, EuiFieldText, - EuiFlexGroup, + EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiSelect, @@ -57,6 +57,7 @@ export const AdvancedStepForm: FC = ({ gamma, jobType, lambda, + maxNumThreads, maxTrees, method, modelMemoryLimit, @@ -82,7 +83,8 @@ export const AdvancedStepForm: FC = ({ const isStepInvalid = mmlInvalid || Object.keys(advancedParamErrors).length > 0 || - fetchingAdvancedParamErrors === true; + fetchingAdvancedParamErrors === true || + maxNumThreads === 0; useEffect(() => { setFetchingAdvancedParamErrors(true); @@ -112,6 +114,7 @@ export const AdvancedStepForm: FC = ({ featureInfluenceThreshold, gamma, lambda, + maxNumThreads, maxTrees, method, nNeighbors, @@ -123,7 +126,7 @@ export const AdvancedStepForm: FC = ({ const outlierDetectionAdvancedConfig = ( - + = ({ /> - + = ({ const regAndClassAdvancedConfig = ( - + = ({ /> - + = ({ })} - + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && outlierDetectionAdvancedConfig} {isRegOrClassJob && regAndClassAdvancedConfig} {jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && ( - + = ({ )} - + = ({ /> - + + + + setFormState({ + maxNumThreads: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={1} + value={getNumberValue(maxNumThreads)} + /> + + + = ({ initialIsOpen={false} data-test-subj="mlAnalyticsCreateJobWizardHyperParametersSection" > - + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( = ({ advancedParamErrors={advancedParamErrors} /> )} - + = ({ actions, state, advancedParamErrors return ( - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedPara return ( - + = ({ actions, state, advancedPara /> - + = ({ actions, state, advancedPara /> - + = ({ actions, state, advancedPara /> - + new DataLoader(indexPattern, toastNotifications), [ + indexPattern, + ]); + const fetchColumnChartsData = async function () { try { - const columnChartsData = await fetchChartsData( - indexPattern.title, - ml.esSearch, - query, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + query ); dataGrid.setColumnCharts(columnChartsData); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index 796670f6a864d..98dd40986e32b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; @@ -12,16 +12,17 @@ import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { - fetchChartsData, getDataGridSchemasFromFieldTypes, + getFieldType, showDataGridColumnChartErrorMessageToast, useDataGrid, useRenderCellValue, UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; import { @@ -72,14 +73,23 @@ export const useExplorationResults = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined ? new DataLoader(indexPattern, toastNotifications) : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index beb6836bf801f..90294a09c0adc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { useColorRange, COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; import { - fetchChartsData, + getFieldType, getDataGridSchemasFromFieldTypes, showDataGridColumnChartErrorMessageToast, useDataGrid, @@ -24,7 +26,6 @@ import { UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; @@ -79,14 +80,25 @@ export const useOutlierData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined + ? new DataLoader(indexPattern, getToastNotifications()) + : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx index 280ec544c1e5e..13f3805cdf613 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_clone/clone_button.tsx @@ -255,6 +255,10 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo optional: true, formKey: 'modelMemoryLimit', }, + max_num_threads: { + optional: true, + formKey: 'maxNumThreads', + }, }); /** diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx index 728f53bf69ee2..4b708d48ca0ec 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty, + EuiFieldNumber, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -52,6 +53,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } const [description, setDescription] = useState(config.description || ''); const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit); const [mmlValidationError, setMmlValidationError] = useState(); + const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads); const { services: { notifications }, @@ -59,7 +61,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } const { refresh } = useRefreshAnalyticsList(); // Disable if mml is not valid - const updateButtonDisabled = mmlValidationError !== undefined; + const updateButtonDisabled = mmlValidationError !== undefined || maxNumThreads === 0; useEffect(() => { if (mmLValidator === undefined) { @@ -93,7 +95,8 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } allow_lazy_start: allowLazyStart, description, }, - modelMemoryLimit && { model_memory_limit: modelMemoryLimit } + modelMemoryLimit && { model_memory_limit: modelMemoryLimit }, + maxNumThreads && { max_num_threads: maxNumThreads } ); try { @@ -210,7 +213,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } helpText={ state !== DATA_FRAME_TASK_STATE.STOPPED && i18n.translate('xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryHelpText', { - defaultMessage: 'Model memory limit cannot be edited while the job is running.', + defaultMessage: 'Model memory limit cannot be edited until the job has stopped.', }) } label={i18n.translate( @@ -236,6 +239,49 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } )} /> + + + setMaxNumThreads(e.target.value === '' ? undefined : +e.target.value) + } + step={1} + min={1} + readOnly={state !== DATA_FRAME_TASK_STATE.STOPPED} + value={maxNumThreads} + /> + 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 0d425c8ead4a2..68a3613f91b5e 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 @@ -23,6 +23,7 @@ export enum DEFAULT_MODEL_MEMORY_LIMIT { } export const DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES = 0; +export const DEFAULT_MAX_NUM_THREADS = 1; export const UNSET_CONFIG_ITEM = '--'; export type EsIndexName = string; @@ -68,6 +69,7 @@ export interface State { jobConfigQueryString: string | undefined; lambda: number | undefined; loadingFieldOptions: boolean; + maxNumThreads: undefined | number; maxTrees: undefined | number; method: undefined | string; modelMemoryLimit: string | undefined; @@ -134,6 +136,7 @@ export const getInitialState = (): State => ({ jobConfigQueryString: undefined, lambda: undefined, loadingFieldOptions: false, + maxNumThreads: DEFAULT_MAX_NUM_THREADS, maxTrees: undefined, method: undefined, modelMemoryLimit: undefined, @@ -200,6 +203,10 @@ export const getJobConfigFromFormState = ( model_memory_limit: formState.modelMemoryLimit, }; + if (formState.maxNumThreads !== undefined) { + jobConfig.max_num_threads = formState.maxNumThreads; + } + const resultsFieldEmpty = typeof formState?.resultsField === 'string' && formState?.resultsField.trim() === ''; @@ -291,6 +298,7 @@ export function getCloneFormStateFromJobConfig( ? analyticsJobConfig.source.index.join(',') : analyticsJobConfig.source.index, modelMemoryLimit: analyticsJobConfig.model_memory_limit, + maxNumThreads: analyticsJobConfig.max_num_threads, includes: analyticsJobConfig.analyzed_fields.includes, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts index 5618f701e4c5f..50278c300d103 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts @@ -5,4 +5,4 @@ */ export { FieldVisConfig } from './field_vis_config'; -export { FieldRequestConfig } from './request'; +export { FieldHistogramRequestConfig, FieldRequestConfig } from './request'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts index 9a886cbc899c2..fd4888b8729c1 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KBN_FIELD_TYPES } from '../../../../../../../../src/plugins/data/public'; + import { ML_JOB_FIELD_TYPES } from '../../../../../common/constants/field_types'; export interface FieldRequestConfig { @@ -11,3 +13,8 @@ export interface FieldRequestConfig { type: ML_JOB_FIELD_TYPES; cardinality: number; } + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts index a08821c65bfe7..34f86ffa18788 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts @@ -6,15 +6,17 @@ import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../../util/dependency_cache'; +import { CoreSetup } from 'src/core/public'; + import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; import { SavedSearchQuery } from '../../../contexts/ml'; import { OMIT_FIELDS } from '../../../../../common/constants/field_types'; import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../common/constants/field_histograms'; import { ml } from '../../../services/ml_api_service'; -import { FieldRequestConfig } from '../common'; +import { FieldHistogramRequestConfig, FieldRequestConfig } from '../common'; // Maximum number of examples to obtain for text type fields. const MAX_EXAMPLES_DEFAULT: number = 10; @@ -23,10 +25,15 @@ export class DataLoader { private _indexPattern: IndexPattern; private _indexPatternTitle: IndexPatternTitle = ''; private _maxExamples: number = MAX_EXAMPLES_DEFAULT; + private _toastNotifications: CoreSetup['notifications']['toasts']; - constructor(indexPattern: IndexPattern, kibanaConfig: any) { + constructor( + indexPattern: IndexPattern, + toastNotifications: CoreSetup['notifications']['toasts'] + ) { this._indexPattern = indexPattern; this._indexPatternTitle = indexPattern.title; + this._toastNotifications = toastNotifications; } async loadOverallData( @@ -90,10 +97,24 @@ export class DataLoader { return stats; } + async loadFieldHistograms( + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ): Promise { + const stats = await ml.getVisualizerFieldHistograms({ + indexPatternTitle: this._indexPatternTitle, + query, + fields, + samplerShardSize, + }); + + return stats; + } + displayError(err: any) { - const toastNotifications = getToastNotifications(); if (err.statusCode === 500) { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.dataLoader.internalServerErrorMessage', { defaultMessage: 'Error loading data in index {index}. {message}. ' + @@ -105,7 +126,7 @@ export class DataLoader { }) ); } else { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.page.errorLoadingDataMessage', { defaultMessage: 'Error loading data in index {index}. {message}', values: { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 97b4043c9fd64..3c332d305d7e9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState } from 'react'; import { merge } from 'rxjs'; import { i18n } from '@kbn/i18n'; @@ -43,6 +43,7 @@ import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { getToastNotifications } from '../../util/dependency_cache'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -107,7 +108,10 @@ export const Page: FC = () => { autoRefreshSelector: true, }); - const dataLoader = new DataLoader(currentIndexPattern, kibanaConfig); + const dataLoader = useMemo(() => new DataLoader(currentIndexPattern, getToastNotifications()), [ + currentIndexPattern, + ]); + const [globalState, setGlobalState] = useUrlState('_g'); useEffect(() => { if (globalState?.time !== undefined) { diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap index 16b5ecc8a4600..4adaac1319d53 100644 --- a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap +++ b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; +exports[`ExplorerSwimlane Overall swimlane 1`] = `"
Overall
2017-02-07T00:00:00Z2017-02-07T00:30:00Z2017-02-07T01:00:00Z2017-02-07T01:30:00Z2017-02-07T02:00:00Z2017-02-07T02:30:00Z2017-02-07T03:00:00Z2017-02-07T03:30:00Z2017-02-07T04:00:00Z2017-02-07T04:30:00Z2017-02-07T05:00:00Z2017-02-07T05:30:00Z2017-02-07T06:00:00Z2017-02-07T06:30:00Z2017-02-07T07:00:00Z2017-02-07T07:30:00Z2017-02-07T08:00:00Z2017-02-07T08:30:00Z2017-02-07T09:00:00Z2017-02-07T09:30:00Z2017-02-07T10:00:00Z2017-02-07T10:30:00Z2017-02-07T11:00:00Z2017-02-07T11:30:00Z2017-02-07T12:00:00Z2017-02-07T12:30:00Z2017-02-07T13:00:00Z2017-02-07T13:30:00Z2017-02-07T14:00:00Z2017-02-07T14:30:00Z2017-02-07T15:00:00Z2017-02-07T15:30:00Z2017-02-07T16:00:00Z
"`; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index e00e2e1e1e2eb..45dada84de20a 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useMemo, useRef, useState } from 'react'; +import React, { FC, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; -import DragSelect from 'dragselect'; import { EuiPanel, EuiPopover, @@ -22,21 +21,17 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { DRAG_SELECT_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; +import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; import { AddToDashboardControl } from './add_to_dashboard_control'; import { useMlKibana } from '../contexts/kibana'; import { TimeBuckets } from '../util/time_buckets'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; -import { - ALLOW_CELL_RANGE_SELECTION, - dragSelect$, - explorerService, -} from './explorer_dashboard_service'; +import { explorerService } from './explorer_dashboard_service'; import { ExplorerState } from './reducers/explorer_reducer'; import { hasMatchingPoints } from './has_matching_points'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; import { SwimlaneContainer } from './swimlane_container'; -import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; import { NoOverallData } from './components/no_overall_data'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { @@ -63,10 +58,6 @@ export const AnomalyTimeline: FC = React.memo( const [isMenuOpen, setIsMenuOpen] = useState(false); const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); - const isSwimlaneSelectActive = useRef(false); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - const disableDragSelectOnMouseLeave = useRef(true); - const canEditDashboards = capabilities.dashboard?.createNew ?? false; const timeBuckets = useMemo(() => { @@ -78,48 +69,6 @@ export const AnomalyTimeline: FC = React.memo( }); }, [uiSettings]); - const dragSelect = useMemo( - () => - new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.querySelectorAll('.sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - disableDragSelectOnMouseLeave.current = true; - }, - onDragStart(e) { - let target = e.target as HTMLElement; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode as HTMLElement; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - disableDragSelectOnMouseLeave.current = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }), - [] - ); - const { filterActive, filteredFields, @@ -138,42 +87,6 @@ export const AnomalyTimeline: FC = React.memo( loading, } = explorerState; - const setSwimlaneSelectActive = useCallback((active: boolean) => { - if (isSwimlaneSelectActive.current && !active && disableDragSelectOnMouseLeave.current) { - dragSelect.stop(); - isSwimlaneSelectActive.current = active; - return; - } - if (!isSwimlaneSelectActive.current && active) { - dragSelect.start(); - dragSelect.clearSelection(); - dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); - isSwimlaneSelectActive.current = active; - } - }, []); - const onSwimlaneEnterHandler = () => setSwimlaneSelectActive(true); - const onSwimlaneLeaveHandler = () => setSwimlaneSelectActive(false); - - // Listens to render updates of the swimlanes to update dragSelect - const swimlaneRenderDoneListener = useCallback(() => { - dragSelect.clearSelection(); - dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); - }, []); - - // Listener for click events in the swimlane to load corresponding anomaly data. - const swimlaneCellClick = useCallback( - (selectedCellsUpdate: any) => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCellsUpdate).length === 0) { - setSelectedCells(); - } else { - setSelectedCells(selectedCellsUpdate); - } - }, - [setSelectedCells] - ); - const menuItems = useMemo(() => { const items = []; if (canEditDashboards) { @@ -193,6 +106,19 @@ export const AnomalyTimeline: FC = React.memo( return items; }, [canEditDashboards]); + // If selecting a cell in the 'view by' swimlane, indicate the corresponding time in the Overall swimlane. + const overallCellSelection: AppStateSelectedCells | undefined = useMemo(() => { + if (!selectedCells) return; + + if (selectedCells.type === SWIMLANE_TYPE.OVERALL) return selectedCells; + + return { + type: SWIMLANE_TYPE.OVERALL, + lanes: [OVERALL_LABEL], + times: selectedCells.times, + }; + }, [selectedCells]); + return ( <> @@ -284,86 +210,68 @@ export const AnomalyTimeline: FC = React.memo( -
+ filterActive={filterActive} + maskAll={maskAll} + timeBuckets={timeBuckets} + swimlaneData={overallSwimlaneData as OverallSwimlaneData} + swimlaneType={SWIMLANE_TYPE.OVERALL} + selection={overallCellSelection} + onCellsSelection={setSelectedCells} + onResize={explorerService.setSwimlaneContainerWidth} + isLoading={loading} + noDataWarning={} + /> + + + + {viewBySwimlaneOptions.length > 0 && ( explorerService.setSwimlaneContainerWidth(width)} - isLoading={loading} - noDataWarning={} + onCellsSelection={setSelectedCells} + onResize={explorerService.setSwimlaneContainerWidth} + fromPage={viewByFromPage} + perPage={viewByPerPage} + swimlaneLimit={swimlaneLimit} + onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { + if (perPageUpdate) { + explorerService.setViewByPerPage(perPageUpdate); + } + if (fromPageUpdate) { + explorerService.setViewByFromPage(fromPageUpdate); + } + }} + isLoading={loading || viewBySwimlaneDataLoading} + noDataWarning={ + typeof viewBySwimlaneFieldName === 'string' ? ( + viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( + + ) : ( + + ) + ) : null + } /> -
- - - - {viewBySwimlaneOptions.length > 0 && ( - <> - <> -
- explorerService.setSwimlaneContainerWidth(width)} - fromPage={viewByFromPage} - perPage={viewByPerPage} - swimlaneLimit={swimlaneLimit} - onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { - if (perPageUpdate) { - explorerService.setViewByPerPage(perPageUpdate); - } - if (fromPageUpdate) { - explorerService.setViewByFromPage(fromPageUpdate); - } - }} - isLoading={loading || viewBySwimlaneDataLoading} - noDataWarning={ - typeof viewBySwimlaneFieldName === 'string' ? ( - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( - - ) : ( - - ) - ) : null - } - /> -
- - )}
{isAddDashboardsActive && selectedJobs && ( diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 21e13cb029d69..7440bf3213413 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -62,6 +62,11 @@ export const MAX_INFLUENCER_FIELD_NAMES = 50; export const VIEW_BY_JOB_LABEL = i18n.translate('xpack.ml.explorer.jobIdLabel', { defaultMessage: 'job ID', }); + +export const OVERALL_LABEL = i18n.translate('xpack.ml.explorer.overallLabel', { + defaultMessage: 'Overall', +}); + /** * Hard limitation for the size of terms * aggregations on influencers values. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index 1429bf0858361..4d697bcda1a06 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -18,17 +18,12 @@ import { DeepPartial } from '../../../common/types/common'; import { jobSelectionActionCreator } from './actions'; import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; -import { DRAG_SELECT_ACTION, EXPLORER_ACTION } from './explorer_constants'; +import { EXPLORER_ACTION } from './explorer_constants'; import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils'; import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; export const ALLOW_CELL_RANGE_SELECTION = true; -export const dragSelect$ = new Subject<{ - action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; - elements?: any[]; -}>(); - type ExplorerAction = Action | Observable; export const explorerAction$ = new Subject(); @@ -54,7 +49,7 @@ const explorerState$: Observable = explorerFilteredAction$.pipe( shareReplay(1) ); -interface ExplorerAppState { +export interface ExplorerAppState { mlExplorerSwimlane: { selectedType?: string; selectedLanes?: string[]; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx index df450a33a52df..f7ae5f232999e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.test.tsx @@ -10,7 +10,6 @@ import moment from 'moment-timezone'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { dragSelect$ } from './explorer_dashboard_service'; import { ExplorerSwimlane } from './explorer_swimlane'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { ChartTooltipService } from '../components/chart_tooltip'; @@ -27,13 +26,15 @@ jest.mock('d3', () => { }; }); -jest.mock('./explorer_dashboard_service', () => ({ - dragSelect$: { - subscribe: jest.fn(() => ({ - unsubscribe: jest.fn(), - })), - }, -})); +jest.mock('@elastic/eui', () => { + return { + htmlIdGenerator: jest.fn(() => { + return jest.fn(() => { + return 'test-gen-id'; + }); + }), + }; +}); function getExplorerSwimlaneMocks() { const swimlaneData = ({ laneLabels: [] } as unknown) as OverallSwimlaneData; @@ -52,6 +53,7 @@ function getExplorerSwimlaneMocks() { timeBuckets, swimlaneData, tooltipService, + parentRef: {} as React.RefObject, }; } @@ -74,50 +76,42 @@ describe('ExplorerSwimlane', () => { test('Minimal initialization', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); expect(wrapper.html()).toBe( - `
` + - `
` + '
' ); // test calls to mock functions // @ts-ignore - expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0); - // @ts-ignore expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); // @ts-ignore expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1); }); test('Overall swimlane', () => { const mocks = getExplorerSwimlaneMocks(); - const swimlaneRenderDoneListener = jest.fn(); const wrapper = mountWithIntl( ); @@ -125,13 +119,8 @@ describe('ExplorerSwimlane', () => { // test calls to mock functions // @ts-ignore - expect(dragSelect$.subscribe.mock.calls.length).toBeGreaterThanOrEqual(1); - // @ts-ignore - expect(wrapper.instance().dragSelectSubscriber.unsubscribe.mock.calls).toHaveLength(0); - // @ts-ignore expect(mocks.timeBuckets.setInterval.mock.calls.length).toBeGreaterThanOrEqual(1); // @ts-ignore expect(mocks.timeBuckets.getScaledDateFormat.mock.calls.length).toBeGreaterThanOrEqual(1); - expect(swimlaneRenderDoneListener.mock.calls.length).toBeGreaterThanOrEqual(1); }); }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index aa386288ac7e0..0f92278e90445 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -13,15 +13,17 @@ import './_explorer.scss'; import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; +import DragSelect from 'dragselect'; import { i18n } from '@kbn/i18n'; -import { Subscription } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; import { TooltipValue } from '@elastic/charts'; +import { htmlIdGenerator } from '@elastic/eui'; import { formatHumanReadableDateTime } from '../util/date_utils'; import { numTicksForDateFormat } from '../util/chart_utils'; import { getSeverityColor } from '../../../common/util/anomaly_utils'; import { mlEscape } from '../util/string_utils'; -import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service'; +import { ALLOW_CELL_RANGE_SELECTION } from './explorer_dashboard_service'; import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; @@ -29,7 +31,7 @@ import { ChartTooltipService, ChartTooltipValue, } from '../components/chart_tooltip/chart_tooltip_service'; -import { OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; +import { AppStateSelectedCells, OverallSwimlaneData, ViewBySwimLaneData } from './explorer_utils'; const SCSS = { mlDragselectDragging: 'mlDragselectDragging', @@ -56,7 +58,6 @@ export interface ExplorerSwimlaneProps { filterActive?: boolean; maskAll?: boolean; timeBuckets: InstanceType; - swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; selection?: { @@ -64,8 +65,15 @@ export interface ExplorerSwimlaneProps { type: string; times: number[]; }; - swimlaneRenderDoneListener?: Function; + onCellsSelection: (payload?: AppStateSelectedCells) => void; tooltipService: ChartTooltipService; + 'data-test-subj'?: string; + /** + * We need to be aware of the parent element in order to set + * the height so the swim lane widget doesn't jump during loading + * or page changes. + */ + parentRef: React.RefObject; } export class ExplorerSwimlane extends React.Component { @@ -78,13 +86,70 @@ export class ExplorerSwimlane extends React.Component { rootNode = React.createRef(); + isSwimlaneSelectActive = false; + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + disableDragSelectOnMouseLeave = true; + + dragSelect$ = new Subject<{ + action: typeof DRAG_SELECT_ACTION[keyof typeof DRAG_SELECT_ACTION]; + elements?: any[]; + }>(); + + /** + * Unique id for swim lane instance + */ + rootNodeId = htmlIdGenerator()(); + + /** + * Initialize drag select instance + */ + dragSelect = new DragSelect({ + selectorClass: 'ml-swimlane-selector', + selectables: document.querySelectorAll(`#${this.rootNodeId} .sl-cell`), + callback: (elements) => { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; + } + + if (elements.length > 0) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); + } + + this.disableDragSelectOnMouseLeave = true; + }, + onDragStart: (e) => { + // make sure we don't trigger text selection on label + e.preventDefault(); + let target = e.target as HTMLElement; + while (target && target !== document.body && !target.classList.contains('sl-cell')) { + target = target.parentNode as HTMLElement; + } + if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + this.disableDragSelectOnMouseLeave = false; + } + }, + onElementSelect: () => { + if (ALLOW_CELL_RANGE_SELECTION) { + this.dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); + } + }, + }); + componentDidMount() { // property for data comparison to be able to filter // consecutive click events with the same data. let previousSelectedData: any = null; // Listen for dragSelect events - this.dragSelectSubscriber = dragSelect$.subscribe(({ action, elements = [] }) => { + this.dragSelectSubscriber = this.dragSelect$.subscribe(({ action, elements = [] }) => { const element = d3.select(this.rootNode.current!.parentNode!); const { swimlaneType } = this.props; @@ -154,7 +219,7 @@ export class ExplorerSwimlane extends React.Component { } selectCell(cellsToSelect: any[], { laneLabels, bucketScore, times }: SelectedData) { - const { selection, swimlaneCellClick = () => {}, swimlaneData, swimlaneType } = this.props; + const { selection, swimlaneData, swimlaneType } = this.props; let triggerNewSelection = false; @@ -184,7 +249,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - swimlaneCellClick({}); + this.swimlaneCellClick(); return; } @@ -194,7 +259,7 @@ export class ExplorerSwimlane extends React.Component { times: d3.extent(times), type: swimlaneType, }; - swimlaneCellClick(selectedCells); + this.swimlaneCellClick(selectedCells); } highlightOverall(times: number[]) { @@ -208,10 +273,8 @@ export class ExplorerSwimlane extends React.Component { } highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { - const { swimlaneType } = this.props; - - // This selects both overall and viewby swimlane - const wrapper = d3.selectAll('.mlExplorerSwimlane'); + // This selects the embeddable container + const wrapper = d3.select(`#${this.rootNodeId}`); wrapper.selectAll('.lane-label').classed('lane-label-masked', true); wrapper @@ -232,13 +295,12 @@ export class ExplorerSwimlane extends React.Component { rootParent.selectAll('.lane-label').classed('lane-label-masked', function (this: HTMLElement) { return laneLabels.indexOf(d3.select(this).text()) === -1; }); - - if (swimlaneType === 'viewBy') { - // If selecting a cell in the 'view by' swimlane, indicate the corresponding time in the Overall swimlane. - this.highlightOverall(times); - } } + /** + * TODO should happen with props instead of imperative check + * @param maskAll + */ maskIrrelevantSwimlanes(maskAll: boolean) { if (maskAll === true) { // This selects both overall and viewby swimlane @@ -288,7 +350,6 @@ export class ExplorerSwimlane extends React.Component { filterActive, maskAll, timeBuckets, - swimlaneCellClick, swimlaneData, swimlaneType, selection, @@ -358,9 +419,12 @@ export class ExplorerSwimlane extends React.Component { const numBuckets = Math.round((endTime - startTime) / stepSecs); const cellHeight = 30; const height = (lanes.length + 1) * cellHeight - 10; - const laneLabelWidth = 170; + // Set height for the wrapper element + if (this.props.parentRef.current) { + this.props.parentRef.current.style.height = `${height + 20}px`; + } - element.style('height', `${height + 20}px`); + const laneLabelWidth = 170; const swimlanes = element.select('.ml-swimlanes'); swimlanes.html(''); @@ -413,8 +477,8 @@ export class ExplorerSwimlane extends React.Component { } }) .on('click', () => { - if (selection && typeof selection.lanes !== 'undefined' && swimlaneCellClick) { - swimlaneCellClick({}); + if (selection && typeof selection.lanes !== 'undefined') { + this.swimlaneCellClick(); } }) .each(function (this: HTMLElement) { @@ -567,9 +631,7 @@ export class ExplorerSwimlane extends React.Component { element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); } - if (this.props.swimlaneRenderDoneListener) { - this.props.swimlaneRenderDoneListener(); - } + this.swimlaneRenderDoneListener(); if ( (swimlaneType !== selectedType || @@ -593,10 +655,7 @@ export class ExplorerSwimlane extends React.Component { selectedTimeExtent[1] <= endTime ) { // Locate matching cell - look for exact time, otherwise closest before. - const swimlaneElements = element.select('.ml-swimlanes'); - const laneCells = swimlaneElements.selectAll( - `div[data-lane-label="${mlEscape(selectedLane)}"]` - ); + const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); laneCells.each(function (this: HTMLElement) { const cell = d3.select(this); @@ -632,9 +691,58 @@ export class ExplorerSwimlane extends React.Component { return true; } + /** + * Listener for click events in the swim lane and execute a prop callback. + * @param selectedCellsUpdate + */ + swimlaneCellClick(selectedCellsUpdate?: AppStateSelectedCells) { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (!selectedCellsUpdate) { + this.props.onCellsSelection(); + } else { + this.props.onCellsSelection(selectedCellsUpdate); + } + } + + /** + * Listens to render updates of the swim lanes to update dragSelect + */ + swimlaneRenderDoneListener() { + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); + } + + setSwimlaneSelectActive(active: boolean) { + if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { + this.dragSelect.stop(); + this.isSwimlaneSelectActive = active; + return; + } + if (!this.isSwimlaneSelectActive && active) { + this.dragSelect.start(); + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.querySelectorAll(`#${this.rootNodeId} .sl-cell`)); + this.isSwimlaneSelectActive = active; + } + } + render() { const { swimlaneType } = this.props; - return
; + return ( +
+
+
+ ); } } diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts index 05fdb52e1ccb2..0faa20295996c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -7,6 +7,7 @@ import { Moment } from 'moment'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { SwimlaneType } from './explorer_constants'; interface ClearedSelectedAnomaliesState { selectedCells: undefined; @@ -182,9 +183,9 @@ export declare interface FilterData { } export declare interface AppStateSelectedCells { - type: string; + type: SwimlaneType; lanes: string[]; times: number[]; - showTopFieldValues: boolean; - viewByFieldName: string; + showTopFieldValues?: boolean; + viewByFieldName?: string; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index 49f5794273a04..4d5ad65065fc3 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEqual } from 'lodash'; import { ActionPayload } from '../../explorer_dashboard_service'; import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; @@ -17,7 +18,11 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, - viewByFromPage: 1, + // currently job selection set asynchronously so + // we want to preserve the pagination from the url state + // on initial load + viewByFromPage: + !state.selectedJobs || isEqual(state.selectedJobs, selectedJobs) ? state.viewByFromPage : 1, }; // clear filter if selected jobs have no influencers diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx index e34e1d26c9cab..51ea0f00d5f6a 100644 --- a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useState } from 'react'; +import React, { FC, useCallback, useRef, useState } from 'react'; import { EuiText, EuiLoadingChart, @@ -49,7 +49,7 @@ export function isViewBySwimLaneData(arg: any): arg is ViewBySwimLaneData { * @constructor */ export const SwimlaneContainer: FC< - Omit & { + Omit & { onResize: (width: number) => void; fromPage?: number; perPage?: number; @@ -70,6 +70,7 @@ export const SwimlaneContainer: FC< ...props }) => { const [chartWidth, setChartWidth] = useState(0); + const wrapperRef = useRef(null); const resizeHandler = useCallback( throttle((e: { width: number; height: number }) => { @@ -111,36 +112,40 @@ export const SwimlaneContainer: FC< data-test-subj="mlSwimLaneContainer" > - - {showSwimlane && !isLoading && ( - - {(tooltipService) => ( - + + {showSwimlane && !isLoading && ( + + {(tooltipService) => ( + + )} + + )} + {isLoading && ( + + - )} - - )} - {isLoading && ( - - + )} + {!isLoading && !showSwimlane && ( + {noDataWarning}} /> - - )} - {!isLoading && !showSwimlane && ( - {noDataWarning}} - /> - )} - + )} + +
+ {isPaginationVisible && ( = ({ jobsWithTim const [lastRefresh, setLastRefresh] = useState(0); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); - const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz()); + const { jobIds } = useJobSelection(jobsWithTimeRange); const refresh = useRefresh(); useEffect(() => { @@ -109,6 +109,14 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, [globalState?.time?.from, globalState?.time?.to]); + useEffect(() => { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + } else { + explorerService.clearJobs(); + } + }, [JSON.stringify(jobIds)]); + useEffect(() => { const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; if (viewByFieldName !== undefined) { @@ -119,15 +127,17 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim if (filterData !== undefined) { explorerService.setFilterData(filterData); } - }, []); - useEffect(() => { - if (jobIds.length > 0) { - explorerService.updateJobSelection(jobIds); - } else { - explorerService.clearJobs(); + const viewByPerPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByPerPage; + if (viewByPerPage) { + explorerService.setViewByPerPage(viewByPerPage); } - }, [JSON.stringify(jobIds)]); + + const viewByFromPage = (appState as ExplorerAppState)?.mlExplorerSwimlane?.viewByFromPage; + if (viewByFromPage) { + explorerService.setViewByFromPage(viewByFromPage); + } + }, []); const [explorerData, loadExplorerData] = useExplorerData(); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/routing/use_refresh.ts b/x-pack/plugins/ml/public/application/routing/use_refresh.ts index c247fd9765e96..539ce6f88a421 100644 --- a/x-pack/plugins/ml/public/application/routing/use_refresh.ts +++ b/x-pack/plugins/ml/public/application/routing/use_refresh.ts @@ -6,7 +6,7 @@ import { useObservable } from 'react-use'; import { merge } from 'rxjs'; -import { map, skip } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { useMemo } from 'react'; import { annotationsRefresh$ } from '../services/annotations_service'; @@ -29,9 +29,7 @@ export const useRefresh = () => { return merge( mlTimefilterRefresh$, timefilter.getTimeUpdate$().pipe( - // skip initially emitted value - skip(1), - map((_) => { + map(() => { const { from, to } = timefilter.getTime(); return { lastRefresh: Date.now(), timeRange: { start: from, end: to } }; }) diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index f2e362f754f2b..2bdb758be874c 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -5,7 +5,6 @@ */ import { IUiSettingsClient } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import { TimefilterContract, TimeRange, @@ -18,7 +17,7 @@ import { SwimlaneData, ViewBySwimLaneData, } from '../explorer/explorer_utils'; -import { VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants'; +import { OVERALL_LABEL, VIEW_BY_JOB_LABEL } from '../explorer/explorer_constants'; import { MlResultsService } from './results_service'; /** @@ -288,9 +287,7 @@ export class AnomalyTimelineService { searchBounds: Required, interval: number ): OverallSwimlaneData { - const overallLabel = i18n.translate('xpack.ml.explorer.overallLabel', { - defaultMessage: 'Overall', - }); + const overallLabel = OVERALL_LABEL; const dataset: OverallSwimlaneData = { laneLabels: [overallLabel], points: [], @@ -302,7 +299,7 @@ export class AnomalyTimelineService { // Store the earliest and latest times of the data returned by the ES aggregations, // These will be used for calculating the earliest and latest times for the swim lane charts. Object.entries(scoresByTime).forEach(([timeMs, score]) => { - const time = Number(timeMs) / 1000; + const time = +timeMs / 1000; dataset.points.push({ laneLabel: overallLabel, time, @@ -346,7 +343,7 @@ export class AnomalyTimelineService { maxScoreByLaneLabel[influencerFieldValue] = 0; Object.entries(influencerData).forEach(([timeMs, anomalyScore]) => { - const time = Number(timeMs) / 1000; + const time = +timeMs / 1000; dataset.points.push({ laneLabel: influencerFieldValue, time, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index d1b6f95f32bed..599e4d4bb8a10 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -27,7 +27,10 @@ import { ModelSnapshot, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; +import { + FieldHistogramRequestConfig, + FieldRequestConfig, +} from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; import { getHttp } from '../../util/dependency_cache'; @@ -494,6 +497,30 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, + getVisualizerFieldHistograms({ + indexPatternTitle, + query, + fields, + samplerShardSize, + }: { + indexPatternTitle: string; + query: any; + fields: FieldHistogramRequestConfig[]; + samplerShardSize?: number; + }) { + const body = JSON.stringify({ + query, + fields, + samplerShardSize, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_histograms/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + getVisualizerOverallStats({ indexPatternTitle, query, diff --git a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts index bc65ebe7a5fac..e2313de5c88b0 100644 --- a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -20,7 +20,7 @@ import { import { ml } from './ml_api_service'; import { getIndexPatternAndSavedSearch } from '../util/index_utils'; -// called in the angular routing resolve block to initialize the +// called in the routing resolve block to initialize the // newJobCapsService with the currently selected index pattern export function loadNewJobCapabilities( indexPatternId: string, diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index 83070a5d94ba0..9f96b73d67c57 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -14,8 +14,8 @@ import { EmbeddableInput, EmbeddableOutput, IContainer, + IEmbeddable, } from '../../../../../../src/plugins/embeddable/public'; -import { MlStartDependencies } from '../../plugin'; import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { JobId } from '../../../common/types/anomaly_detection_jobs'; @@ -27,6 +27,9 @@ import { TimeRange, } from '../../../../../../src/plugins/data/common'; import { SwimlaneType } from '../../application/explorer/explorer_constants'; +import { MlDependencies } from '../../application/app'; +import { AppStateSelectedCells } from '../../application/explorer/explorer_utils'; +import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions/triggers'; export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; @@ -49,16 +52,26 @@ export interface AnomalySwimlaneEmbeddableCustomInput { timeRange: TimeRange; } +export interface EditSwimlanePanelContext { + embeddable: IEmbeddable; +} + +export interface SwimLaneDrilldownContext extends EditSwimlanePanelContext { + /** + * Optional data provided by swim lane selection + */ + data?: AppStateSelectedCells; +} + export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & AnomalySwimlaneEmbeddableCustomOutput; export interface AnomalySwimlaneEmbeddableCustomOutput { - jobIds: JobId[]; - swimlaneType: SwimlaneType; - viewBy?: string; perPage?: number; + fromPage?: number; + interval?: number; } export interface AnomalySwimlaneServices { @@ -68,7 +81,7 @@ export interface AnomalySwimlaneServices { export type AnomalySwimlaneEmbeddableServices = [ CoreStart, - MlStartDependencies, + MlDependencies, AnomalySwimlaneServices ]; @@ -82,16 +95,13 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< constructor( initialInput: AnomalySwimlaneEmbeddableInput, - private services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices], + public services: [CoreStart, MlDependencies, AnomalySwimlaneServices], parent?: IContainer ) { super( initialInput, { - jobIds: initialInput.jobIds, - swimlaneType: initialInput.swimlaneType, defaultTitle: initialInput.title, - ...(initialInput.viewBy ? { viewBy: initialInput.viewBy } : {}), }, parent ); @@ -107,12 +117,12 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< { - this.updateInput(input); - }} + onInputChange={this.updateInput.bind(this)} + onOutputChange={this.updateOutput.bind(this)} /> , node @@ -129,4 +139,8 @@ export class AnomalySwimlaneEmbeddable extends Embeddable< public reload() { this.reload$.next(); } + + public supportedTriggers() { + return [SWIM_LANE_SELECTION_TRIGGER as typeof SWIM_LANE_SELECTION_TRIGGER]; + } } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index 0d587b428d89b..14fbf77544b21 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -19,19 +19,22 @@ import { AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableServices, } from './anomaly_swimlane_embeddable'; -import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { AnomalyTimelineService } from '../../application/services/anomaly_timeline_service'; import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; import { mlApiServicesProvider } from '../../application/services/ml_api_service'; +import { MlPluginStart, MlStartDependencies } from '../../plugin'; +import { MlDependencies } from '../../application/app'; export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { public readonly type = ANOMALY_SWIMLANE_EMBEDDABLE_TYPE; - constructor(private getStartServices: StartServicesAccessor) {} + constructor( + private getStartServices: StartServicesAccessor + ) {} public async isEditable() { return true; @@ -64,7 +67,11 @@ export class AnomalySwimlaneEmbeddableFactory mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); - return [coreStart, pluginsStart, { anomalyDetectorService, anomalyTimelineService }]; + return [ + coreStart, + pluginsStart as MlDependencies, + { anomalyDetectorService, anomalyTimelineService }, + ]; } public async create( diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index be9a332e51dbc..e5a13adca05db 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx @@ -17,6 +17,7 @@ import { EuiModalHeaderTitle, EuiSelect, EuiFieldText, + EuiModal, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -33,7 +34,6 @@ export interface AnomalySwimlaneInitializerProps { panelTitle: string; swimlaneType: SwimlaneType; viewBy?: string; - limit?: number; }) => void; onCancel: () => void; } @@ -81,7 +81,7 @@ export const AnomalySwimlaneInitializer: FC = ( (swimlaneType === SWIMLANE_TYPE.VIEW_BY && !!viewBySwimlaneFieldName)); return ( -
+ = ( /> -
+ ); }; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx index 846a3f543c2d4..23045834eae5f 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.test.tsx @@ -6,18 +6,25 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { EmbeddableSwimLaneContainer } from './embeddable_swim_lane_container'; +import { + EmbeddableSwimLaneContainer, + ExplorerSwimlaneContainerProps, +} from './embeddable_swim_lane_container'; import { BehaviorSubject, Observable } from 'rxjs'; import { I18nProvider } from '@kbn/i18n/react'; import { + AnomalySwimlaneEmbeddable, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { CoreStart } from 'kibana/public'; -import { MlStartDependencies } from '../../plugin'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; import { SwimlaneContainer } from '../../application/explorer/swimlane_container'; +import { MlDependencies } from '../../application/app'; +import { uiActionsPluginMock } from 'src/plugins/ui_actions/public/mocks'; +import { TriggerContract } from 'src/plugins/ui_actions/public/triggers'; +import { TriggerId } from 'src/plugins/ui_actions/public'; jest.mock('./swimlane_input_resolver', () => ({ useSwimlaneInputResolver: jest.fn(() => { @@ -37,13 +44,30 @@ const defaultOptions = { wrapper: I18nProvider }; describe('ExplorerSwimlaneContainer', () => { let embeddableInput: BehaviorSubject>; let refresh: BehaviorSubject; - let services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + let services: jest.Mocked<[CoreStart, MlDependencies, AnomalySwimlaneServices]>; + let embeddableContext: AnomalySwimlaneEmbeddable; + let trigger: jest.Mocked>; + const onInputChange = jest.fn(); + const onOutputChange = jest.fn(); beforeEach(() => { + embeddableContext = { id: 'test-id' } as AnomalySwimlaneEmbeddable; embeddableInput = new BehaviorSubject({ id: 'test-swimlane-embeddable', } as Partial); + + trigger = ({ exec: jest.fn() } as unknown) as jest.Mocked>; + + const uiActionsMock = uiActionsPluginMock.createStartContract(); + uiActionsMock.getTrigger.mockReturnValue(trigger); + + services = ([ + {}, + { + uiActions: uiActionsMock, + }, + ] as unknown) as ExplorerSwimlaneContainerProps['services']; }); test('should render a swimlane with a valid embeddable input', async () => { @@ -74,12 +98,14 @@ describe('ExplorerSwimlaneContainer', () => { render( } services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); @@ -110,6 +136,7 @@ describe('ExplorerSwimlaneContainer', () => { const { findByText } = render( @@ -117,6 +144,7 @@ describe('ExplorerSwimlaneContainer', () => { services={services} refresh={refresh} onInputChange={onInputChange} + onOutputChange={onOutputChange} />, defaultOptions ); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 5d91bdb41df6a..8ee4e391fcdde 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState, useEffect } from 'react'; import { EuiCallOut } from '@elastic/eui'; import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MlStartDependencies } from '../../plugin'; import { + AnomalySwimlaneEmbeddable, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, @@ -22,25 +22,36 @@ import { isViewBySwimLaneData, SwimlaneContainer, } from '../../application/explorer/swimlane_container'; +import { AppStateSelectedCells } from '../../application/explorer/explorer_utils'; +import { MlDependencies } from '../../application/app'; +import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions/triggers'; export interface ExplorerSwimlaneContainerProps { id: string; + embeddableContext: AnomalySwimlaneEmbeddable; embeddableInput: Observable; - services: [CoreStart, MlStartDependencies, AnomalySwimlaneServices]; + services: [CoreStart, MlDependencies, AnomalySwimlaneServices]; refresh: Observable; - onInputChange: (output: Partial) => void; + onInputChange: (input: Partial) => void; + onOutputChange: (output: Partial) => void; } export const EmbeddableSwimLaneContainer: FC = ({ id, + embeddableContext, embeddableInput, services, refresh, onInputChange, + onOutputChange, }) => { const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); + const [{}, { uiActions }] = services; + + const [selectedCells, setSelectedCells] = useState(); + const [ swimlaneType, swimlaneData, @@ -58,6 +69,28 @@ export const EmbeddableSwimLaneContainer: FC = ( fromPage ); + useEffect(() => { + onOutputChange({ + perPage, + fromPage, + interval: swimlaneData?.interval, + }); + }, [perPage, fromPage, swimlaneData]); + + const onCellsSelection = useCallback( + (update?: AppStateSelectedCells) => { + setSelectedCells(update); + + if (update) { + uiActions.getTrigger(SWIM_LANE_SELECTION_TRIGGER).exec({ + embeddable: embeddableContext, + data: update, + }); + } + }, + [swimlaneData, perPage, fromPage] + ); + if (error) { return ( = ( data-test-subj="mlAnomalySwimlaneEmbeddableWrapper" > { - setChartWidth(width); - }} + onResize={setChartWidth} + selection={selectedCells} + onCellsSelection={onCellsSelection} onPaginationChange={(update) => { if (update.fromPage) { setFromPage(update.fromPage); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index 9ed6f88150f68..f17c779a00252 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -40,6 +40,7 @@ import { parseInterval } from '../../../common/util/parse_interval'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { isViewBySwimLaneData } from '../../application/explorer/swimlane_container'; import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; +import { CONTROLLED_BY_SWIM_LANE_FILTER } from '../../ui_actions/apply_influencer_filters_action'; const FETCH_RESULTS_DEBOUNCE_MS = 500; @@ -240,7 +241,9 @@ export function processFilters(filters: Filter[], query: Query) { const must = [inputQuery]; const mustNot = []; for (const filter of filters) { - if (filter.meta.disabled) continue; + // ignore disabled filters as well as created by swim lane selection + if (filter.meta.disabled || filter.meta.controlledBy === CONTROLLED_BY_SWIM_LANE_FILTER) + continue; const { meta: { negate, type, key: fieldName }, diff --git a/x-pack/plugins/ml/public/embeddables/index.ts b/x-pack/plugins/ml/public/embeddables/index.ts index 5e9d54645b516..db9f094d5721e 100644 --- a/x-pack/plugins/ml/public/embeddables/index.ts +++ b/x-pack/plugins/ml/public/embeddables/index.ts @@ -4,15 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'kibana/public'; import { AnomalySwimlaneEmbeddableFactory } from './anomaly_swimlane'; -import { MlPluginStart, MlStartDependencies } from '../plugin'; +import { MlCoreSetup } from '../plugin'; import { EmbeddableSetup } from '../../../../../src/plugins/embeddable/public'; -export function registerEmbeddables( - embeddable: EmbeddableSetup, - core: CoreSetup -) { +export function registerEmbeddables(embeddable: EmbeddableSetup, core: MlCoreSetup) { const anomalySwimlaneEmbeddableFactory = new AnomalySwimlaneEmbeddableFactory( core.getStartServices ); diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 7f7544a44efa7..449d8baa2a184 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -13,7 +13,7 @@ import { PluginInitializerContext, } from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; -import { SharePluginStart } from 'src/plugins/share/public'; +import { SharePluginSetup, SharePluginStart, UrlGeneratorState } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { DataPublicPluginStart } from 'src/plugins/data/public'; @@ -28,14 +28,16 @@ import { PLUGIN_ID, PLUGIN_ICON } from '../common/constants/app'; import { registerFeature } from './register_feature'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { registerEmbeddables } from './embeddables'; -import { UiActionsSetup } from '../../../../src/plugins/ui_actions/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { registerMlUiActions } from './ui_actions'; import { KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; +import { MlUrlGenerator, MlUrlGeneratorState, ML_APP_URL_GENERATOR } from './url_generator'; export interface MlStartDependencies { data: DataPublicPluginStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + uiActions: UiActionsStart; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -47,13 +49,30 @@ export interface MlSetupDependencies { embeddable: EmbeddableSetup; uiActions: UiActionsSetup; kibanaVersion: string; - share: SharePluginStart; + share: SharePluginSetup; +} + +declare module '../../../../src/plugins/share/public' { + export interface UrlGeneratorStateMapping { + [ML_APP_URL_GENERATOR]: UrlGeneratorState; + } } +export type MlCoreSetup = CoreSetup; + export class MlPlugin implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} - setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { + setup(core: MlCoreSetup, pluginsSetup: MlSetupDependencies) { + const baseUrl = core.http.basePath.prepend('/app/ml'); + + pluginsSetup.share.urlGenerators.registerUrlGenerator( + new MlUrlGenerator({ + appBasePath: baseUrl, + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + core.application.register({ id: PLUGIN_ID, title: i18n.translate('xpack.ml.plugin.title', { @@ -80,7 +99,7 @@ export class MlPlugin implements Plugin { licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, - uiActions: pluginsSetup.uiActions, + uiActions: pluginsStart.uiActions, kibanaVersion, }, { @@ -96,10 +115,8 @@ export class MlPlugin implements Plugin { registerFeature(pluginsSetup.home); initManagementSection(pluginsSetup, core); - - registerMlUiActions(pluginsSetup.uiActions, core); - registerEmbeddables(pluginsSetup.embeddable, core); + registerMlUiActions(pluginsSetup.uiActions, core); return {}; } @@ -113,6 +130,7 @@ export class MlPlugin implements Plugin { }); return {}; } + public stop() {} } diff --git a/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx new file mode 100644 index 0000000000000..3af39993d39fd --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_influencer_filters_action.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; +import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../application/explorer/explorer_constants'; +import { Filter, FilterStateStore } from '../../../../../src/plugins/data/common'; + +export const APPLY_INFLUENCER_FILTERS_ACTION = 'applyInfluencerFiltersAction'; + +export const CONTROLLED_BY_SWIM_LANE_FILTER = 'anomaly-swim-lane'; + +export function createApplyInfluencerFiltersAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-to-current-view', + type: APPLY_INFLUENCER_FILTERS_ACTION, + getIconType(context: ActionContextMapping[typeof APPLY_INFLUENCER_FILTERS_ACTION]): string { + return 'filter'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.applyInfluencersFiltersTitle', { + defaultMessage: 'Filer for value', + }); + }, + async execute({ data }: SwimLaneDrilldownContext) { + if (!data) { + throw new Error('No swim lane selection data provided'); + } + const [, pluginStart] = await getStartServices(); + const filterManager = pluginStart.data.query.filterManager; + + filterManager.addFilters( + data.lanes.map((influencerValue) => { + return { + $state: { + store: FilterStateStore.APP_STATE, + }, + meta: { + alias: i18n.translate('xpack.ml.actions.influencerFilterAliasLabel', { + defaultMessage: 'Influencer {labelValue}', + values: { + labelValue: `${data.viewByFieldName}:${influencerValue}`, + }, + }), + controlledBy: CONTROLLED_BY_SWIM_LANE_FILTER, + disabled: false, + key: data.viewByFieldName, + negate: false, + params: { + query: influencerValue, + }, + type: 'phrase', + }, + query: { + match_phrase: { + [data.viewByFieldName!]: influencerValue, + }, + }, + }; + }) + ); + }, + async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { + // Only compatible with view by influencer swim lanes and single selection + return ( + embeddable instanceof AnomalySwimlaneEmbeddable && + data !== undefined && + data.type === SWIMLANE_TYPE.VIEW_BY && + data.viewByFieldName !== VIEW_BY_JOB_LABEL && + data.lanes.length === 1 + ); + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.tsx new file mode 100644 index 0000000000000..ec59ba20acf98 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/apply_time_range_action.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 { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; + +export const APPLY_TIME_RANGE_SELECTION_ACTION = 'applyTimeRangeSelectionAction'; + +export function createApplyTimeRangeSelectionAction( + getStartServices: MlCoreSetup['getStartServices'] +) { + return createAction({ + id: 'apply-time-range-selection', + type: APPLY_TIME_RANGE_SELECTION_ACTION, + getIconType(context: ActionContextMapping[typeof APPLY_TIME_RANGE_SELECTION_ACTION]): string { + return 'timeline'; + }, + getDisplayName: () => + i18n.translate('xpack.ml.actions.applyTimeRangeSelectionTitle', { + defaultMessage: 'Apply time range selection', + }), + async execute({ embeddable, data }: SwimLaneDrilldownContext) { + if (!data) { + throw new Error('No swim lane selection data provided'); + } + const [, pluginStart] = await getStartServices(); + const timefilter = pluginStart.data.query.timefilter.timefilter; + const { interval } = embeddable.getOutput(); + + if (!interval) { + throw new Error('Interval is required to set a time range'); + } + + let [from, to] = data.times; + from = from * 1000; + // extend bounds with the interval + to = to * 1000 + interval * 1000; + + timefilter.setTime({ + from: moment(from), + to: moment(to), + mode: 'absolute', + }); + }, + async isCompatible({ embeddable, data }: SwimLaneDrilldownContext) { + return embeddable instanceof AnomalySwimlaneEmbeddable && data !== undefined; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx index 0db41c1ed104e..cfd90f92e3238 100644 --- a/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx +++ b/x-pack/plugins/ml/public/ui_actions/edit_swimlane_panel_action.tsx @@ -4,24 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; -import { IEmbeddable } from '../../../../../src/plugins/embeddable/public'; import { AnomalySwimlaneEmbeddable, - AnomalySwimlaneEmbeddableInput, - AnomalySwimlaneEmbeddableOutput, + EditSwimlanePanelContext, } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { resolveAnomalySwimlaneUserInput } from '../embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout'; +import { ViewMode } from '../../../../../src/plugins/embeddable/public'; +import { MlCoreSetup } from '../plugin'; export const EDIT_SWIMLANE_PANEL_ACTION = 'editSwimlanePanelAction'; -export interface EditSwimlanePanelContext { - embeddable: IEmbeddable; -} - -export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getStartServices']) { +export function createEditSwimlanePanelAction(getStartServices: MlCoreSetup['getStartServices']) { return createAction({ id: 'edit-anomaly-swimlane', type: EDIT_SWIMLANE_PANEL_ACTION, @@ -48,7 +43,8 @@ export function createEditSwimlanePanelAction(getStartServices: CoreSetup['getSt }, isCompatible: async ({ embeddable }: EditSwimlanePanelContext) => { return ( - embeddable instanceof AnomalySwimlaneEmbeddable && embeddable.getInput().viewMode === 'edit' + embeddable instanceof AnomalySwimlaneEmbeddable && + embeddable.getInput().viewMode === ViewMode.EDIT ); }, }); diff --git a/x-pack/plugins/ml/public/ui_actions/index.ts b/x-pack/plugins/ml/public/ui_actions/index.ts index 4a1535c4e8c2e..b7262a330b310 100644 --- a/x-pack/plugins/ml/public/ui_actions/index.ts +++ b/x-pack/plugins/ml/public/ui_actions/index.ts @@ -8,23 +8,65 @@ import { CoreSetup } from 'kibana/public'; import { createEditSwimlanePanelAction, EDIT_SWIMLANE_PANEL_ACTION, - EditSwimlanePanelContext, } from './edit_swimlane_panel_action'; +import { + createOpenInExplorerAction, + OPEN_IN_ANOMALY_EXPLORER_ACTION, +} from './open_in_anomaly_explorer_action'; +import { EditSwimlanePanelContext } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; import { UiActionsSetup } from '../../../../../src/plugins/ui_actions/public'; import { MlPluginStart, MlStartDependencies } from '../plugin'; import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +import { + APPLY_INFLUENCER_FILTERS_ACTION, + createApplyInfluencerFiltersAction, +} from './apply_influencer_filters_action'; +import { SWIM_LANE_SELECTION_TRIGGER, swimLaneSelectionTrigger } from './triggers'; +import { SwimLaneDrilldownContext } from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { + APPLY_TIME_RANGE_SELECTION_ACTION, + createApplyTimeRangeSelectionAction, +} from './apply_time_range_action'; +/** + * Register ML UI actions + */ export function registerMlUiActions( uiActions: UiActionsSetup, core: CoreSetup ) { + // Initialize actions const editSwimlanePanelAction = createEditSwimlanePanelAction(core.getStartServices); + const openInExplorerAction = createOpenInExplorerAction(core.getStartServices); + const applyInfluencerFiltersAction = createApplyInfluencerFiltersAction(core.getStartServices); + const applyTimeRangeSelectionAction = createApplyTimeRangeSelectionAction(core.getStartServices); + + // Register actions uiActions.registerAction(editSwimlanePanelAction); + uiActions.registerAction(openInExplorerAction); + uiActions.registerAction(applyInfluencerFiltersAction); + uiActions.registerAction(applyTimeRangeSelectionAction); + + // Assign triggers uiActions.attachAction(CONTEXT_MENU_TRIGGER, editSwimlanePanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, openInExplorerAction.id); + + uiActions.registerTrigger(swimLaneSelectionTrigger); + + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyInfluencerFiltersAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, applyTimeRangeSelectionAction); + uiActions.addTriggerAction(SWIM_LANE_SELECTION_TRIGGER, openInExplorerAction); } declare module '../../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { [EDIT_SWIMLANE_PANEL_ACTION]: EditSwimlanePanelContext; + [OPEN_IN_ANOMALY_EXPLORER_ACTION]: SwimLaneDrilldownContext; + [APPLY_INFLUENCER_FILTERS_ACTION]: SwimLaneDrilldownContext; + [APPLY_TIME_RANGE_SELECTION_ACTION]: SwimLaneDrilldownContext; + } + + export interface TriggerContextMapping { + [SWIM_LANE_SELECTION_TRIGGER]: SwimLaneDrilldownContext; } } diff --git a/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx new file mode 100644 index 0000000000000..211840467e38c --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/open_in_anomaly_explorer_action.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { ActionContextMapping, createAction } from '../../../../../src/plugins/ui_actions/public'; +import { + AnomalySwimlaneEmbeddable, + SwimLaneDrilldownContext, +} from '../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { MlCoreSetup } from '../plugin'; +import { ML_APP_URL_GENERATOR } from '../url_generator'; + +export const OPEN_IN_ANOMALY_EXPLORER_ACTION = 'openInAnomalyExplorerAction'; + +export function createOpenInExplorerAction(getStartServices: MlCoreSetup['getStartServices']) { + return createAction({ + id: 'open-in-anomaly-explorer', + type: OPEN_IN_ANOMALY_EXPLORER_ACTION, + getIconType(context: ActionContextMapping[typeof OPEN_IN_ANOMALY_EXPLORER_ACTION]): string { + return 'tableOfContents'; + }, + getDisplayName() { + return i18n.translate('xpack.ml.actions.openInAnomalyExplorerTitle', { + defaultMessage: 'Open in Anomaly Explorer', + }); + }, + async getHref({ embeddable, data }: SwimLaneDrilldownContext): Promise { + const [, pluginsStart] = await getStartServices(); + const urlGenerator = pluginsStart.share.urlGenerators.getUrlGenerator(ML_APP_URL_GENERATOR); + const { jobIds, timeRange, viewBy } = embeddable.getInput(); + const { perPage, fromPage } = embeddable.getOutput(); + + return urlGenerator.createUrl({ + page: 'explorer', + jobIds, + timeRange, + mlExplorerSwimlane: { + viewByFromPage: fromPage, + viewByPerPage: perPage, + viewByFieldName: viewBy, + ...(data + ? { + selectedType: data.type, + selectedTimes: data.times, + selectedLanes: data.lanes, + } + : {}), + }, + }); + }, + async execute({ embeddable, data }: SwimLaneDrilldownContext) { + if (!embeddable) { + throw new Error('Not possible to execute an action without the embeddable context'); + } + const [{ application }] = await getStartServices(); + const anomalyExplorerUrl = await this.getHref!({ embeddable, data }); + await application.navigateToUrl(anomalyExplorerUrl!); + }, + async isCompatible({ embeddable }: SwimLaneDrilldownContext) { + return embeddable instanceof AnomalySwimlaneEmbeddable; + }, + }); +} diff --git a/x-pack/plugins/ml/public/ui_actions/triggers.ts b/x-pack/plugins/ml/public/ui_actions/triggers.ts new file mode 100644 index 0000000000000..8a8b2602573a1 --- /dev/null +++ b/x-pack/plugins/ml/public/ui_actions/triggers.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Trigger } from '../../../../../src/plugins/ui_actions/public'; + +export const SWIM_LANE_SELECTION_TRIGGER = 'SWIM_LANE_SELECTION_TRIGGER'; + +export const swimLaneSelectionTrigger: Trigger<'SWIM_LANE_SELECTION_TRIGGER'> = { + id: SWIM_LANE_SELECTION_TRIGGER, + // This is empty string to hide title of ui_actions context menu that appears + // when this trigger is executed. + title: '', + description: 'Swim lane selection triggered', +}; diff --git a/x-pack/plugins/ml/public/url_generator.test.ts b/x-pack/plugins/ml/public/url_generator.test.ts new file mode 100644 index 0000000000000..45e2932b7781a --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.test.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 { MlUrlGenerator } from './url_generator'; + +describe('MlUrlGenerator', () => { + const urlGenerator = new MlUrlGenerator({ + appBasePath: '/app/ml', + useHash: false, + }); + + it('should generate valid URL for the Anomaly Explorer page', async () => { + const url = await urlGenerator.createUrl({ + page: 'explorer', + jobIds: ['test-job'], + mlExplorerSwimlane: { viewByFromPage: 2, viewByPerPage: 20 }, + }); + expect(url).toBe( + '/app/ml#/explorer?_g=(ml:(jobIds:!(test-job)))&_a=(mlExplorerFilter:(),mlExplorerSwimlane:(viewByFromPage:2,viewByPerPage:20))' + ); + }); + + it('should throw an error in case the page is not provided', async () => { + expect.assertions(1); + + // @ts-ignore + await urlGenerator.createUrl({ jobIds: ['test-job'] }).catch((e) => { + expect(e.message).toEqual('Page type is not provided or unknown'); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/url_generator.ts b/x-pack/plugins/ml/public/url_generator.ts new file mode 100644 index 0000000000000..65d5077e081a3 --- /dev/null +++ b/x-pack/plugins/ml/public/url_generator.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { UrlGeneratorsDefinition } from '../../../../src/plugins/share/public'; +import { TimeRange } from '../../../../src/plugins/data/public'; +import { setStateToKbnUrl } from '../../../../src/plugins/kibana_utils/public'; +import { JobId } from '../../reporting/common/types'; +import { ExplorerAppState } from './application/explorer/explorer_dashboard_service'; + +export const ML_APP_URL_GENERATOR = 'ML_APP_URL_GENERATOR'; + +export interface ExplorerUrlState { + /** + * ML App Page + */ + page: 'explorer'; + /** + * Job IDs + */ + jobIds: JobId[]; + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + /** + * Optional state for the swim lane + */ + mlExplorerSwimlane?: ExplorerAppState['mlExplorerSwimlane']; + mlExplorerFilter?: ExplorerAppState['mlExplorerFilter']; +} + +/** + * Union type of ML URL state based on page + */ +export type MlUrlGeneratorState = ExplorerUrlState; + +export interface ExplorerQueryState { + ml: { jobIds: JobId[] }; + time?: TimeRange; +} + +interface Params { + appBasePath: string; + useHash: boolean; +} + +export class MlUrlGenerator implements UrlGeneratorsDefinition { + constructor(private readonly params: Params) {} + + public readonly id = ML_APP_URL_GENERATOR; + + public readonly createUrl = async ({ page, ...params }: MlUrlGeneratorState): Promise => { + if (page === 'explorer') { + return this.createExplorerUrl(params); + } + throw new Error('Page type is not provided or unknown'); + }; + + /** + * Creates URL to the Anomaly Explorer page + */ + private createExplorerUrl({ + timeRange, + jobIds, + mlExplorerSwimlane = {}, + mlExplorerFilter = {}, + }: Omit): string { + const appState: ExplorerAppState = { + mlExplorerSwimlane, + mlExplorerFilter, + }; + + const queryState: ExplorerQueryState = { + ml: { + jobIds, + }, + }; + + if (timeRange) queryState.time = timeRange; + + let url = `${this.params.appBasePath}#/explorer`; + url = setStateToKbnUrl('_g', queryState, { useHash: false }, url); + url = setStateToKbnUrl('_a', appState, { useHash: false }, url); + + return url; + } +} diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 8e18b57ac92a8..21d32813c0d51 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { getAdminCapabilities, getUserCapabilities } from './__mocks__/ml_capabilities'; import { capabilitiesProvider } from './check_capabilities'; import { MlLicense } from '../../../common/license'; @@ -23,18 +23,23 @@ const mlLicenseBasic = { const mlIsEnabled = async () => true; const mlIsNotEnabled = async () => false; -const callWithRequestNonUpgrade = ((async () => ({ - upgrade_mode: false, -})) as unknown) as LegacyAPICaller; -const callWithRequestUpgrade = ((async () => ({ - upgrade_mode: true, -})) as unknown) as LegacyAPICaller; +const mlClusterClientNonUpgrade = ({ + callAsInternalUser: async () => ({ + upgrade_mode: false, + }), +} as unknown) as ILegacyScopedClusterClient; + +const mlClusterClientUpgrade = ({ + callAsInternalUser: async () => ({ + upgrade_mode: true, + }), +} as unknown) as ILegacyScopedClusterClient; describe('check_capabilities', () => { describe('getCapabilities() - right number of capabilities', () => { test('kibana capabilities count', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestNonUpgrade, + mlClusterClientNonUpgrade, getAdminCapabilities(), mlLicense, mlIsEnabled @@ -49,7 +54,7 @@ describe('check_capabilities', () => { describe('getCapabilities() with security', () => { test('ml_user capabilities only', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestNonUpgrade, + mlClusterClientNonUpgrade, getUserCapabilities(), mlLicense, mlIsEnabled @@ -98,7 +103,7 @@ describe('check_capabilities', () => { test('full capabilities', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestNonUpgrade, + mlClusterClientNonUpgrade, getAdminCapabilities(), mlLicense, mlIsEnabled @@ -147,7 +152,7 @@ describe('check_capabilities', () => { test('upgrade in progress with full capabilities', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestUpgrade, + mlClusterClientUpgrade, getAdminCapabilities(), mlLicense, mlIsEnabled @@ -196,7 +201,7 @@ describe('check_capabilities', () => { test('upgrade in progress with partial capabilities', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestUpgrade, + mlClusterClientUpgrade, getUserCapabilities(), mlLicense, mlIsEnabled @@ -245,7 +250,7 @@ describe('check_capabilities', () => { test('full capabilities, ml disabled in space', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestNonUpgrade, + mlClusterClientNonUpgrade, getDefaultCapabilities(), mlLicense, mlIsNotEnabled @@ -295,7 +300,7 @@ describe('check_capabilities', () => { test('full capabilities, basic license, ml disabled in space', async (done) => { const { getCapabilities } = capabilitiesProvider( - callWithRequestNonUpgrade, + mlClusterClientNonUpgrade, getDefaultCapabilities(), mlLicenseBasic, mlIsNotEnabled diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts index bdcdf50b983f5..c976ab598b28c 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { mlLog } from '../../client/log'; import { MlCapabilities, @@ -22,12 +22,12 @@ import { } from './errors'; export function capabilitiesProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, capabilities: MlCapabilities, mlLicense: MlLicense, isMlEnabledInSpace: () => Promise ) { - const { isUpgradeInProgress } = upgradeCheckProvider(callAsCurrentUser); + const { isUpgradeInProgress } = upgradeCheckProvider(mlClusterClient); async function getCapabilities(): Promise { const upgradeInProgress = await isUpgradeInProgress(); const isPlatinumOrTrialLicense = mlLicense.isFullLicense(); diff --git a/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts b/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts index 45f3f3da20c24..6df4d0c87ecf5 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/upgrade.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { mlLog } from '../../client/log'; -export function upgradeCheckProvider(callAsCurrentUser: LegacyAPICaller) { +export function upgradeCheckProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { async function isUpgradeInProgress(): Promise { let upgradeInProgress = false; try { - const info = await callAsCurrentUser('ml.info'); + const info = await callAsInternalUser('ml.info'); // if ml indices are currently being migrated, upgrade_mode will be set to true // pass this back with the privileges to allow for the disabling of UI controls. upgradeInProgress = info.upgrade_mode === true; diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.ts index 2c46be394cbb2..fb37917c512cb 100644 --- a/x-pack/plugins/ml/server/lib/check_annotations/index.ts +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { mlLog } from '../../client/log'; import { @@ -17,7 +17,9 @@ import { // - ML_ANNOTATIONS_INDEX_PATTERN index is present // - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present // - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present -export async function isAnnotationsFeatureAvailable(callAsCurrentUser: LegacyAPICaller) { +export async function isAnnotationsFeatureAvailable({ + callAsCurrentUser, +}: ILegacyScopedClusterClient) { try { const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; diff --git a/x-pack/plugins/ml/server/lib/request_authorization.ts b/x-pack/plugins/ml/server/lib/request_authorization.ts new file mode 100644 index 0000000000000..01df0900b96f4 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/request_authorization.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from 'kibana/server'; + +export function getAuthorizationHeader(request: KibanaRequest) { + return { + headers: { 'es-secondary-authorization': request.headers.authorization }, + }; +} diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 19db8b7b56aa6..3bf9bd0232a5d 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -6,7 +6,6 @@ import getAnnotationsRequestMock from './__mocks__/get_annotations_request.json'; import getAnnotationsResponseMock from './__mocks__/get_annotations_response.json'; -import { LegacyAPICaller } from 'kibana/server'; import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; import { ML_ANNOTATIONS_INDEX_ALIAS_WRITE } from '../../../common/constants/index_patterns'; @@ -20,10 +19,10 @@ const acknowledgedResponseMock = { acknowledged: true }; const jobIdMock = 'jobIdMock'; describe('annotation_service', () => { - let callWithRequestSpy: any; + let mlClusterClientSpy = {} as any; beforeEach(() => { - callWithRequestSpy = (jest.fn((action: string) => { + const callAs = jest.fn((action: string) => { switch (action) { case 'delete': case 'index': @@ -31,13 +30,18 @@ describe('annotation_service', () => { case 'search': return Promise.resolve(getAnnotationsResponseMock); } - }) as unknown) as LegacyAPICaller; + }); + + mlClusterClientSpy = { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, + }; }); describe('deleteAnnotation()', () => { it('should delete annotation', async (done) => { - const { deleteAnnotation } = annotationServiceProvider(callWithRequestSpy); - const mockFunct = callWithRequestSpy; + const { deleteAnnotation } = annotationServiceProvider(mlClusterClientSpy); + const mockFunct = mlClusterClientSpy; const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { @@ -48,8 +52,8 @@ describe('annotation_service', () => { const response = await deleteAnnotation(annotationMockId); - expect(mockFunct.mock.calls[0][0]).toBe('delete'); - expect(mockFunct.mock.calls[0][1]).toEqual(deleteParamsMock); + expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('delete'); + expect(mockFunct.callAsCurrentUser.mock.calls[0][1]).toEqual(deleteParamsMock); expect(response).toBe(acknowledgedResponseMock); done(); }); @@ -57,8 +61,8 @@ describe('annotation_service', () => { describe('getAnnotation()', () => { it('should get annotations for specific job', async (done) => { - const { getAnnotations } = annotationServiceProvider(callWithRequestSpy); - const mockFunct = callWithRequestSpy; + const { getAnnotations } = annotationServiceProvider(mlClusterClientSpy); + const mockFunct = mlClusterClientSpy; const indexAnnotationArgsMock: IndexAnnotationArgs = { jobIds: [jobIdMock], @@ -69,8 +73,8 @@ describe('annotation_service', () => { const response: GetResponse = await getAnnotations(indexAnnotationArgsMock); - expect(mockFunct.mock.calls[0][0]).toBe('search'); - expect(mockFunct.mock.calls[0][1]).toEqual(getAnnotationsRequestMock); + expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('search'); + expect(mockFunct.callAsCurrentUser.mock.calls[0][1]).toEqual(getAnnotationsRequestMock); expect(Object.keys(response.annotations)).toHaveLength(1); expect(response.annotations[jobIdMock]).toHaveLength(2); expect(isAnnotations(response.annotations[jobIdMock])).toBeTruthy(); @@ -84,11 +88,13 @@ describe('annotation_service', () => { message: 'mock error message', }; - const callWithRequestSpyError = (jest.fn(() => { - return Promise.resolve(mockEsError); - }) as unknown) as LegacyAPICaller; + const mlClusterClientSpyError: any = { + callAsCurrentUser: jest.fn(() => { + return Promise.resolve(mockEsError); + }), + }; - const { getAnnotations } = annotationServiceProvider(callWithRequestSpyError); + const { getAnnotations } = annotationServiceProvider(mlClusterClientSpyError); const indexAnnotationArgsMock: IndexAnnotationArgs = { jobIds: [jobIdMock], @@ -105,8 +111,8 @@ describe('annotation_service', () => { describe('indexAnnotation()', () => { it('should index annotation', async (done) => { - const { indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const mockFunct = callWithRequestSpy; + const { indexAnnotation } = annotationServiceProvider(mlClusterClientSpy); + const mockFunct = mlClusterClientSpy; const annotationMock: Annotation = { annotation: 'Annotation text', @@ -118,10 +124,10 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.mock.calls[0][0]).toBe('index'); + expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('index'); // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.mock.calls[0][1]; + const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[0][1]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -133,8 +139,8 @@ describe('annotation_service', () => { }); it('should remove ._id and .key before updating annotation', async (done) => { - const { indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const mockFunct = callWithRequestSpy; + const { indexAnnotation } = annotationServiceProvider(mlClusterClientSpy); + const mockFunct = mlClusterClientSpy; const annotationMock: Annotation = { _id: 'mockId', @@ -148,10 +154,10 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.mock.calls[0][0]).toBe('index'); + expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('index'); // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.mock.calls[0][1]; + const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[0][1]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -165,8 +171,8 @@ describe('annotation_service', () => { }); it('should update annotation text and the username for modified_username', async (done) => { - const { getAnnotations, indexAnnotation } = annotationServiceProvider(callWithRequestSpy); - const mockFunct = callWithRequestSpy; + const { getAnnotations, indexAnnotation } = annotationServiceProvider(mlClusterClientSpy); + const mockFunct = mlClusterClientSpy; const indexAnnotationArgsMock: IndexAnnotationArgs = { jobIds: [jobIdMock], @@ -190,9 +196,9 @@ describe('annotation_service', () => { await indexAnnotation(annotation, modifiedUsernameMock); - expect(mockFunct.mock.calls[1][0]).toBe('index'); + expect(mockFunct.callAsCurrentUser.mock.calls[1][0]).toBe('index'); // test if the annotation has been correctly updated - const indexParamsCheck = mockFunct.mock.calls[1][1]; + const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[1][1]; const modifiedAnnotation = indexParamsCheck.body; expect(modifiedAnnotation.annotation).toBe(modifiedAnnotationText); expect(modifiedAnnotation.create_username).toBe(originalUsernameMock); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index 2808b06103a75..c2582107062bb 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -6,7 +6,7 @@ import Boom from 'boom'; import _ from 'lodash'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; import { @@ -61,14 +61,7 @@ export interface DeleteParams { id: string; } -type annotationProviderParams = DeleteParams | GetParams | IndexParams; - -export type callWithRequestType = ( - action: string, - params: annotationProviderParams -) => Promise; - -export function annotationProvider(callAsCurrentUser: LegacyAPICaller) { +export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. diff --git a/x-pack/plugins/ml/server/models/annotation_service/index.ts b/x-pack/plugins/ml/server/models/annotation_service/index.ts index efc42c693c24b..e17af2a154b87 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/index.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/index.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { annotationProvider } from './annotation'; -export function annotationServiceProvider(callAsCurrentUser: LegacyAPICaller) { +export function annotationServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { return { - ...annotationProvider(callAsCurrentUser), + ...annotationProvider(mlClusterClient), }; } diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts index 3e80e79705a5c..eeabb24d9be3b 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; export interface BucketSpanEstimatorData { @@ -20,8 +20,7 @@ export interface BucketSpanEstimatorData { timeField: string | undefined; } -export function estimateBucketSpanFactory( - callAsCurrentUser: LegacyAPICaller, - callAsInternalUser: LegacyAPICaller, - isSecurityDisabled: boolean -): (config: BucketSpanEstimatorData) => Promise; +export function estimateBucketSpanFactory({ + callAsCurrentUser, + callAsInternalUser, +}: ILegacyScopedClusterClient): (config: BucketSpanEstimatorData) => Promise; diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js index 2e03a9532c831..3758547779403 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js @@ -12,13 +12,10 @@ import { INTERVALS } from './intervals'; import { singleSeriesCheckerFactory } from './single_series_checker'; import { polledDataCheckerFactory } from './polled_data_checker'; -export function estimateBucketSpanFactory( - callAsCurrentUser, - callAsInternalUser, - isSecurityDisabled -) { - const PolledDataChecker = polledDataCheckerFactory(callAsCurrentUser); - const SingleSeriesChecker = singleSeriesCheckerFactory(callAsCurrentUser); +export function estimateBucketSpanFactory(mlClusterClient) { + const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; + const PolledDataChecker = polledDataCheckerFactory(mlClusterClient); + const SingleSeriesChecker = singleSeriesCheckerFactory(mlClusterClient); class BucketSpanEstimator { constructor( @@ -334,99 +331,65 @@ export function estimateBucketSpanFactory( } return new Promise((resolve, reject) => { - function getBucketSpanEstimation() { - // fetch the `search.max_buckets` cluster setting so we're able to - // adjust aggregations to not exceed that limit. - callAsInternalUser('cluster.getSettings', { - flatSettings: true, - includeDefaults: true, - filterPath: '*.*max_buckets', - }) - .then((settings) => { - if (typeof settings !== 'object') { - reject('Unable to retrieve cluster settings'); - } - - // search.max_buckets could exist in default, persistent or transient cluster settings - const maxBucketsSetting = (settings.defaults || - settings.persistent || - settings.transient || - {})['search.max_buckets']; - - if (maxBucketsSetting === undefined) { - reject('Unable to retrieve cluster setting search.max_buckets'); - } - - const maxBuckets = parseInt(maxBucketsSetting); + // fetch the `search.max_buckets` cluster setting so we're able to + // adjust aggregations to not exceed that limit. + callAsInternalUser('cluster.getSettings', { + flatSettings: true, + includeDefaults: true, + filterPath: '*.*max_buckets', + }) + .then((settings) => { + if (typeof settings !== 'object') { + reject('Unable to retrieve cluster settings'); + } - const runEstimator = (splitFieldValues = []) => { - const bucketSpanEstimator = new BucketSpanEstimator( - formConfig, - splitFieldValues, - maxBuckets - ); + // search.max_buckets could exist in default, persistent or transient cluster settings + const maxBucketsSetting = (settings.defaults || + settings.persistent || + settings.transient || + {})['search.max_buckets']; - bucketSpanEstimator - .run() - .then((resp) => { - resolve(resp); - }) - .catch((resp) => { - reject(resp); - }); - }; - - // a partition has been selected, so we need to load some field values to use in the - // bucket span tests. - if (formConfig.splitField !== undefined) { - getRandomFieldValues(formConfig.index, formConfig.splitField, formConfig.query) - .then((splitFieldValues) => { - runEstimator(splitFieldValues); - }) - .catch((resp) => { - reject(resp); - }); - } else { - // no partition field selected or we're in the single metric config - runEstimator(); - } - }) - .catch((resp) => { - reject(resp); - }); - } + if (maxBucketsSetting === undefined) { + reject('Unable to retrieve cluster setting search.max_buckets'); + } - if (isSecurityDisabled) { - getBucketSpanEstimation(); - } else { - // if security is enabled, check that the user has permission to - // view jobs before calling getBucketSpanEstimation. - // getBucketSpanEstimation calls the 'cluster.getSettings' endpoint as the internal user - // and so could give the user access to more information than - // they are entitled to. - const body = { - cluster: [ - 'cluster:monitor/xpack/ml/job/get', - 'cluster:monitor/xpack/ml/job/stats/get', - 'cluster:monitor/xpack/ml/datafeeds/get', - 'cluster:monitor/xpack/ml/datafeeds/stats/get', - ], - }; - callAsCurrentUser('ml.privilegeCheck', { body }) - .then((resp) => { - if ( - resp.cluster['cluster:monitor/xpack/ml/job/get'] && - resp.cluster['cluster:monitor/xpack/ml/job/stats/get'] && - resp.cluster['cluster:monitor/xpack/ml/datafeeds/get'] && - resp.cluster['cluster:monitor/xpack/ml/datafeeds/stats/get'] - ) { - getBucketSpanEstimation(); - } else { - reject('Insufficient permissions to call bucket span estimation.'); - } - }) - .catch(reject); - } + const maxBuckets = parseInt(maxBucketsSetting); + + const runEstimator = (splitFieldValues = []) => { + const bucketSpanEstimator = new BucketSpanEstimator( + formConfig, + splitFieldValues, + maxBuckets + ); + + bucketSpanEstimator + .run() + .then((resp) => { + resolve(resp); + }) + .catch((resp) => { + reject(resp); + }); + }; + + // a partition has been selected, so we need to load some field values to use in the + // bucket span tests. + if (formConfig.splitField !== undefined) { + getRandomFieldValues(formConfig.index, formConfig.splitField, formConfig.query) + .then((splitFieldValues) => { + runEstimator(splitFieldValues); + }) + .catch((resp) => { + reject(resp); + }); + } else { + // no partition field selected or we're in the single metric config + runEstimator(); + } + }) + .catch((resp) => { + reject(resp); + }); }); }; } diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index 8da1fb69eec34..f7c7dd8172ea5 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -4,40 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; import { estimateBucketSpanFactory, BucketSpanEstimatorData } from './bucket_span_estimator'; -// Mock callWithRequest with the ability to simulate returning different -// permission settings. On each call using `ml.privilegeCheck` we retrieve -// the last value from `permissions` and pass that to one of the permission -// settings. The tests call `ml.privilegeCheck` two times, the first time -// sufficient permissions should be returned, the second time insufficient -// permissions. -const permissions = [false, true]; -const callWithRequest: LegacyAPICaller = (method: string) => { +const callAs = () => { return new Promise((resolve) => { - if (method === 'ml.privilegeCheck') { - resolve({ - cluster: { - 'cluster:monitor/xpack/ml/job/get': true, - 'cluster:monitor/xpack/ml/job/stats/get': true, - 'cluster:monitor/xpack/ml/datafeeds/get': true, - 'cluster:monitor/xpack/ml/datafeeds/stats/get': permissions.pop(), - }, - }); - return; - } resolve({}); }) as Promise; }; -const callWithInternalUser: LegacyAPICaller = () => { - return new Promise((resolve) => { - resolve({}); - }) as Promise; +const mlClusterClient: ILegacyScopedClusterClient = { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, }; // mock configuration to be passed to the estimator @@ -59,17 +40,13 @@ const formConfig: BucketSpanEstimatorData = { describe('ML - BucketSpanEstimator', () => { it('call factory', () => { expect(function () { - estimateBucketSpanFactory(callWithRequest, callWithInternalUser, false); + estimateBucketSpanFactory(mlClusterClient); }).not.toThrow('Not initialized.'); }); it('call factory and estimator with security disabled', (done) => { expect(function () { - const estimateBucketSpan = estimateBucketSpanFactory( - callWithRequest, - callWithInternalUser, - true - ); + const estimateBucketSpan = estimateBucketSpanFactory(mlClusterClient); estimateBucketSpan(formConfig).catch((catchData) => { expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); @@ -81,11 +58,7 @@ describe('ML - BucketSpanEstimator', () => { it('call factory and estimator with security enabled.', (done) => { expect(function () { - const estimateBucketSpan = estimateBucketSpanFactory( - callWithRequest, - callWithInternalUser, - false - ); + const estimateBucketSpan = estimateBucketSpanFactory(mlClusterClient); estimateBucketSpan(formConfig).catch((catchData) => { expect(catchData).toBe('Unable to retrieve cluster setting search.max_buckets'); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js index de9fd06c34e6a..347843e276c36 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/polled_data_checker.js @@ -12,7 +12,7 @@ import _ from 'lodash'; -export function polledDataCheckerFactory(callAsCurrentUser) { +export function polledDataCheckerFactory({ callAsCurrentUser }) { class PolledDataChecker { constructor(index, timeField, duration, query) { this.index = index; diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js index 6ae485fe11307..a5449395501dc 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js @@ -13,7 +13,7 @@ import { mlLog } from '../../client/log'; import { INTERVALS, LONG_INTERVALS } from './intervals'; -export function singleSeriesCheckerFactory(callAsCurrentUser) { +export function singleSeriesCheckerFactory({ callAsCurrentUser }) { const REF_DATA_INTERVAL = { name: '1h', ms: 3600000 }; class SingleSeriesChecker { diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index 61299aa3ae26d..bc3c326e7d070 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -5,7 +5,7 @@ */ import numeral from '@elastic/numeral'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; @@ -36,8 +36,8 @@ export interface ModelMemoryEstimate { /** * Retrieves overall and max bucket cardinalities. */ -const cardinalityCheckProvider = (callAsCurrentUser: LegacyAPICaller) => { - const fieldsService = fieldsServiceProvider(callAsCurrentUser); +const cardinalityCheckProvider = (mlClusterClient: ILegacyScopedClusterClient) => { + const fieldsService = fieldsServiceProvider(mlClusterClient); return async ( analysisConfig: AnalysisConfig, @@ -123,8 +123,9 @@ const cardinalityCheckProvider = (callAsCurrentUser: LegacyAPICaller) => { }; }; -export function calculateModelMemoryLimitProvider(callAsCurrentUser: LegacyAPICaller) { - const getCardinalities = cardinalityCheckProvider(callAsCurrentUser); +export function calculateModelMemoryLimitProvider(mlClusterClient: ILegacyScopedClusterClient) { + const { callAsInternalUser } = mlClusterClient; + const getCardinalities = cardinalityCheckProvider(mlClusterClient); /** * Retrieves an estimated size of the model memory limit used in the job config @@ -140,7 +141,7 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: LegacyAPICa latestMs: number, allowMMLGreaterThanMax = false ): Promise { - const info = await callAsCurrentUser('ml.info'); + const info = (await callAsInternalUser('ml.info')) as MlInfoResponse; const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); @@ -153,28 +154,26 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: LegacyAPICa latestMs ); - const estimatedModelMemoryLimit = ( - await callAsCurrentUser('ml.estimateModelMemory', { - body: { - analysis_config: analysisConfig, - overall_cardinality: overallCardinality, - max_bucket_cardinality: maxBucketCardinality, - }, - }) - ).model_memory_estimate.toUpperCase(); + const estimatedModelMemoryLimit = ((await callAsInternalUser('ml.estimateModelMemory', { + body: { + analysis_config: analysisConfig, + overall_cardinality: overallCardinality, + max_bucket_cardinality: maxBucketCardinality, + }, + })) as ModelMemoryEstimate).model_memory_estimate.toUpperCase(); let modelMemoryLimit = estimatedModelMemoryLimit; let mmlCappedAtMax = false; // if max_model_memory_limit has been set, // make sure the estimated value is not greater than it. if (allowMMLGreaterThanMax === false) { - // @ts-ignore + // @ts-expect-error const mmlBytes = numeral(estimatedModelMemoryLimit).value(); if (maxModelMemoryLimit !== undefined) { - // @ts-ignore + // @ts-expect-error const maxBytes = numeral(maxModelMemoryLimit).value(); if (mmlBytes > maxBytes) { - // @ts-ignore + // @ts-expect-error modelMemoryLimit = `${Math.floor(maxBytes / numeral('1MB').value())}MB`; mmlCappedAtMax = true; } @@ -183,10 +182,10 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: LegacyAPICa // if we've not already capped the estimated mml at the hard max server setting // ensure that the estimated mml isn't greater than the effective max mml if (mmlCappedAtMax === false && effectiveMaxModelMemoryLimit !== undefined) { - // @ts-ignore + // @ts-expect-error const effectiveMaxMmlBytes = numeral(effectiveMaxModelMemoryLimit).value(); if (mmlBytes > effectiveMaxMmlBytes) { - // @ts-ignore + // @ts-expect-error modelMemoryLimit = `${Math.floor(effectiveMaxMmlBytes / numeral('1MB').value())}MB`; } } diff --git a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts index 5df9c037b3f83..43f4dc3cba7e2 100644 --- a/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/calendar_manager.ts @@ -5,7 +5,7 @@ */ import { difference } from 'lodash'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { EventManager, CalendarEvent } from './event_manager'; interface BasicCalendar { @@ -23,16 +23,16 @@ export interface FormCalendar extends BasicCalendar { } export class CalendarManager { - private _callAsCurrentUser: LegacyAPICaller; + private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; private _eventManager: EventManager; - constructor(callAsCurrentUser: LegacyAPICaller) { - this._callAsCurrentUser = callAsCurrentUser; - this._eventManager = new EventManager(callAsCurrentUser); + constructor(mlClusterClient: ILegacyScopedClusterClient) { + this._callAsInternalUser = mlClusterClient.callAsInternalUser; + this._eventManager = new EventManager(mlClusterClient); } async getCalendar(calendarId: string) { - const resp = await this._callAsCurrentUser('ml.calendars', { + const resp = await this._callAsInternalUser('ml.calendars', { calendarId, }); @@ -43,7 +43,7 @@ export class CalendarManager { } async getAllCalendars() { - const calendarsResp = await this._callAsCurrentUser('ml.calendars'); + const calendarsResp = await this._callAsInternalUser('ml.calendars'); const events: CalendarEvent[] = await this._eventManager.getAllEvents(); const calendars: Calendar[] = calendarsResp.calendars; @@ -74,7 +74,7 @@ export class CalendarManager { const events = calendar.events; delete calendar.calendarId; delete calendar.events; - await this._callAsCurrentUser('ml.addCalendar', { + await this._callAsInternalUser('ml.addCalendar', { calendarId, body: calendar, }); @@ -109,7 +109,7 @@ export class CalendarManager { // add all new jobs if (jobsToAdd.length) { - await this._callAsCurrentUser('ml.addJobToCalendar', { + await this._callAsInternalUser('ml.addJobToCalendar', { calendarId, jobId: jobsToAdd.join(','), }); @@ -117,7 +117,7 @@ export class CalendarManager { // remove all removed jobs if (jobsToRemove.length) { - await this._callAsCurrentUser('ml.removeJobFromCalendar', { + await this._callAsInternalUser('ml.removeJobFromCalendar', { calendarId, jobId: jobsToRemove.join(','), }); @@ -140,6 +140,6 @@ export class CalendarManager { } async deleteCalendar(calendarId: string) { - return this._callAsCurrentUser('ml.deleteCalendar', { calendarId }); + return this._callAsInternalUser('ml.deleteCalendar', { calendarId }); } } diff --git a/x-pack/plugins/ml/server/models/calendar/event_manager.ts b/x-pack/plugins/ml/server/models/calendar/event_manager.ts index 57034ab772710..b670bbe187544 100644 --- a/x-pack/plugins/ml/server/models/calendar/event_manager.ts +++ b/x-pack/plugins/ml/server/models/calendar/event_manager.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; export interface CalendarEvent { @@ -16,10 +16,13 @@ export interface CalendarEvent { } export class EventManager { - constructor(private _callAsCurrentUser: LegacyAPICaller) {} + private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; + constructor({ callAsInternalUser }: ILegacyScopedClusterClient) { + this._callAsInternalUser = callAsInternalUser; + } async getCalendarEvents(calendarId: string) { - const resp = await this._callAsCurrentUser('ml.events', { calendarId }); + const resp = await this._callAsInternalUser('ml.events', { calendarId }); return resp.events; } @@ -27,7 +30,7 @@ export class EventManager { // jobId is optional async getAllEvents(jobId?: string) { const calendarId = GLOBAL_CALENDAR; - const resp = await this._callAsCurrentUser('ml.events', { + const resp = await this._callAsInternalUser('ml.events', { calendarId, jobId, }); @@ -38,14 +41,14 @@ export class EventManager { async addEvents(calendarId: string, events: CalendarEvent[]) { const body = { events }; - return await this._callAsCurrentUser('ml.addEvent', { + return await this._callAsInternalUser('ml.addEvent', { calendarId, body, }); } async deleteEvent(calendarId: string, eventId: string) { - return this._callAsCurrentUser('ml.deleteEvent', { calendarId, eventId }); + return this._callAsInternalUser('ml.deleteEvent', { calendarId, eventId }); } isEqual(ev1: CalendarEvent, ev2: CalendarEvent) { diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts index abe389165182f..c8471b5462205 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { callWithRequestType } from '../../../common/types/kibana'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ML_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { JobMessage } from '../../../common/types/audit_message'; @@ -23,7 +23,7 @@ interface BoolQuery { bool: { [key: string]: any }; } -export function analyticsAuditMessagesProvider(callWithRequest: callWithRequestType) { +export function analyticsAuditMessagesProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { // search for audit messages, // analyticsId is optional. without it, all analytics will be listed. async function getAnalyticsAuditMessages(analyticsId: string) { @@ -69,7 +69,7 @@ export function analyticsAuditMessagesProvider(callWithRequest: callWithRequestT } try { - const resp = await callWithRequest('search', { + const resp = await callAsCurrentUser('search', { index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, rest_total_hits_as_int: true, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts index ee8598ad338e3..82d7707464308 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.test.ts @@ -4,17 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, KibanaRequest } from 'kibana/server'; import { Module } from '../../../common/types/modules'; import { DataRecognizer } from '../data_recognizer'; describe('ML - data recognizer', () => { const dr = new DataRecognizer( - jest.fn() as LegacyAPICaller, + { callAsCurrentUser: jest.fn(), callAsInternalUser: jest.fn() }, ({ find: jest.fn(), bulkCreate: jest.fn(), - } as never) as SavedObjectsClientContract + } as unknown) as SavedObjectsClientContract, + { headers: { authorization: '' } } as KibanaRequest ); describe('jobOverrides', () => { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index ae9a56f00a5c1..521d04159ca7a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -7,11 +7,16 @@ import fs from 'fs'; import Boom from 'boom'; import numeral from '@elastic/numeral'; -import { LegacyAPICaller, SavedObjectsClientContract } from 'kibana/server'; +import { + KibanaRequest, + ILegacyScopedClusterClient, + SavedObjectsClientContract, +} from 'kibana/server'; import moment from 'moment'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { merge } from 'lodash'; import { AnalysisLimits, CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { getAuthorizationHeader } from '../../lib/request_authorization'; import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { KibanaObjects, @@ -104,18 +109,28 @@ interface SaveResults { } export class DataRecognizer { - modulesDir = `${__dirname}/modules`; - indexPatternName: string = ''; - indexPatternId: string | undefined = undefined; + private _callAsCurrentUser: ILegacyScopedClusterClient['callAsCurrentUser']; + private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; + private _mlClusterClient: ILegacyScopedClusterClient; + private _authorizationHeader: object; + private _modulesDir = `${__dirname}/modules`; + private _indexPatternName: string = ''; + private _indexPatternId: string | undefined = undefined; /** * List of the module jobs that require model memory estimation */ jobsForModelMemoryEstimation: Array<{ job: ModuleJob; query: any }> = []; constructor( - private callAsCurrentUser: LegacyAPICaller, - private savedObjectsClient: SavedObjectsClientContract - ) {} + mlClusterClient: ILegacyScopedClusterClient, + private savedObjectsClient: SavedObjectsClientContract, + request: KibanaRequest + ) { + this._mlClusterClient = mlClusterClient; + this._callAsCurrentUser = mlClusterClient.callAsCurrentUser; + this._callAsInternalUser = mlClusterClient.callAsInternalUser; + this._authorizationHeader = getAuthorizationHeader(request); + } // list all directories under the given directory async listDirs(dirName: string): Promise { @@ -150,12 +165,12 @@ export class DataRecognizer { async loadManifestFiles(): Promise { const configs: Config[] = []; - const dirs = await this.listDirs(this.modulesDir); + const dirs = await this.listDirs(this._modulesDir); await Promise.all( dirs.map(async (dir) => { let file: string | undefined; try { - file = await this.readFile(`${this.modulesDir}/${dir}/manifest.json`); + file = await this.readFile(`${this._modulesDir}/${dir}/manifest.json`); } catch (error) { mlLog.warn(`Data recognizer skipping folder ${dir} as manifest.json cannot be read`); } @@ -204,7 +219,7 @@ export class DataRecognizer { if (moduleConfig.logoFile) { try { logo = await this.readFile( - `${this.modulesDir}/${i.dirName}/${moduleConfig.logoFile}` + `${this._modulesDir}/${i.dirName}/${moduleConfig.logoFile}` ); logo = JSON.parse(logo); } catch (e) { @@ -236,7 +251,7 @@ export class DataRecognizer { query: moduleConfig.query, }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, rest_total_hits_as_int: true, size, @@ -281,7 +296,7 @@ export class DataRecognizer { manifestJSON.jobs.map(async (job) => { try { const jobConfig = await this.readFile( - `${this.modulesDir}/${dirName}/${ML_DIR}/${job.file}` + `${this._modulesDir}/${dirName}/${ML_DIR}/${job.file}` ); // use the file name for the id jobs.push({ @@ -301,7 +316,7 @@ export class DataRecognizer { manifestJSON.datafeeds.map(async (datafeed) => { try { const datafeedConfig = await this.readFile( - `${this.modulesDir}/${dirName}/${ML_DIR}/${datafeed.file}` + `${this._modulesDir}/${dirName}/${ML_DIR}/${datafeed.file}` ); const config = JSON.parse(datafeedConfig); // use the job id from the manifestFile @@ -329,7 +344,7 @@ export class DataRecognizer { manifestJSON!.kibana[key].map(async (obj) => { try { const kConfig = await this.readFile( - `${this.modulesDir}/${dirName}/${KIBANA_DIR}/${key}/${obj.file}` + `${this._modulesDir}/${dirName}/${KIBANA_DIR}/${key}/${obj.file}` ); // use the file name for the id const kId = obj.file.replace('.json', ''); @@ -385,26 +400,26 @@ export class DataRecognizer { ); } - this.indexPatternName = + this._indexPatternName = indexPatternName === undefined ? moduleConfig.defaultIndexPattern : indexPatternName; - this.indexPatternId = await this.getIndexPatternId(this.indexPatternName); + this._indexPatternId = await this.getIndexPatternId(this._indexPatternName); // the module's jobs contain custom URLs which require an index patten id // but there is no corresponding index pattern, throw an error - if (this.indexPatternId === undefined && this.doJobUrlsContainIndexPatternId(moduleConfig)) { + if (this._indexPatternId === undefined && this.doJobUrlsContainIndexPatternId(moduleConfig)) { throw Boom.badRequest( - `Module's jobs contain custom URLs which require a kibana index pattern (${this.indexPatternName}) which cannot be found.` + `Module's jobs contain custom URLs which require a kibana index pattern (${this._indexPatternName}) which cannot be found.` ); } // the module's saved objects require an index patten id // but there is no corresponding index pattern, throw an error if ( - this.indexPatternId === undefined && + this._indexPatternId === undefined && this.doSavedObjectsContainIndexPatternId(moduleConfig) ) { throw Boom.badRequest( - `Module's saved objects contain custom URLs which require a kibana index pattern (${this.indexPatternName}) which cannot be found.` + `Module's saved objects contain custom URLs which require a kibana index pattern (${this._indexPatternName}) which cannot be found.` ); } @@ -495,7 +510,7 @@ export class DataRecognizer { // Add a wildcard at the front of each of the job IDs in the module, // as a prefix may have been supplied when creating the jobs in the module. const jobIds = module.jobs.map((job) => `*${job.id}`); - const { jobsExist } = jobServiceProvider(this.callAsCurrentUser); + const { jobsExist } = jobServiceProvider(this._mlClusterClient); const jobInfo = await jobsExist(jobIds); // Check if the value for any of the jobs is false. @@ -504,11 +519,13 @@ export class DataRecognizer { if (doJobsExist === true) { // Get the IDs of the jobs created from the module, and their earliest / latest timestamps. - const jobStats: MlJobStats = await this.callAsCurrentUser('ml.jobStats', { jobId: jobIds }); + const jobStats: MlJobStats = await this._callAsInternalUser('ml.jobStats', { + jobId: jobIds, + }); const jobStatsJobs: JobStat[] = []; if (jobStats.jobs && jobStats.jobs.length > 0) { const foundJobIds = jobStats.jobs.map((job) => job.job_id); - const { getLatestBucketTimestampByJob } = resultsServiceProvider(this.callAsCurrentUser); + const { getLatestBucketTimestampByJob } = resultsServiceProvider(this._mlClusterClient); const latestBucketTimestampsByJob = await getLatestBucketTimestampByJob(foundJobIds); jobStats.jobs.forEach((job) => { @@ -669,7 +686,7 @@ export class DataRecognizer { async saveJob(job: ModuleJob) { const { id: jobId, config: body } = job; - return this.callAsCurrentUser('ml.addJob', { jobId, body }); + return this._callAsInternalUser('ml.addJob', { jobId, body }); } // save the datafeeds. @@ -690,7 +707,11 @@ export class DataRecognizer { async saveDatafeed(datafeed: ModuleDataFeed) { const { id: datafeedId, config: body } = datafeed; - return this.callAsCurrentUser('ml.addDatafeed', { datafeedId, body }); + return this._callAsInternalUser('ml.addDatafeed', { + datafeedId, + body, + ...this._authorizationHeader, + }); } async startDatafeeds( @@ -713,7 +734,7 @@ export class DataRecognizer { const result = { started: false } as DatafeedResponse; let opened = false; try { - const openResult = await this.callAsCurrentUser('ml.openJob', { + const openResult = await this._callAsInternalUser('ml.openJob', { jobId: datafeed.config.job_id, }); opened = openResult.opened; @@ -737,7 +758,10 @@ export class DataRecognizer { duration.end = end; } - await this.callAsCurrentUser('ml.startDatafeed', { datafeedId: datafeed.id, ...duration }); + await this._callAsInternalUser('ml.startDatafeed', { + datafeedId: datafeed.id, + ...duration, + }); result.started = true; } catch (error) { result.started = false; @@ -838,7 +862,7 @@ export class DataRecognizer { updateDatafeedIndices(moduleConfig: Module) { // if the supplied index pattern contains a comma, split into multiple indices and // add each one to the datafeed - const indexPatternNames = splitIndexPatternNames(this.indexPatternName); + const indexPatternNames = splitIndexPatternNames(this._indexPatternName); moduleConfig.datafeeds.forEach((df) => { const newIndices: string[] = []; @@ -876,7 +900,7 @@ export class DataRecognizer { if (url.match(INDEX_PATTERN_ID)) { const newUrl = url.replace( new RegExp(INDEX_PATTERN_ID, 'g'), - this.indexPatternId as string + this._indexPatternId as string ); // update the job's url cUrl.url_value = newUrl; @@ -915,7 +939,7 @@ export class DataRecognizer { if (jsonString.match(INDEX_PATTERN_ID)) { jsonString = jsonString.replace( new RegExp(INDEX_PATTERN_ID, 'g'), - this.indexPatternId as string + this._indexPatternId as string ); item.config.kibanaSavedObjectMeta!.searchSourceJSON = jsonString; } @@ -927,7 +951,7 @@ export class DataRecognizer { if (visStateString !== undefined && visStateString.match(INDEX_PATTERN_NAME)) { visStateString = visStateString.replace( new RegExp(INDEX_PATTERN_NAME, 'g'), - this.indexPatternName + this._indexPatternName ); item.config.visState = visStateString; } @@ -944,10 +968,10 @@ export class DataRecognizer { timeField: string, query?: any ): Promise<{ start: number; end: number }> { - const fieldsService = fieldsServiceProvider(this.callAsCurrentUser); + const fieldsService = fieldsServiceProvider(this._mlClusterClient); const timeFieldRange = await fieldsService.getTimeFieldRange( - this.indexPatternName, + this._indexPatternName, timeField, query ); @@ -974,7 +998,7 @@ export class DataRecognizer { if (estimateMML && this.jobsForModelMemoryEstimation.length > 0) { try { - const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this.callAsCurrentUser); + const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this._mlClusterClient); // Checks if all jobs in the module have the same time field configured const firstJobTimeField = this.jobsForModelMemoryEstimation[0].job.config.data_description @@ -1009,7 +1033,7 @@ export class DataRecognizer { const { modelMemoryLimit } = await calculateModelMemoryLimit( job.config.analysis_config, - this.indexPatternName, + this._indexPatternName, query, job.config.data_description.time_field, earliestMs, @@ -1027,20 +1051,20 @@ export class DataRecognizer { } } - const { limits } = await this.callAsCurrentUser('ml.info'); + const { limits } = (await this._callAsInternalUser('ml.info')) as MlInfoResponse; const maxMml = limits.max_model_memory_limit; if (!maxMml) { return; } - // @ts-ignore + // @ts-expect-error const maxBytes: number = numeral(maxMml.toUpperCase()).value(); for (const job of moduleConfig.jobs) { const mml = job.config?.analysis_limits?.model_memory_limit; if (mml !== undefined) { - // @ts-ignore + // @ts-expect-error const mmlBytes: number = numeral(mml.toUpperCase()).value(); if (mmlBytes > maxBytes) { // if the job's mml is over the max, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json index 0f8fa814ac60a..a4ec84f1fb3f3 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json @@ -1,6 +1,6 @@ { "job_type": "anomaly_detector", - "description": "Looks for unsual errors. Rare and unusual errors may simply indicate an impending service failure but they can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", + "description": "Looks for unusual errors. Rare and unusual errors may simply indicate an impending service failure but they can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", "groups": [ "siem", "cloudtrail" diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index d58c797b446db..7f19f32373e07 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyCallAPIOptions, LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import _ from 'lodash'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/server'; import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types'; import { getSafeAggregationName } from '../../../common/util/job_utils'; +import { stringHash } from '../../../common/util/string_utils'; import { buildBaseFilterCriteria, buildSamplerAggregation, @@ -19,6 +21,8 @@ const SAMPLER_TOP_TERMS_SHARD_SIZE = 5000; const AGGREGATABLE_EXISTS_REQUEST_BATCH_SIZE = 200; const FIELDS_REQUEST_BATCH_SIZE = 10; +const MAX_CHART_COLUMNS = 20; + interface FieldData { fieldName: string; existsInDocs: boolean; @@ -35,6 +39,11 @@ export interface Field { cardinality: number; } +export interface HistogramField { + fieldName: string; + type: string; +} + interface Distribution { percentiles: any[]; minPercentile: number; @@ -98,6 +107,70 @@ interface FieldExamples { examples: any[]; } +interface NumericColumnStats { + interval: number; + min: number; + max: number; +} +type NumericColumnStatsMap = Record; + +interface AggHistogram { + histogram: { + field: string; + interval: number; + }; +} + +interface AggCardinality { + cardinality: { + field: string; + }; +} + +interface AggTerms { + terms: { + field: string; + size: number; + }; +} + +interface NumericDataItem { + key: number; + key_as_string?: string; + doc_count: number; +} + +interface NumericChartData { + data: NumericDataItem[]; + id: string; + interval: number; + stats: [number, number]; + type: 'numeric'; +} + +interface OrdinalDataItem { + key: string; + key_as_string?: string; + doc_count: number; +} + +interface OrdinalChartData { + type: 'ordinal' | 'boolean'; + cardinality: number; + data: OrdinalDataItem[]; + id: string; +} + +interface UnsupportedChartData { + id: string; + type: 'unsupported'; +} + +type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; + +// type ChartDataItem = NumericDataItem | OrdinalDataItem; +type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData; + type BatchStats = | NumericFieldStats | StringFieldStats @@ -106,15 +179,182 @@ type BatchStats = | DocumentCountStats | FieldExamples; +const getAggIntervals = async ( + { callAsCurrentUser }: ILegacyScopedClusterClient, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +): Promise => { + const numericColumns = fields.filter((field) => { + return field.type === KBN_FIELD_TYPES.NUMBER || field.type === KBN_FIELD_TYPES.DATE; + }); + + if (numericColumns.length === 0) { + return {}; + } + + const minMaxAggs = numericColumns.reduce((aggs, c) => { + const id = stringHash(c.fieldName); + aggs[id] = { + stats: { + field: c.fieldName, + }, + }; + return aggs; + }, {} as Record); + + const respStats = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(minMaxAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 ? _.get(respStats.aggregations, aggsPath) : respStats.aggregations; + + return Object.keys(aggregations).reduce((p, aggName) => { + const stats = [aggregations[aggName].min, aggregations[aggName].max]; + if (!stats.includes(null)) { + const delta = aggregations[aggName].max - aggregations[aggName].min; + + let aggInterval = 1; + + if (delta > MAX_CHART_COLUMNS || delta <= 1) { + aggInterval = delta / (MAX_CHART_COLUMNS - 1); + } + + p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; + } + + return p; + }, {} as NumericColumnStatsMap); +}; + +// export for re-use by transforms plugin +export const getHistogramsForFields = async ( + mlClusterClient: ILegacyScopedClusterClient, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) => { + const { callAsCurrentUser } = mlClusterClient; + const aggIntervals = await getAggIntervals( + mlClusterClient, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + const chartDataAggs = fields.reduce((aggs, field) => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(fieldName); + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] !== undefined) { + aggs[`${id}_histogram`] = { + histogram: { + field: fieldName, + interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, + }, + }; + } + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + if (fieldType === KBN_FIELD_TYPES.STRING) { + aggs[`${id}_cardinality`] = { + cardinality: { + field: fieldName, + }, + }; + } + aggs[`${id}_terms`] = { + terms: { + field: fieldName, + size: MAX_CHART_COLUMNS, + }, + }; + } + return aggs; + }, {} as Record); + + if (Object.keys(chartDataAggs).length === 0) { + return []; + } + + const respChartsData = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(chartDataAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 + ? _.get(respChartsData.aggregations, aggsPath) + : respChartsData.aggregations; + + const chartsData: ChartData[] = fields.map( + (field): ChartData => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(field.fieldName); + + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] === undefined) { + return { + type: 'numeric', + data: [], + interval: 0, + stats: [0, 0], + id: fieldName, + }; + } + + return { + data: aggregations[`${id}_histogram`].buckets, + interval: aggIntervals[id].interval, + stats: [aggIntervals[id].min, aggIntervals[id].max], + type: 'numeric', + id: fieldName, + }; + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + return { + type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', + cardinality: + fieldType === KBN_FIELD_TYPES.STRING ? aggregations[`${id}_cardinality`].value : 2, + data: aggregations[`${id}_terms`].buckets, + id: fieldName, + }; + } + + return { + type: 'unsupported', + id: fieldName, + }; + } + ); + + return chartsData; +}; + export class DataVisualizer { - callAsCurrentUser: ( - endpoint: string, - clientParams: Record, - options?: LegacyCallAPIOptions - ) => Promise; - - constructor(callAsCurrentUser: LegacyAPICaller) { - this.callAsCurrentUser = callAsCurrentUser; + private _mlClusterClient: ILegacyScopedClusterClient; + private _callAsCurrentUser: ILegacyScopedClusterClient['callAsCurrentUser']; + + constructor(mlClusterClient: ILegacyScopedClusterClient) { + this._callAsCurrentUser = mlClusterClient.callAsCurrentUser; + this._mlClusterClient = mlClusterClient; } // Obtains overall stats on the fields in the supplied index pattern, returning an object @@ -200,6 +440,24 @@ export class DataVisualizer { return stats; } + // Obtains binned histograms for supplied list of fields. The statistics for each field in the + // returned array depend on the type of the field (keyword, number, date etc). + // Sampling will be used if supplied samplerShardSize > 0. + async getHistogramsForFields( + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number + ): Promise { + return await getHistogramsForFields( + this._mlClusterClient, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + } + // Obtains statistics for supplied list of fields. The statistics for each field in the // returned array depend on the type of the field (keyword, number, date etc). // Sampling will be used if supplied samplerShardSize > 0. @@ -371,7 +629,7 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, rest_total_hits_as_int: true, size, @@ -438,7 +696,7 @@ export class DataVisualizer { }; filterCriteria.push({ exists: { field } }); - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, rest_total_hits_as_int: true, size, @@ -480,7 +738,7 @@ export class DataVisualizer { aggs, }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, size, body, @@ -583,7 +841,7 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, size, body, @@ -704,7 +962,7 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, size, body, @@ -778,7 +1036,7 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, size, body, @@ -845,7 +1103,7 @@ export class DataVisualizer { aggs: buildSamplerAggregation(aggs, samplerShardSize), }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, size, body, @@ -907,7 +1165,7 @@ export class DataVisualizer { }, }; - const resp = await this.callAsCurrentUser('search', { + const resp = await this._callAsCurrentUser('search', { index, rest_total_hits_as_int: true, size, diff --git a/x-pack/plugins/ml/server/models/data_visualizer/index.ts b/x-pack/plugins/ml/server/models/data_visualizer/index.ts index ed44e9b12e1d1..ca1df0fe8300c 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/index.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { DataVisualizer } from './data_visualizer'; +export { getHistogramsForFields, DataVisualizer } from './data_visualizer'; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index 661ea6c6fec24..43a6876f76c49 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { duration } from 'moment'; import { parseInterval } from '../../../common/util/parse_interval'; import { initCardinalityFieldsCache } from './fields_aggs_cache'; @@ -14,7 +14,7 @@ import { initCardinalityFieldsCache } from './fields_aggs_cache'; * Service for carrying out queries to obtain data * specific to fields in Elasticsearch indices. */ -export function fieldsServiceProvider(callAsCurrentUser: LegacyAPICaller) { +export function fieldsServiceProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { const fieldsAggsCache = initCardinalityFieldsCache(); /** diff --git a/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts b/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts index 978355d098b13..9cd71c046b66c 100644 --- a/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/file_data_visualizer/file_data_visualizer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { AnalysisResult, FormattedOverrides, @@ -13,9 +13,9 @@ import { export type InputData = any[]; -export function fileDataVisualizerProvider(callAsCurrentUser: LegacyAPICaller) { +export function fileDataVisualizerProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { async function analyzeFile(data: any, overrides: any): Promise { - const results = await callAsCurrentUser('ml.fileStructure', { + const results = await callAsInternalUser('ml.fileStructure', { body: data, ...overrides, }); diff --git a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts index e082a7462241a..fc9b333298c9d 100644 --- a/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts +++ b/x-pack/plugins/ml/server/models/file_data_visualizer/import_data.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { INDEX_META_DATA_CREATED_BY } from '../../../common/constants/file_datavisualizer'; import { ImportResponse, @@ -15,7 +15,7 @@ import { } from '../../../common/types/file_datavisualizer'; import { InputData } from './file_data_visualizer'; -export function importDataProvider(callAsCurrentUser: LegacyAPICaller) { +export function importDataProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { async function importData( id: string, index: string, diff --git a/x-pack/plugins/ml/server/models/filter/filter_manager.ts b/x-pack/plugins/ml/server/models/filter/filter_manager.ts index 40a20030cb635..20dc95e92a86c 100644 --- a/x-pack/plugins/ml/server/models/filter/filter_manager.ts +++ b/x-pack/plugins/ml/server/models/filter/filter_manager.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { DetectorRule, DetectorRuleScope } from '../../../common/types/detector_rules'; @@ -58,14 +58,17 @@ interface PartialJob { } export class FilterManager { - constructor(private callAsCurrentUser: LegacyAPICaller) {} + private _callAsInternalUser: ILegacyScopedClusterClient['callAsInternalUser']; + constructor({ callAsInternalUser }: ILegacyScopedClusterClient) { + this._callAsInternalUser = callAsInternalUser; + } async getFilter(filterId: string) { try { const [JOBS, FILTERS] = [0, 1]; const results = await Promise.all([ - this.callAsCurrentUser('ml.jobs'), - this.callAsCurrentUser('ml.filters', { filterId }), + this._callAsInternalUser('ml.jobs'), + this._callAsInternalUser('ml.filters', { filterId }), ]); if (results[FILTERS] && results[FILTERS].filters.length) { @@ -87,7 +90,7 @@ export class FilterManager { async getAllFilters() { try { - const filtersResp = await this.callAsCurrentUser('ml.filters'); + const filtersResp = await this._callAsInternalUser('ml.filters'); return filtersResp.filters; } catch (error) { throw Boom.badRequest(error); @@ -98,8 +101,8 @@ export class FilterManager { try { const [JOBS, FILTERS] = [0, 1]; const results = await Promise.all([ - this.callAsCurrentUser('ml.jobs'), - this.callAsCurrentUser('ml.filters'), + this._callAsInternalUser('ml.jobs'), + this._callAsInternalUser('ml.filters'), ]); // Build a map of filter_ids against jobs and detectors using that filter. @@ -137,7 +140,7 @@ export class FilterManager { delete filter.filterId; try { // Returns the newly created filter. - return await this.callAsCurrentUser('ml.addFilter', { filterId, body: filter }); + return await this._callAsInternalUser('ml.addFilter', { filterId, body: filter }); } catch (error) { throw Boom.badRequest(error); } @@ -157,7 +160,7 @@ export class FilterManager { } // Returns the newly updated filter. - return await this.callAsCurrentUser('ml.updateFilter', { + return await this._callAsInternalUser('ml.updateFilter', { filterId, body, }); @@ -167,7 +170,7 @@ export class FilterManager { } async deleteFilter(filterId: string) { - return this.callAsCurrentUser('ml.deleteFilter', { filterId }); + return this._callAsInternalUser('ml.deleteFilter', { filterId }); } buildFiltersInUse(jobsList: PartialJob[]) { diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts index f11771a88c5c6..d72552b548b82 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.d.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; export function jobAuditMessagesProvider( - callAsCurrentUser: LegacyAPICaller + mlClusterClient: ILegacyScopedClusterClient ): { getJobAuditMessages: (jobId?: string, from?: string) => any; getAuditMessagesSummary: (jobIds?: string[]) => any; diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js index 6b782f8652363..dcbabd879b47a 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js @@ -34,14 +34,14 @@ const anomalyDetectorTypeFilter = { }, }; -export function jobAuditMessagesProvider(callAsCurrentUser) { +export function jobAuditMessagesProvider({ callAsCurrentUser, callAsInternalUser }) { // search for audit messages, // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d async function getJobAuditMessages(jobId, from) { let gte = null; if (jobId !== undefined && from === undefined) { - const jobs = await callAsCurrentUser('ml.jobs', { jobId }); + const jobs = await callAsInternalUser('ml.jobs', { jobId }); if (jobs.count > 0 && jobs.jobs !== undefined) { gte = moment(jobs.jobs[0].create_time).valueOf(); } diff --git a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts index 0f64f5e0e7b4f..98e1be48bb766 100644 --- a/x-pack/plugins/ml/server/models/job_service/datafeeds.ts +++ b/x-pack/plugins/ml/server/models/job_service/datafeeds.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; @@ -26,7 +26,7 @@ interface Results { }; } -export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { +export function datafeedsProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { async function forceStartDatafeeds(datafeedIds: string[], start?: number, end?: number) { const jobIds = await getJobIdsByDatafeedId(); const doStartsCalled = datafeedIds.reduce((acc, cur) => { @@ -84,7 +84,7 @@ export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { async function openJob(jobId: string) { let opened = false; try { - const resp = await callAsCurrentUser('ml.openJob', { jobId }); + const resp = await callAsInternalUser('ml.openJob', { jobId }); opened = resp.opened; } catch (error) { if (error.statusCode === 409) { @@ -97,7 +97,7 @@ export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { } async function startDatafeed(datafeedId: string, start?: number, end?: number) { - return callAsCurrentUser('ml.startDatafeed', { datafeedId, start, end }); + return callAsInternalUser('ml.startDatafeed', { datafeedId, start, end }); } async function stopDatafeeds(datafeedIds: string[]) { @@ -105,7 +105,7 @@ export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { for (const datafeedId of datafeedIds) { try { - results[datafeedId] = await callAsCurrentUser('ml.stopDatafeed', { datafeedId }); + results[datafeedId] = await callAsInternalUser('ml.stopDatafeed', { datafeedId }); } catch (error) { if (isRequestTimeout(error)) { return fillResultsWithTimeouts(results, datafeedId, datafeedIds, DATAFEED_STATE.STOPPED); @@ -117,11 +117,11 @@ export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { } async function forceDeleteDatafeed(datafeedId: string) { - return callAsCurrentUser('ml.deleteDatafeed', { datafeedId, force: true }); + return callAsInternalUser('ml.deleteDatafeed', { datafeedId, force: true }); } async function getDatafeedIdsByJobId() { - const { datafeeds } = await callAsCurrentUser('ml.datafeeds'); + const { datafeeds } = (await callAsInternalUser('ml.datafeeds')) as MlDatafeedsResponse; return datafeeds.reduce((acc, cur) => { acc[cur.job_id] = cur.datafeed_id; return acc; @@ -129,7 +129,7 @@ export function datafeedsProvider(callAsCurrentUser: LegacyAPICaller) { } async function getJobIdsByDatafeedId() { - const { datafeeds } = await callAsCurrentUser('ml.datafeeds'); + const { datafeeds } = (await callAsInternalUser('ml.datafeeds')) as MlDatafeedsResponse; return datafeeds.reduce((acc, cur) => { acc[cur.datafeed_id] = cur.job_id; return acc; diff --git a/x-pack/plugins/ml/server/models/job_service/groups.ts b/x-pack/plugins/ml/server/models/job_service/groups.ts index ab5707ab29e65..c4ea854c14f87 100644 --- a/x-pack/plugins/ml/server/models/job_service/groups.ts +++ b/x-pack/plugins/ml/server/models/job_service/groups.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CalendarManager } from '../calendar'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { Job } from '../../../common/types/anomaly_detection_jobs'; @@ -23,14 +23,15 @@ interface Results { }; } -export function groupsProvider(callAsCurrentUser: LegacyAPICaller) { - const calMngr = new CalendarManager(callAsCurrentUser); +export function groupsProvider(mlClusterClient: ILegacyScopedClusterClient) { + const calMngr = new CalendarManager(mlClusterClient); + const { callAsInternalUser } = mlClusterClient; async function getAllGroups() { const groups: { [id: string]: Group } = {}; const jobIds: { [id: string]: undefined | null } = {}; const [{ jobs }, calendars] = await Promise.all([ - callAsCurrentUser('ml.jobs'), + callAsInternalUser('ml.jobs') as Promise, calMngr.getAllCalendars(), ]); @@ -79,7 +80,7 @@ export function groupsProvider(callAsCurrentUser: LegacyAPICaller) { for (const job of jobs) { const { job_id: jobId, groups } = job; try { - await callAsCurrentUser('ml.updateJob', { jobId, body: { groups } }); + await callAsInternalUser('ml.updateJob', { jobId, body: { groups } }); results[jobId] = { success: true }; } catch (error) { results[jobId] = { success: false, error }; diff --git a/x-pack/plugins/ml/server/models/job_service/index.ts b/x-pack/plugins/ml/server/models/job_service/index.ts index 5d053c1be73e4..1ff33a7b00f0b 100644 --- a/x-pack/plugins/ml/server/models/job_service/index.ts +++ b/x-pack/plugins/ml/server/models/job_service/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; @@ -12,14 +12,14 @@ import { newJobCapsProvider } from './new_job_caps'; import { newJobChartsProvider, topCategoriesProvider } from './new_job'; import { modelSnapshotProvider } from './model_snapshots'; -export function jobServiceProvider(callAsCurrentUser: LegacyAPICaller) { +export function jobServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { return { - ...datafeedsProvider(callAsCurrentUser), - ...jobsProvider(callAsCurrentUser), - ...groupsProvider(callAsCurrentUser), - ...newJobCapsProvider(callAsCurrentUser), - ...newJobChartsProvider(callAsCurrentUser), - ...topCategoriesProvider(callAsCurrentUser), - ...modelSnapshotProvider(callAsCurrentUser), + ...datafeedsProvider(mlClusterClient), + ...jobsProvider(mlClusterClient), + ...groupsProvider(mlClusterClient), + ...newJobCapsProvider(mlClusterClient), + ...newJobChartsProvider(mlClusterClient), + ...topCategoriesProvider(mlClusterClient), + ...modelSnapshotProvider(mlClusterClient), }; } diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 2d26b2150edf3..aca0c5d72a9f5 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { uniq } from 'lodash'; import Boom from 'boom'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { JOB_STATE, DATAFEED_STATE } from '../../../common/constants/states'; import { MlSummaryJob, @@ -46,14 +46,16 @@ interface Results { }; } -export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { - const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(callAsCurrentUser); - const { getAuditMessagesSummary } = jobAuditMessagesProvider(callAsCurrentUser); - const { getLatestBucketTimestampByJob } = resultsServiceProvider(callAsCurrentUser); - const calMngr = new CalendarManager(callAsCurrentUser); +export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { + const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; + + const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(mlClusterClient); + const { getAuditMessagesSummary } = jobAuditMessagesProvider(mlClusterClient); + const { getLatestBucketTimestampByJob } = resultsServiceProvider(mlClusterClient); + const calMngr = new CalendarManager(mlClusterClient); async function forceDeleteJob(jobId: string) { - return callAsCurrentUser('ml.deleteJob', { jobId, force: true }); + return callAsInternalUser('ml.deleteJob', { jobId, force: true }); } async function deleteJobs(jobIds: string[]) { @@ -97,7 +99,7 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { const results: Results = {}; for (const jobId of jobIds) { try { - await callAsCurrentUser('ml.closeJob', { jobId }); + await callAsInternalUser('ml.closeJob', { jobId }); results[jobId] = { closed: true }; } catch (error) { if (isRequestTimeout(error)) { @@ -113,7 +115,7 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { // if the job has failed we want to attempt a force close. // however, if we received a 409 due to the datafeed being started we should not attempt a force close. try { - await callAsCurrentUser('ml.closeJob', { jobId, force: true }); + await callAsInternalUser('ml.closeJob', { jobId, force: true }); results[jobId] = { closed: true }; } catch (error2) { if (isRequestTimeout(error)) { @@ -136,12 +138,12 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { throw Boom.notFound(`Cannot find datafeed for job ${jobId}`); } - const dfResult = await callAsCurrentUser('ml.stopDatafeed', { datafeedId, force: true }); + const dfResult = await callAsInternalUser('ml.stopDatafeed', { datafeedId, force: true }); if (!dfResult || dfResult.stopped !== true) { return { success: false }; } - await callAsCurrentUser('ml.closeJob', { jobId, force: true }); + await callAsInternalUser('ml.closeJob', { jobId, force: true }); return { success: true }; } @@ -257,13 +259,13 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { Promise<{ [id: string]: number | undefined }> ] = [ jobIds.length > 0 - ? callAsCurrentUser('ml.jobs', { jobId: jobIds }) // move length check in side call - : callAsCurrentUser('ml.jobs'), + ? (callAsInternalUser('ml.jobs', { jobId: jobIds }) as Promise) // move length check in side call + : (callAsInternalUser('ml.jobs') as Promise), jobIds.length > 0 - ? callAsCurrentUser('ml.jobStats', { jobId: jobIds }) - : callAsCurrentUser('ml.jobStats'), - callAsCurrentUser('ml.datafeeds'), - callAsCurrentUser('ml.datafeedStats'), + ? (callAsInternalUser('ml.jobStats', { jobId: jobIds }) as Promise) + : (callAsInternalUser('ml.jobStats') as Promise), + callAsInternalUser('ml.datafeeds') as Promise, + callAsInternalUser('ml.datafeedStats') as Promise, calMngr.getAllCalendars(), getLatestBucketTimestampByJob(), ]; @@ -402,7 +404,7 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { } catch (e) { // if the user doesn't have permission to load the task list, // use the jobs list to get the ids of deleting jobs - const { jobs } = await callAsCurrentUser('ml.jobs'); + const { jobs } = (await callAsInternalUser('ml.jobs')) as MlJobsResponse; jobIds.push(...jobs.filter((j) => j.deleting === true).map((j) => j.job_id)); } return { jobIds }; @@ -413,9 +415,9 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { // e.g. *_low_request_rate_ecs async function jobsExist(jobIds: string[] = []) { // Get the list of job IDs. - const jobsInfo = await callAsCurrentUser('ml.jobs', { + const jobsInfo = (await callAsInternalUser('ml.jobs', { jobId: jobIds, - }); + })) as MlJobsResponse; const results: { [id: string]: boolean } = {}; if (jobsInfo.count > 0) { @@ -438,8 +440,8 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { } async function getAllJobAndGroupIds() { - const { getAllGroups } = groupsProvider(callAsCurrentUser); - const jobs = await callAsCurrentUser('ml.jobs'); + const { getAllGroups } = groupsProvider(mlClusterClient); + const jobs = (await callAsInternalUser('ml.jobs')) as MlJobsResponse; const jobIds = jobs.jobs.map((job) => job.job_id); const groups = await getAllGroups(); const groupIds = groups.map((group) => group.id); @@ -453,7 +455,7 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { async function getLookBackProgress(jobId: string, start: number, end: number) { const datafeedId = `datafeed-${jobId}`; const [jobStats, isRunning] = await Promise.all([ - callAsCurrentUser('ml.jobStats', { jobId: [jobId] }), + callAsInternalUser('ml.jobStats', { jobId: [jobId] }) as Promise, isDatafeedRunning(datafeedId), ]); @@ -472,9 +474,9 @@ export function jobsProvider(callAsCurrentUser: LegacyAPICaller) { } async function isDatafeedRunning(datafeedId: string) { - const stats = await callAsCurrentUser('ml.datafeedStats', { + const stats = (await callAsInternalUser('ml.datafeedStats', { datafeedId: [datafeedId], - }); + })) as MlDatafeedsStatsResponse; if (stats.datafeeds.length) { const state = stats.datafeeds[0].state; return ( diff --git a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts index 136d4f47c7fac..576d6f8cbb160 100644 --- a/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts +++ b/x-pack/plugins/ml/server/models/job_service/model_snapshots.ts @@ -6,10 +6,9 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ModelSnapshot } from '../../../common/types/anomaly_detection_jobs'; -import { datafeedsProvider, MlDatafeedsResponse } from './datafeeds'; -import { MlJobsResponse } from './jobs'; +import { datafeedsProvider } from './datafeeds'; import { FormCalendar, CalendarManager } from '../calendar'; export interface ModelSnapshotsResponse { @@ -20,8 +19,9 @@ export interface RevertModelSnapshotResponse { model: ModelSnapshot; } -export function modelSnapshotProvider(callAsCurrentUser: LegacyAPICaller) { - const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(callAsCurrentUser); +export function modelSnapshotProvider(mlClusterClient: ILegacyScopedClusterClient) { + const { callAsInternalUser } = mlClusterClient; + const { forceStartDatafeeds, getDatafeedIdsByJobId } = datafeedsProvider(mlClusterClient); async function revertModelSnapshot( jobId: string, @@ -33,12 +33,12 @@ export function modelSnapshotProvider(callAsCurrentUser: LegacyAPICaller) { ) { let datafeedId = `datafeed-${jobId}`; // ensure job exists - await callAsCurrentUser('ml.jobs', { jobId: [jobId] }); + await callAsInternalUser('ml.jobs', { jobId: [jobId] }); try { // ensure the datafeed exists // the datafeed is probably called datafeed- - await callAsCurrentUser('ml.datafeeds', { + await callAsInternalUser('ml.datafeeds', { datafeedId: [datafeedId], }); } catch (e) { @@ -52,22 +52,19 @@ export function modelSnapshotProvider(callAsCurrentUser: LegacyAPICaller) { } // ensure the snapshot exists - const snapshot = await callAsCurrentUser('ml.modelSnapshots', { + const snapshot = (await callAsInternalUser('ml.modelSnapshots', { jobId, snapshotId, - }); + })) as ModelSnapshotsResponse; // apply the snapshot revert - const { model } = await callAsCurrentUser( - 'ml.revertModelSnapshot', - { - jobId, - snapshotId, - body: { - delete_intervening_results: deleteInterveningResults, - }, - } - ); + const { model } = (await callAsInternalUser('ml.revertModelSnapshot', { + jobId, + snapshotId, + body: { + delete_intervening_results: deleteInterveningResults, + }, + })) as RevertModelSnapshotResponse; // create calendar (if specified) and replay datafeed if (replay && model.snapshot_id === snapshotId && snapshot.model_snapshots.length) { @@ -88,7 +85,7 @@ export function modelSnapshotProvider(callAsCurrentUser: LegacyAPICaller) { end_time: s.end, })), }; - const cm = new CalendarManager(callAsCurrentUser); + const cm = new CalendarManager(mlClusterClient); await cm.newCalendar(calendar); } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index bf0d79b3ec072..ca3e0cef21049 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'kibana/server'; import { chunk } from 'lodash'; import { SearchResponse } from 'elasticsearch'; import { CATEGORY_EXAMPLES_SAMPLE_SIZE } from '../../../../../common/constants/categorization_job'; @@ -12,15 +13,14 @@ import { CategorizationAnalyzer, CategoryFieldExample, } from '../../../../../common/types/categories'; -import { callWithRequestType } from '../../../../../common/types/kibana'; import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; -export function categorizationExamplesProvider( - callWithRequest: callWithRequestType, - callWithInternalUser: callWithRequestType -) { +export function categorizationExamplesProvider({ + callAsCurrentUser, + callAsInternalUser, +}: ILegacyScopedClusterClient) { const validationResults = new ValidationResults(); async function categorizationExamples( @@ -57,7 +57,7 @@ export function categorizationExamplesProvider( } } - const results: SearchResponse<{ [id: string]: string }> = await callWithRequest('search', { + const results: SearchResponse<{ [id: string]: string }> = await callAsCurrentUser('search', { index: indexPatternTitle, size, body: { @@ -112,7 +112,7 @@ export function categorizationExamplesProvider( } async function loadTokens(examples: string[], analyzer: CategorizationAnalyzer) { - const { tokens }: { tokens: Token[] } = await callWithInternalUser('indices.analyze', { + const { tokens }: { tokens: Token[] } = await callAsInternalUser('indices.analyze', { body: { ...getAnalyzer(analyzer), text: examples, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts index 13c5f107972eb..4f97238a4a0b5 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts @@ -5,13 +5,13 @@ */ import { SearchResponse } from 'elasticsearch'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { CategoryId, Category } from '../../../../../common/types/categories'; -import { callWithRequestType } from '../../../../../common/types/kibana'; -export function topCategoriesProvider(callWithRequest: callWithRequestType) { +export function topCategoriesProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { async function getTotalCategories(jobId: string): Promise<{ total: number }> { - const totalResp = await callWithRequest('search', { + const totalResp = await callAsCurrentUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -37,7 +37,7 @@ export function topCategoriesProvider(callWithRequest: callWithRequestType) { } async function getTopCategoryCounts(jobId: string, numberOfCategories: number) { - const top: SearchResponse = await callWithRequest('search', { + const top: SearchResponse = await callAsCurrentUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -99,7 +99,7 @@ export function topCategoriesProvider(callWithRequest: callWithRequestType) { field: 'category_id', }, }; - const result: SearchResponse = await callWithRequest('search', { + const result: SearchResponse = await callAsCurrentUser('search', { index: ML_RESULTS_INDEX_PATTERN, size, body: { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts index 88ae8caa91e4a..63ae2c624ac38 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/charts.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'kibana/server'; import { newJobLineChartProvider } from './line_chart'; import { newJobPopulationChartProvider } from './population_chart'; -import { callWithRequestType } from '../../../../common/types/kibana'; -export function newJobChartsProvider(callWithRequest: callWithRequestType) { - const { newJobLineChart } = newJobLineChartProvider(callWithRequest); - const { newJobPopulationChart } = newJobPopulationChartProvider(callWithRequest); +export function newJobChartsProvider(mlClusterClient: ILegacyScopedClusterClient) { + const { newJobLineChart } = newJobLineChartProvider(mlClusterClient); + const { newJobPopulationChart } = newJobPopulationChartProvider(mlClusterClient); return { newJobLineChart, diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts index 4872f0f5e0ea4..3080b37867de5 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -5,8 +5,8 @@ */ import { get } from 'lodash'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; -import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; type DtrIndex = number; @@ -23,7 +23,7 @@ interface ProcessedResults { totalResults: number; } -export function newJobLineChartProvider(callWithRequest: callWithRequestType) { +export function newJobLineChartProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { async function newJobLineChart( indexPatternTitle: string, timeField: string, @@ -47,7 +47,7 @@ export function newJobLineChartProvider(callWithRequest: callWithRequestType) { splitFieldValue ); - const results = await callWithRequest('search', json); + const results = await callAsCurrentUser('search', json); return processSearchResults( results, aggFieldNamePairs.map((af) => af.field) diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts index 26609bdcc8f7d..a9a2ce57f966c 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -5,8 +5,8 @@ */ import { get } from 'lodash'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; -import { callWithRequestType } from '../../../../common/types/kibana'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; const OVER_FIELD_EXAMPLES_COUNT = 40; @@ -29,7 +29,7 @@ interface ProcessedResults { totalResults: number; } -export function newJobPopulationChartProvider(callWithRequest: callWithRequestType) { +export function newJobPopulationChartProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { async function newJobPopulationChart( indexPatternTitle: string, timeField: string, @@ -52,7 +52,7 @@ export function newJobPopulationChartProvider(callWithRequest: callWithRequestTy ); try { - const results = await callWithRequest('search', json); + const results = await callAsCurrentUser('search', json); return processSearchResults( results, aggFieldNamePairs.map((af) => af.field) diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index a5ed4a18bf51c..fd20610450cc1 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'kibana/server'; import { cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/server'; import { @@ -39,32 +40,32 @@ const supportedTypes: string[] = [ export function fieldServiceProvider( indexPattern: string, isRollup: boolean, - callWithRequest: any, + mlClusterClient: ILegacyScopedClusterClient, savedObjectsClient: SavedObjectsClientContract ) { - return new FieldsService(indexPattern, isRollup, callWithRequest, savedObjectsClient); + return new FieldsService(indexPattern, isRollup, mlClusterClient, savedObjectsClient); } class FieldsService { private _indexPattern: string; private _isRollup: boolean; - private _callWithRequest: any; + private _mlClusterClient: ILegacyScopedClusterClient; private _savedObjectsClient: SavedObjectsClientContract; constructor( indexPattern: string, isRollup: boolean, - callWithRequest: any, - savedObjectsClient: any + mlClusterClient: ILegacyScopedClusterClient, + savedObjectsClient: SavedObjectsClientContract ) { this._indexPattern = indexPattern; this._isRollup = isRollup; - this._callWithRequest = callWithRequest; + this._mlClusterClient = mlClusterClient; this._savedObjectsClient = savedObjectsClient; } private async loadFieldCaps(): Promise { - return this._callWithRequest('fieldCaps', { + return this._mlClusterClient.callAsCurrentUser('fieldCaps', { index: this._indexPattern, fields: '*', }); @@ -108,7 +109,7 @@ class FieldsService { if (this._isRollup) { const rollupService = await rollupServiceProvider( this._indexPattern, - this._callWithRequest, + this._mlClusterClient, this._savedObjectsClient ); const rollupConfigs: RollupJob[] | null = await rollupService.getRollupJobs(); diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts index 02fef16a384d0..38d6481e02a74 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts @@ -16,19 +16,23 @@ import farequoteJobCapsEmpty from './__mocks__/results/farequote_job_caps_empty. import cloudwatchJobCaps from './__mocks__/results/cloudwatch_rollup_job_caps.json'; describe('job_service - job_caps', () => { - let callWithRequestNonRollupMock: jest.Mock; - let callWithRequestRollupMock: jest.Mock; + let mlClusterClientNonRollupMock: any; + let mlClusterClientRollupMock: any; let savedObjectsClientMock: any; beforeEach(() => { - callWithRequestNonRollupMock = jest.fn((action: string) => { + const callAsNonRollupMock = jest.fn((action: string) => { switch (action) { case 'fieldCaps': return farequoteFieldCaps; } }); + mlClusterClientNonRollupMock = { + callAsCurrentUser: callAsNonRollupMock, + callAsInternalUser: callAsNonRollupMock, + }; - callWithRequestRollupMock = jest.fn((action: string) => { + const callAsRollupMock = jest.fn((action: string) => { switch (action) { case 'fieldCaps': return cloudwatchFieldCaps; @@ -36,6 +40,10 @@ describe('job_service - job_caps', () => { return Promise.resolve(rollupCaps); } }); + mlClusterClientRollupMock = { + callAsCurrentUser: callAsRollupMock, + callAsInternalUser: callAsRollupMock, + }; savedObjectsClientMock = { async find() { @@ -48,7 +56,7 @@ describe('job_service - job_caps', () => { it('can get job caps for index pattern', async (done) => { const indexPattern = 'farequote-*'; const isRollup = false; - const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock); + const { newJobCaps } = newJobCapsProvider(mlClusterClientNonRollupMock); const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(farequoteJobCaps); done(); @@ -57,7 +65,7 @@ describe('job_service - job_caps', () => { it('can get rollup job caps for non rollup index pattern', async (done) => { const indexPattern = 'farequote-*'; const isRollup = true; - const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock); + const { newJobCaps } = newJobCapsProvider(mlClusterClientNonRollupMock); const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(farequoteJobCapsEmpty); done(); @@ -68,7 +76,7 @@ describe('job_service - job_caps', () => { it('can get rollup job caps for rollup index pattern', async (done) => { const indexPattern = 'cloud_roll_index'; const isRollup = true; - const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock); + const { newJobCaps } = newJobCapsProvider(mlClusterClientRollupMock); const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).toEqual(cloudwatchJobCaps); done(); @@ -77,7 +85,7 @@ describe('job_service - job_caps', () => { it('can get non rollup job caps for rollup index pattern', async (done) => { const indexPattern = 'cloud_roll_index'; const isRollup = false; - const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock); + const { newJobCaps } = newJobCapsProvider(mlClusterClientRollupMock); const response = await newJobCaps(indexPattern, isRollup, savedObjectsClientMock); expect(response).not.toEqual(cloudwatchJobCaps); done(); diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts index a0ab4b5cf4e3e..5616dade53a78 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { ILegacyScopedClusterClient, SavedObjectsClientContract } from 'kibana/server'; import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; import { fieldServiceProvider } from './field_service'; @@ -12,7 +12,7 @@ interface NewJobCapsResponse { [indexPattern: string]: NewJobCaps; } -export function newJobCapsProvider(callWithRequest: any) { +export function newJobCapsProvider(mlClusterClient: ILegacyScopedClusterClient) { async function newJobCaps( indexPattern: string, isRollup: boolean = false, @@ -21,7 +21,7 @@ export function newJobCapsProvider(callWithRequest: any) { const fieldService = fieldServiceProvider( indexPattern, isRollup, - callWithRequest, + mlClusterClient, savedObjectsClient ); const { aggs, fields } = await fieldService.getData(); diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index f7d846839503d..f3a9bd49c27d6 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ILegacyScopedClusterClient } from 'kibana/server'; import { SavedObject } from 'kibana/server'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { SavedObjectsClientContract } from 'kibana/server'; @@ -21,7 +22,7 @@ export interface RollupJob { export async function rollupServiceProvider( indexPattern: string, - callWithRequest: any, + { callAsCurrentUser }: ILegacyScopedClusterClient, savedObjectsClient: SavedObjectsClientContract ) { const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, savedObjectsClient); @@ -31,7 +32,7 @@ export async function rollupServiceProvider( if (rollupIndexPatternObject !== null) { const parsedTypeMetaData = JSON.parse(rollupIndexPatternObject.attributes.typeMeta); const rollUpIndex: string = parsedTypeMetaData.params.rollup_index; - const rollupCaps = await callWithRequest('ml.rollupIndexCapabilities', { + const rollupCaps = await callAsCurrentUser('ml.rollupIndexCapabilities', { indexPattern: rollUpIndex, }); diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts index 8deaae823e8b3..1c74953e4dda9 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.test.ts @@ -4,28 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { validateJob, ValidateJobPayload } from './job_validation'; import { JobValidationMessage } from '../../../common/constants/messages'; -// mock callWithRequest -const callWithRequest: LegacyAPICaller = (method: string) => { - return new Promise((resolve) => { - if (method === 'fieldCaps') { - resolve({ fields: [] }); - return; - } else if (method === 'ml.info') { - resolve({ - limits: { - effective_max_model_memory_limit: '100MB', - max_model_memory_limit: '1GB', - }, - }); - } - resolve({}); - }) as Promise; -}; +const mlClusterClient = ({ + // mock callAsCurrentUser + callAsCurrentUser: (method: string) => { + return new Promise((resolve) => { + if (method === 'fieldCaps') { + resolve({ fields: [] }); + return; + } else if (method === 'ml.info') { + resolve({ + limits: { + effective_max_model_memory_limit: '100MB', + max_model_memory_limit: '1GB', + }, + }); + } + resolve({}); + }) as Promise; + }, + + // mock callAsInternalUser + callAsInternalUser: (method: string) => { + return new Promise((resolve) => { + if (method === 'fieldCaps') { + resolve({ fields: [] }); + return; + } else if (method === 'ml.info') { + resolve({ + limits: { + effective_max_model_memory_limit: '100MB', + max_model_memory_limit: '1GB', + }, + }); + } + resolve({}); + }) as Promise; + }, +} as unknown) as ILegacyScopedClusterClient; // Note: The tests cast `payload` as any // so we can simulate possible runtime payloads @@ -36,7 +56,7 @@ describe('ML - validateJob', () => { job: { analysis_config: { detectors: [] } }, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ @@ -56,7 +76,7 @@ describe('ML - validateJob', () => { job_id: id, }, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).catch(() => { + return validateJob(mlClusterClient, payload).catch(() => { new Error('Promise should not fail for jobIdTests.'); }); }); @@ -77,7 +97,7 @@ describe('ML - validateJob', () => { job: { analysis_config: { detectors: [] }, groups: testIds }, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids.includes(messageId)).toBe(true); }); @@ -117,7 +137,7 @@ describe('ML - validateJob', () => { const payload = ({ job: { analysis_config: { bucket_span: format, detectors: [] } }, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).catch(() => { + return validateJob(mlClusterClient, payload).catch(() => { new Error('Promise should not fail for bucketSpanFormatTests.'); }); }); @@ -152,11 +172,11 @@ describe('ML - validateJob', () => { function: '', }); payload.job.analysis_config.detectors.push({ - // @ts-ignore + // @ts-expect-error function: undefined, }); - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids.includes('detectors_function_empty')).toBe(true); }); @@ -170,7 +190,7 @@ describe('ML - validateJob', () => { function: 'count', }); - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids.includes('detectors_function_not_empty')).toBe(true); }); @@ -182,7 +202,7 @@ describe('ML - validateJob', () => { fields: {}, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids.includes('index_fields_invalid')).toBe(true); }); @@ -194,7 +214,7 @@ describe('ML - validateJob', () => { fields: { testField: {} }, } as unknown) as ValidateJobPayload; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids.includes('index_fields_valid')).toBe(true); }); @@ -222,7 +242,7 @@ describe('ML - validateJob', () => { const payload = getBasicPayload() as any; delete payload.job.analysis_config.influencers; - validateJob(callWithRequest, payload).then( + validateJob(mlClusterClient, payload).then( () => done( new Error('Promise should not resolve for this test when influencers is not an Array.') @@ -234,7 +254,7 @@ describe('ML - validateJob', () => { it('detect duplicate detectors', () => { const payload = getBasicPayload() as any; payload.job.analysis_config.detectors.push({ function: 'count' }); - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -257,7 +277,7 @@ describe('ML - validateJob', () => { { function: 'count', by_field_name: 'airline' }, { function: 'count', partition_field_name: 'airline' }, ]; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -272,7 +292,7 @@ describe('ML - validateJob', () => { // Failing https://github.com/elastic/kibana/issues/65865 it('basic validation passes, extended checks return some messages', () => { const payload = getBasicPayload(); - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -305,7 +325,7 @@ describe('ML - validateJob', () => { fields: { testField: {} }, }; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -338,7 +358,7 @@ describe('ML - validateJob', () => { fields: { testField: {} }, }; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -381,7 +401,7 @@ describe('ML - validateJob', () => { fields: { testField: {} }, }; - return validateJob(callWithRequest, payload).then((messages) => { + return validateJob(mlClusterClient, payload).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([ 'job_id_valid', @@ -400,7 +420,7 @@ describe('ML - validateJob', () => { const docsTestPayload = getBasicPayload() as any; docsTestPayload.job.analysis_config.detectors = [{ function: 'count', by_field_name: 'airline' }]; it('creates a docs url pointing to the current docs version', () => { - return validateJob(callWithRequest, docsTestPayload).then((messages) => { + return validateJob(mlClusterClient, docsTestPayload).then((messages) => { const message = messages[ messages.findIndex((m) => m.id === 'field_not_aggregatable') ] as JobValidationMessage; @@ -409,7 +429,7 @@ describe('ML - validateJob', () => { }); it('creates a docs url pointing to the master docs version', () => { - return validateJob(callWithRequest, docsTestPayload, 'master').then((messages) => { + return validateJob(mlClusterClient, docsTestPayload, 'master').then((messages) => { const message = messages[ messages.findIndex((m) => m.id === 'field_not_aggregatable') ] as JobValidationMessage; diff --git a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts index 6e65e5e64f3b7..118e923283b3f 100644 --- a/x-pack/plugins/ml/server/models/job_validation/job_validation.ts +++ b/x-pack/plugins/ml/server/models/job_validation/job_validation.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import Boom from 'boom'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { fieldsServiceProvider } from '../fields_service'; @@ -19,7 +19,7 @@ import { import { VALIDATION_STATUS } from '../../../common/constants/validation'; import { basicJobValidation, uniqWithIsEqual } from '../../../common/util/job_utils'; -// @ts-ignore +// @ts-expect-error import { validateBucketSpan } from './validate_bucket_span'; import { validateCardinality } from './validate_cardinality'; import { validateInfluencers } from './validate_influencers'; @@ -35,10 +35,9 @@ export type ValidateJobPayload = TypeOf; * @kbn/config-schema has checked the payload {@link validateJobSchema}. */ export async function validateJob( - callWithRequest: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, payload: ValidateJobPayload, kbnVersion = 'current', - callAsInternalUser?: LegacyAPICaller, isSecurityDisabled?: boolean ) { const messages = getMessages(); @@ -65,8 +64,8 @@ export async function validateJob( // if no duration was part of the request, fall back to finding out // the time range of the time field of the index, but also check first // if the time field is a valid field of type 'date' using isValidTimeField() - if (typeof duration === 'undefined' && (await isValidTimeField(callWithRequest, job))) { - const fs = fieldsServiceProvider(callWithRequest); + if (typeof duration === 'undefined' && (await isValidTimeField(mlClusterClient, job))) { + const fs = fieldsServiceProvider(mlClusterClient); const index = job.datafeed_config.indices.join(','); const timeField = job.data_description.time_field; const timeRange = await fs.getTimeFieldRange(index, timeField, job.datafeed_config.query); @@ -81,29 +80,23 @@ export async function validateJob( // next run only the cardinality tests to find out if they trigger an error // so we can decide later whether certain additional tests should be run - const cardinalityMessages = await validateCardinality(callWithRequest, job); + const cardinalityMessages = await validateCardinality(mlClusterClient, job); validationMessages.push(...cardinalityMessages); const cardinalityError = cardinalityMessages.some((m) => { return messages[m.id as MessageId].status === VALIDATION_STATUS.ERROR; }); validationMessages.push( - ...(await validateBucketSpan( - callWithRequest, - job, - duration, - callAsInternalUser, - isSecurityDisabled - )) + ...(await validateBucketSpan(mlClusterClient, job, duration, isSecurityDisabled)) ); - validationMessages.push(...(await validateTimeRange(callWithRequest, job, duration))); + validationMessages.push(...(await validateTimeRange(mlClusterClient, job, duration))); // only run the influencer and model memory limit checks // if cardinality checks didn't return a message with an error level if (cardinalityError === false) { - validationMessages.push(...(await validateInfluencers(callWithRequest, job))); + validationMessages.push(...(await validateInfluencers(job))); validationMessages.push( - ...(await validateModelMemoryLimit(callWithRequest, job, duration)) + ...(await validateModelMemoryLimit(mlClusterClient, job, duration)) ); } } else { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js index 7dc2ad7ff3b8f..11f8d8967c4e0 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.js @@ -45,13 +45,7 @@ const pickBucketSpan = (bucketSpans) => { return bucketSpans[i]; }; -export async function validateBucketSpan( - callWithRequest, - job, - duration, - callAsInternalUser, - isSecurityDisabled -) { +export async function validateBucketSpan(mlClusterClient, job, duration) { validateJobObject(job); // if there is no duration, do not run the estimate test @@ -123,11 +117,7 @@ export async function validateBucketSpan( try { const estimations = estimatorConfigs.map((data) => { return new Promise((resolve) => { - estimateBucketSpanFactory( - callWithRequest, - callAsInternalUser, - isSecurityDisabled - )(data) + estimateBucketSpanFactory(mlClusterClient)(data) .then(resolve) // this catch gets triggered when the estimation code runs without error // but isn't able to come up with a bucket span estimation. diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts index 8d77fd5a1fd0e..f9145ab576d71 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_bucket_span.test.ts @@ -20,32 +20,36 @@ import mockFareQuoteSearchResponse from './__mocks__/mock_farequote_search_respo // sparse data with a low number of buckets import mockItSearchResponse from './__mocks__/mock_it_search_response.json'; -// mock callWithRequestFactory -const callWithRequestFactory = (mockSearchResponse: any) => { - return () => { +// mock mlClusterClientFactory +const mlClusterClientFactory = (mockSearchResponse: any) => { + const callAs = () => { return new Promise((resolve) => { resolve(mockSearchResponse); }); }; + return { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, + }; }; describe('ML - validateBucketSpan', () => { it('called without arguments', (done) => { - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse)).then( + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse)).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing datafeed_config', (done) => { - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), {}).then( + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), {}).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config.indices', (done) => { - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), { + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), { datafeed_config: {}, }).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -55,7 +59,7 @@ describe('ML - validateBucketSpan', () => { it('called with non-valid job argument #3, missing data_description', (done) => { const job = { datafeed_config: { indices: [] } }; - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -63,7 +67,7 @@ describe('ML - validateBucketSpan', () => { it('called with non-valid job argument #4, missing data_description.time_field', (done) => { const job = { datafeed_config: { indices: [] }, data_description: {} }; - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -74,7 +78,7 @@ describe('ML - validateBucketSpan', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( + validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -87,7 +91,7 @@ describe('ML - validateBucketSpan', () => { datafeed_config: { indices: [] }, }; - return validateBucketSpan(callWithRequestFactory(mockFareQuoteSearchResponse), job).then( + return validateBucketSpan(mlClusterClientFactory(mockFareQuoteSearchResponse), job).then( (messages: JobValidationMessage[]) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([]); @@ -110,7 +114,7 @@ describe('ML - validateBucketSpan', () => { const duration = { start: 0, end: 1 }; return validateBucketSpan( - callWithRequestFactory(mockFareQuoteSearchResponse), + mlClusterClientFactory(mockFareQuoteSearchResponse), job, duration ).then((messages: JobValidationMessage[]) => { @@ -124,7 +128,7 @@ describe('ML - validateBucketSpan', () => { const duration = { start: 0, end: 1 }; return validateBucketSpan( - callWithRequestFactory(mockFareQuoteSearchResponse), + mlClusterClientFactory(mockFareQuoteSearchResponse), job, duration ).then((messages: JobValidationMessage[]) => { @@ -147,7 +151,7 @@ describe('ML - validateBucketSpan', () => { function: 'count', }); - return validateBucketSpan(callWithRequestFactory(mockSearchResponse), job, {}).then( + return validateBucketSpan(mlClusterClientFactory(mockSearchResponse), job, {}).then( (messages: JobValidationMessage[]) => { const ids = messages.map((m) => m.id); test(ids); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts index bcfe4a48a0de0..92933877e2836 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.test.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; @@ -20,9 +20,12 @@ const mockResponses = { fieldCaps: mockFieldCaps, }; -// mock callWithRequestFactory -const callWithRequestFactory = (responses: Record, fail = false): LegacyAPICaller => { - return (requestName: string) => { +// mock mlClusterClientFactory +const mlClusterClientFactory = ( + responses: Record, + fail = false +): ILegacyScopedClusterClient => { + const callAs = (requestName: string) => { return new Promise((resolve, reject) => { const response = responses[requestName]; if (fail) { @@ -32,25 +35,29 @@ const callWithRequestFactory = (responses: Record, fail = false): L } }) as Promise; }; + return { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, + }; }; describe('ML - validateCardinality', () => { it('called without arguments', (done) => { - validateCardinality(callWithRequestFactory(mockResponses)).then( + validateCardinality(mlClusterClientFactory(mockResponses)).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing analysis_config', (done) => { - validateCardinality(callWithRequestFactory(mockResponses), {} as CombinedJob).then( + validateCardinality(mlClusterClientFactory(mockResponses), {} as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); }); it('called with non-valid job argument #2, missing datafeed_config', (done) => { - validateCardinality(callWithRequestFactory(mockResponses), { + validateCardinality(mlClusterClientFactory(mockResponses), { analysis_config: {}, } as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -60,7 +67,7 @@ describe('ML - validateCardinality', () => { it('called with non-valid job argument #3, missing datafeed_config.indices', (done) => { const job = { analysis_config: {}, datafeed_config: {} } as CombinedJob; - validateCardinality(callWithRequestFactory(mockResponses), job).then( + validateCardinality(mlClusterClientFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -71,7 +78,7 @@ describe('ML - validateCardinality', () => { analysis_config: {}, datafeed_config: { indices: [] }, } as unknown) as CombinedJob; - validateCardinality(callWithRequestFactory(mockResponses), job).then( + validateCardinality(mlClusterClientFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -83,7 +90,7 @@ describe('ML - validateCardinality', () => { data_description: {}, datafeed_config: { indices: [] }, } as unknown) as CombinedJob; - validateCardinality(callWithRequestFactory(mockResponses), job).then( + validateCardinality(mlClusterClientFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -95,7 +102,7 @@ describe('ML - validateCardinality', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, } as unknown) as CombinedJob; - validateCardinality(callWithRequestFactory(mockResponses), job).then( + validateCardinality(mlClusterClientFactory(mockResponses), job).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -110,7 +117,7 @@ describe('ML - validateCardinality', () => { }, } as unknown) as CombinedJob; - return validateCardinality(callWithRequestFactory(mockResponses), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockResponses), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([]); }); @@ -141,7 +148,7 @@ describe('ML - validateCardinality', () => { const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; return validateCardinality( - callWithRequestFactory(mockCardinality), + mlClusterClientFactory(mockCardinality), (job as unknown) as CombinedJob ).then((messages) => { const ids = messages.map((m) => m.id); @@ -153,7 +160,7 @@ describe('ML - validateCardinality', () => { const job = getJobConfig('partition_field_name'); job.analysis_config.detectors[0].partition_field_name = '_source'; return validateCardinality( - callWithRequestFactory(mockResponses), + mlClusterClientFactory(mockResponses), (job as unknown) as CombinedJob ).then((messages) => { const ids = messages.map((m) => m.id); @@ -164,7 +171,7 @@ describe('ML - validateCardinality', () => { it(`field 'airline' aggregatable`, () => { const job = getJobConfig('partition_field_name'); return validateCardinality( - callWithRequestFactory(mockResponses), + mlClusterClientFactory(mockResponses), (job as unknown) as CombinedJob ).then((messages) => { const ids = messages.map((m) => m.id); @@ -174,7 +181,7 @@ describe('ML - validateCardinality', () => { it('field not aggregatable', () => { const job = getJobConfig('partition_field_name'); - return validateCardinality(callWithRequestFactory({}), (job as unknown) as CombinedJob).then( + return validateCardinality(mlClusterClientFactory({}), (job as unknown) as CombinedJob).then( (messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['field_not_aggregatable']); @@ -189,7 +196,7 @@ describe('ML - validateCardinality', () => { partition_field_name: 'airline', }); return validateCardinality( - callWithRequestFactory({}, true), + mlClusterClientFactory({}, true), (job as unknown) as CombinedJob ).then((messages) => { const ids = messages.map((m) => m.id); @@ -245,7 +252,7 @@ describe('ML - validateCardinality', () => { job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockCardinality), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['success_cardinality']); }); @@ -256,7 +263,7 @@ describe('ML - validateCardinality', () => { job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockCardinality), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['cardinality_model_plot_high']); }); @@ -267,7 +274,7 @@ describe('ML - validateCardinality', () => { job.model_plot_config = { enabled: false }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockCardinality), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['cardinality_by_field']); }); @@ -278,7 +285,7 @@ describe('ML - validateCardinality', () => { job.model_plot_config = { enabled: true }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockCardinality), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['cardinality_model_plot_high', 'cardinality_by_field']); }); @@ -289,7 +296,7 @@ describe('ML - validateCardinality', () => { job.model_plot_config = { enabled: true, terms: 'AAL,AAB' }; const mockCardinality = _.cloneDeep(mockResponses); mockCardinality.search.aggregations.airline_cardinality.value = cardinality; - return validateCardinality(callWithRequestFactory(mockCardinality), job).then((messages) => { + return validateCardinality(mlClusterClientFactory(mockCardinality), job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['cardinality_by_field']); }); diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts index d5bc6aa20e32a..1545c4c0062ec 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_cardinality.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { DataVisualizer } from '../data_visualizer'; import { validateJobObject } from './validate_job_object'; @@ -43,8 +43,12 @@ type Validator = (obj: { messages: Messages; }>; -const validateFactory = (callWithRequest: LegacyAPICaller, job: CombinedJob): Validator => { - const dv = new DataVisualizer(callWithRequest); +const validateFactory = ( + mlClusterClient: ILegacyScopedClusterClient, + job: CombinedJob +): Validator => { + const { callAsCurrentUser } = mlClusterClient; + const dv = new DataVisualizer(mlClusterClient); const modelPlotConfigTerms = job?.model_plot_config?.terms ?? ''; const modelPlotConfigFieldCount = @@ -73,7 +77,7 @@ const validateFactory = (callWithRequest: LegacyAPICaller, job: CombinedJob): Va ] as string[]; // use fieldCaps endpoint to get data about whether fields are aggregatable - const fieldCaps = await callWithRequest('fieldCaps', { + const fieldCaps = await callAsCurrentUser('fieldCaps', { index: job.datafeed_config.indices.join(','), fields: uniqueFieldNames, }); @@ -150,7 +154,7 @@ const validateFactory = (callWithRequest: LegacyAPICaller, job: CombinedJob): Va }; export async function validateCardinality( - callWithRequest: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, job?: CombinedJob ): Promise | never { const messages: Messages = []; @@ -170,7 +174,7 @@ export async function validateCardinality( } // validate({ type, isInvalid }) asynchronously returns an array of validation messages - const validate = validateFactory(callWithRequest, job); + const validate = validateFactory(mlClusterClient, job); const modelPlotEnabled = job.model_plot_config?.enabled ?? false; diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts index 594b51a773ada..39f5b86c44b7f 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.test.ts @@ -4,28 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; - import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { validateInfluencers } from './validate_influencers'; describe('ML - validateInfluencers', () => { it('called without arguments throws an error', (done) => { - validateInfluencers( - (undefined as unknown) as LegacyAPICaller, - (undefined as unknown) as CombinedJob - ).then( + validateInfluencers((undefined as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without job argument.')), () => done() ); }); it('called with non-valid job argument #1, missing analysis_config', (done) => { - validateInfluencers( - (undefined as unknown) as LegacyAPICaller, - ({} as unknown) as CombinedJob - ).then( + validateInfluencers(({} as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -37,10 +29,7 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers( - (undefined as unknown) as LegacyAPICaller, - (job as unknown) as CombinedJob - ).then( + validateInfluencers((job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -52,10 +41,7 @@ describe('ML - validateInfluencers', () => { datafeed_config: { indices: [] }, data_description: { time_field: '@timestamp' }, }; - validateInfluencers( - (undefined as unknown) as LegacyAPICaller, - (job as unknown) as CombinedJob - ).then( + validateInfluencers((job as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), () => done() ); @@ -75,7 +61,7 @@ describe('ML - validateInfluencers', () => { it('success_influencer', () => { const job = getJobConfig(['airline']); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['success_influencers']); }); @@ -93,7 +79,7 @@ describe('ML - validateInfluencers', () => { ] ); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual([]); }); @@ -101,7 +87,7 @@ describe('ML - validateInfluencers', () => { it('influencer_low', () => { const job = getJobConfig(); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['influencer_low']); }); @@ -109,7 +95,7 @@ describe('ML - validateInfluencers', () => { it('influencer_high', () => { const job = getJobConfig(['i1', 'i2', 'i3', 'i4']); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['influencer_high']); }); @@ -127,7 +113,7 @@ describe('ML - validateInfluencers', () => { }, ] ); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toStrictEqual(['influencer_low_suggestion']); }); @@ -157,7 +143,7 @@ describe('ML - validateInfluencers', () => { }, ] ); - return validateInfluencers((undefined as unknown) as LegacyAPICaller, job).then((messages) => { + return validateInfluencers(job).then((messages) => { expect(messages).toStrictEqual([ { id: 'influencer_low_suggestions', diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts index 1a77bfaf60811..72995619f6eca 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_influencers.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; - import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { validateJobObject } from './validate_job_object'; @@ -14,7 +12,7 @@ const INFLUENCER_LOW_THRESHOLD = 0; const INFLUENCER_HIGH_THRESHOLD = 4; const DETECTOR_FIELD_NAMES_THRESHOLD = 1; -export async function validateInfluencers(callWithRequest: LegacyAPICaller, job: CombinedJob) { +export async function validateInfluencers(job: CombinedJob) { validateJobObject(job); const messages = []; diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts index d9be8e282e923..61af960847f7f 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CombinedJob, Detector } from '../../../common/types/anomaly_detection_jobs'; import { ModelMemoryEstimate } from '../calculate_model_memory_limit/calculate_model_memory_limit'; import { validateModelMemoryLimit } from './validate_model_memory_limit'; @@ -73,15 +73,15 @@ describe('ML - validateModelMemoryLimit', () => { 'ml.estimateModelMemory'?: ModelMemoryEstimate; } - // mock callWithRequest + // mock callAsCurrentUser // used in three places: // - to retrieve the info endpoint // - to search for cardinality of split field // - to retrieve field capabilities used in search for split field cardinality - const getMockCallWithRequest = ({ + const getMockMlClusterClient = ({ 'ml.estimateModelMemory': estimateModelMemory, - }: MockAPICallResponse = {}) => - ((call: string) => { + }: MockAPICallResponse = {}): ILegacyScopedClusterClient => { + const callAs = (call: string) => { if (typeof call === undefined) { return Promise.reject(); } @@ -97,7 +97,13 @@ describe('ML - validateModelMemoryLimit', () => { response = estimateModelMemory || modelMemoryEstimateResponse; } return Promise.resolve(response); - }) as LegacyAPICaller; + }; + + return { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, + }; + }; function getJobConfig(influencers: string[] = [], detectors: Detector[] = []) { return ({ @@ -129,7 +135,7 @@ describe('ML - validateModelMemoryLimit', () => { const job = getJobConfig(); const duration = undefined; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual([]); }); @@ -138,10 +144,10 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit', () => { const job = getJobConfig(); const duration = undefined; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '31mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_greater_than_max_mml']); }); @@ -151,11 +157,11 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(10); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '20mb'; return validateModelMemoryLimit( - getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '66mb' } }), + getMockMlClusterClient({ 'ml.estimateModelMemory': { model_memory_estimate: '66mb' } }), job, duration ).then((messages) => { @@ -168,11 +174,11 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '30mb'; return validateModelMemoryLimit( - getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '24mb' } }), + getMockMlClusterClient({ 'ml.estimateModelMemory': { model_memory_estimate: '24mb' } }), job, duration ).then((messages) => { @@ -185,11 +191,11 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(2); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '10mb'; return validateModelMemoryLimit( - getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '22mb' } }), + getMockMlClusterClient({ 'ml.estimateModelMemory': { model_memory_estimate: '22mb' } }), job, duration ).then((messages) => { @@ -203,10 +209,10 @@ describe('ML - validateModelMemoryLimit', () => { const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; delete mlInfoResponse.limits.max_model_memory_limit; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '10mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); @@ -215,10 +221,10 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit, no max setting', () => { const job = getJobConfig(); const duration = undefined; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '31mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual([]); }); @@ -227,10 +233,10 @@ describe('ML - validateModelMemoryLimit', () => { it('Called with no duration or split and mml above limit, no max setting, above effective max mml', () => { const job = getJobConfig(); const duration = undefined; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '41mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_greater_than_effective_max_mml']); }); @@ -240,11 +246,11 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '20mb'; return validateModelMemoryLimit( - getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '19mb' } }), + getMockMlClusterClient({ 'ml.estimateModelMemory': { model_memory_estimate: '19mb' } }), job, duration ).then((messages) => { @@ -257,10 +263,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '0mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -270,10 +276,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '10mbananas'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -283,10 +289,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '10'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -296,10 +302,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = 'mb'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -309,10 +315,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = 'asdf'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -322,10 +328,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '1023KB'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['mml_value_invalid']); }); @@ -335,10 +341,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '1024KB'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); @@ -348,10 +354,10 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '6MB'; - return validateModelMemoryLimit(getMockCallWithRequest(), job, duration).then((messages) => { + return validateModelMemoryLimit(getMockMlClusterClient(), job, duration).then((messages) => { const ids = messages.map((m) => m.id); expect(ids).toEqual(['half_estimated_mml_greater_than_mml']); }); @@ -361,11 +367,11 @@ describe('ML - validateModelMemoryLimit', () => { const dtrs = createDetectors(1); const job = getJobConfig(['instance'], dtrs); const duration = { start: 0, end: 1 }; - // @ts-ignore + // @ts-expect-error job.analysis_limits.model_memory_limit = '20MB'; return validateModelMemoryLimit( - getMockCallWithRequest({ 'ml.estimateModelMemory': { model_memory_estimate: '20mb' } }), + getMockMlClusterClient({ 'ml.estimateModelMemory': { model_memory_estimate: '20mb' } }), job, duration ).then((messages) => { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts index 2c7d1cc23bbaa..728342294c424 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_model_memory_limit.ts @@ -5,7 +5,7 @@ */ import numeral from '@elastic/numeral'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { validateJobObject } from './validate_job_object'; import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; @@ -16,10 +16,11 @@ import { MlInfoResponse } from '../../../common/types/ml_server_info'; const MODEL_MEMORY_LIMIT_MINIMUM_BYTES = 1048576; export async function validateModelMemoryLimit( - callWithRequest: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, job: CombinedJob, duration?: { start?: number; end?: number } ) { + const { callAsInternalUser } = mlClusterClient; validateJobObject(job); // retrieve the model memory limit specified by the user in the job config. @@ -51,12 +52,12 @@ export async function validateModelMemoryLimit( // retrieve the max_model_memory_limit value from the server // this will be unset unless the user has set this on their cluster - const info = await callWithRequest('ml.info'); + const info = (await callAsInternalUser('ml.info')) as MlInfoResponse; const maxModelMemoryLimit = info.limits.max_model_memory_limit?.toUpperCase(); const effectiveMaxModelMemoryLimit = info.limits.effective_max_model_memory_limit?.toUpperCase(); if (runCalcModelMemoryTest) { - const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(callWithRequest)( + const { modelMemoryLimit } = await calculateModelMemoryLimitProvider(mlClusterClient)( job.analysis_config, job.datafeed_config.indices.join(','), job.datafeed_config.query, @@ -65,14 +66,14 @@ export async function validateModelMemoryLimit( duration!.end as number, true ); - // @ts-ignore + // @ts-expect-error const mmlEstimateBytes: number = numeral(modelMemoryLimit).value(); let runEstimateGreaterThenMml = true; // if max_model_memory_limit has been set, // make sure the estimated value is not greater than it. if (typeof maxModelMemoryLimit !== 'undefined') { - // @ts-ignore + // @ts-expect-error const maxMmlBytes: number = numeral(maxModelMemoryLimit).value(); if (mmlEstimateBytes > maxMmlBytes) { runEstimateGreaterThenMml = false; @@ -89,7 +90,7 @@ export async function validateModelMemoryLimit( // do not run this if we've already found that it's larger than // the max mml if (runEstimateGreaterThenMml && mml !== null) { - // @ts-ignore + // @ts-expect-error const mmlBytes: number = numeral(mml).value(); if (mmlBytes < MODEL_MEMORY_LIMIT_MINIMUM_BYTES) { messages.push({ @@ -116,11 +117,11 @@ export async function validateModelMemoryLimit( // make sure the user defined MML is not greater than it if (mml !== null) { let maxMmlExceeded = false; - // @ts-ignore + // @ts-expect-error const mmlBytes = numeral(mml).value(); if (maxModelMemoryLimit !== undefined) { - // @ts-ignore + // @ts-expect-error const maxMmlBytes = numeral(maxModelMemoryLimit).value(); if (mmlBytes > maxMmlBytes) { maxMmlExceeded = true; @@ -133,7 +134,7 @@ export async function validateModelMemoryLimit( } if (effectiveMaxModelMemoryLimit !== undefined && maxMmlExceeded === false) { - // @ts-ignore + // @ts-expect-error const effectiveMaxMmlBytes = numeral(effectiveMaxModelMemoryLimit).value(); if (mmlBytes > effectiveMaxMmlBytes) { messages.push({ diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts index d4e1f0cc379fb..f74d8a26ef370 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.test.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; @@ -21,12 +21,16 @@ const mockSearchResponse = { search: mockTimeRange, }; -const callWithRequestFactory = (resp: any): LegacyAPICaller => { - return (path: string) => { +const mlClusterClientFactory = (resp: any): ILegacyScopedClusterClient => { + const callAs = (path: string) => { return new Promise((resolve) => { resolve(resp[path]); }) as Promise; }; + return { + callAsCurrentUser: callAs, + callAsInternalUser: callAs, + }; }; function getMinimalValidJob() { @@ -46,7 +50,7 @@ function getMinimalValidJob() { describe('ML - isValidTimeField', () => { it('called without job config argument triggers Promise rejection', (done) => { isValidTimeField( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), (undefined as unknown) as CombinedJob ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), @@ -55,7 +59,7 @@ describe('ML - isValidTimeField', () => { }); it('time_field `@timestamp`', (done) => { - isValidTimeField(callWithRequestFactory(mockSearchResponse), getMinimalValidJob()).then( + isValidTimeField(mlClusterClientFactory(mockSearchResponse), getMinimalValidJob()).then( (valid) => { expect(valid).toBe(true); done(); @@ -74,7 +78,7 @@ describe('ML - isValidTimeField', () => { }; isValidTimeField( - callWithRequestFactory(mockSearchResponseNestedDate), + mlClusterClientFactory(mockSearchResponseNestedDate), mockJobConfigNestedDate ).then( (valid) => { @@ -89,7 +93,7 @@ describe('ML - isValidTimeField', () => { describe('ML - validateTimeRange', () => { it('called without arguments', (done) => { validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), (undefined as unknown) as CombinedJob ).then( () => done(new Error('Promise should not resolve for this test without job argument.')), @@ -98,7 +102,7 @@ describe('ML - validateTimeRange', () => { }); it('called with non-valid job argument #2, missing datafeed_config', (done) => { - validateTimeRange(callWithRequestFactory(mockSearchResponse), ({ + validateTimeRange(mlClusterClientFactory(mockSearchResponse), ({ analysis_config: {}, } as unknown) as CombinedJob).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -109,7 +113,7 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #3, missing datafeed_config.indices', (done) => { const job = { analysis_config: {}, datafeed_config: {} }; validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), (job as unknown) as CombinedJob ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -120,7 +124,7 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #4, missing data_description', (done) => { const job = { analysis_config: {}, datafeed_config: { indices: [] } }; validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), (job as unknown) as CombinedJob ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -131,7 +135,7 @@ describe('ML - validateTimeRange', () => { it('called with non-valid job argument #5, missing data_description.time_field', (done) => { const job = { analysis_config: {}, data_description: {}, datafeed_config: { indices: [] } }; validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), (job as unknown) as CombinedJob ).then( () => done(new Error('Promise should not resolve for this test without valid job argument.')), @@ -144,7 +148,7 @@ describe('ML - validateTimeRange', () => { mockSearchResponseInvalid.fieldCaps = undefined; const duration = { start: 0, end: 1 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponseInvalid), + mlClusterClientFactory(mockSearchResponseInvalid), getMinimalValidJob(), duration ).then((messages) => { @@ -158,7 +162,7 @@ describe('ML - validateTimeRange', () => { jobShortTimeRange.analysis_config.bucket_span = '1s'; const duration = { start: 0, end: 1 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), jobShortTimeRange, duration ).then((messages) => { @@ -170,7 +174,7 @@ describe('ML - validateTimeRange', () => { it('too short time range, 25x bucket span is more than 2h', () => { const duration = { start: 0, end: 1 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), getMinimalValidJob(), duration ).then((messages) => { @@ -182,7 +186,7 @@ describe('ML - validateTimeRange', () => { it('time range between 2h and 25x bucket span', () => { const duration = { start: 0, end: 8000000 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), getMinimalValidJob(), duration ).then((messages) => { @@ -194,7 +198,7 @@ describe('ML - validateTimeRange', () => { it('valid time range', () => { const duration = { start: 0, end: 100000000 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), getMinimalValidJob(), duration ).then((messages) => { @@ -206,7 +210,7 @@ describe('ML - validateTimeRange', () => { it('invalid time range, start time is before the UNIX epoch', () => { const duration = { start: -1, end: 100000000 }; return validateTimeRange( - callWithRequestFactory(mockSearchResponse), + mlClusterClientFactory(mockSearchResponse), getMinimalValidJob(), duration ).then((messages) => { diff --git a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts index f47938e059ec0..a94ceffa90273 100644 --- a/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts +++ b/x-pack/plugins/ml/server/models/job_validation/validate_time_range.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/server'; import { parseInterval } from '../../../common/util/parse_interval'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -// @ts-ignore import { validateJobObject } from './validate_job_object'; interface ValidateTimeRangeMessage { @@ -27,7 +26,10 @@ const BUCKET_SPAN_COMPARE_FACTOR = 25; const MIN_TIME_SPAN_MS = 7200000; const MIN_TIME_SPAN_READABLE = '2 hours'; -export async function isValidTimeField(callAsCurrentUser: LegacyAPICaller, job: CombinedJob) { +export async function isValidTimeField( + { callAsCurrentUser }: ILegacyScopedClusterClient, + job: CombinedJob +) { const index = job.datafeed_config.indices.join(','); const timeField = job.data_description.time_field; @@ -45,7 +47,7 @@ export async function isValidTimeField(callAsCurrentUser: LegacyAPICaller, job: } export async function validateTimeRange( - callAsCurrentUser: LegacyAPICaller, + mlClientCluster: ILegacyScopedClusterClient, job: CombinedJob, timeRange?: Partial ) { @@ -54,7 +56,7 @@ export async function validateTimeRange( validateJobObject(job); // check if time_field is a date type - if (!(await isValidTimeField(callAsCurrentUser, job))) { + if (!(await isValidTimeField(mlClientCluster, job))) { messages.push({ id: 'time_field_invalid', timeField: job.data_description.time_field, diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index 99eeaacc8de9c..d7403c45f1be2 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -5,8 +5,8 @@ */ import Boom from 'boom'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; -import { callWithRequestType } from '../../../common/types/kibana'; import { CriteriaField } from './results_service'; const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; @@ -76,7 +76,10 @@ function getFieldObject(fieldType: PartitionFieldsType, aggs: any) { : {}; } -export const getPartitionFieldsValuesFactory = (callWithRequest: callWithRequestType) => +export const getPartitionFieldsValuesFactory = ({ + callAsCurrentUser, + callAsInternalUser, +}: ILegacyScopedClusterClient) => /** * Gets the record of partition fields with possible values that fit the provided queries. * @param jobId - Job ID @@ -92,7 +95,7 @@ export const getPartitionFieldsValuesFactory = (callWithRequest: callWithRequest earliestMs: number, latestMs: number ) { - const jobsResponse = await callWithRequest('ml.jobs', { jobId: [jobId] }); + const jobsResponse = await callAsInternalUser('ml.jobs', { jobId: [jobId] }); if (jobsResponse.count === 0 || jobsResponse.jobs === undefined) { throw Boom.notFound(`Job with the id "${jobId}" not found`); } @@ -101,7 +104,7 @@ export const getPartitionFieldsValuesFactory = (callWithRequest: callWithRequest const isModelPlotEnabled = job?.model_plot_config?.enabled; - const resp = await callWithRequest('search', { + const resp = await callAsCurrentUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 8255395000f47..8e904143263d7 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import moment from 'moment'; import { SearchResponse } from 'elasticsearch'; -import { LegacyAPICaller } from 'kibana/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; import { buildAnomalyTableItems } from './build_anomaly_table_items'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; @@ -30,7 +30,8 @@ interface Influencer { fieldValue: any; } -export function resultsServiceProvider(callAsCurrentUser: LegacyAPICaller) { +export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { + const { callAsCurrentUser } = mlClusterClient; // Obtains data for the anomalies table, aggregating anomalies by day or hour as requested. // Return an Object with properties 'anomalies' and 'interval' (interval used to aggregate anomalies, // one of day, hour or second. Note 'auto' can be provided as the aggregationInterval in the request, @@ -435,6 +436,6 @@ export function resultsServiceProvider(callAsCurrentUser: LegacyAPICaller) { getCategoryExamples, getLatestBucketTimestampByJob, getMaxAnomalyScore, - getPartitionFieldsValues: getPartitionFieldsValuesFactory(callAsCurrentUser), + getPartitionFieldsValues: getPartitionFieldsValuesFactory(mlClusterClient), }; } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 83b14d60fb416..812db744d1bda 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -75,7 +75,7 @@ export class MlServerPlugin implements Plugin { try { - const { getAnnotations } = annotationServiceProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { getAnnotations } = annotationServiceProvider(context.ml!.mlClient); const resp = await getAnnotations(request.body); return response.ok({ @@ -96,19 +94,17 @@ export function annotationRoutes( mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable( - context.ml!.mlClient.callAsCurrentUser + context.ml!.mlClient ); if (annotationsFeatureAvailable === false) { throw getAnnotationsFeatureUnavailableErrorMessage(); } - const { indexAnnotation } = annotationServiceProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { indexAnnotation } = annotationServiceProvider(context.ml!.mlClient); const currentUser = securityPlugin !== undefined ? securityPlugin.authc.getCurrentUser(request) : {}; - // @ts-ignore username doesn't exist on {} + // @ts-expect-error username doesn't exist on {} const username = currentUser?.username ?? ANNOTATION_USER_UNKNOWN; const resp = await indexAnnotation(request.body, username); @@ -143,16 +139,14 @@ export function annotationRoutes( mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const annotationsFeatureAvailable = await isAnnotationsFeatureAvailable( - context.ml!.mlClient.callAsCurrentUser + context.ml!.mlClient ); if (annotationsFeatureAvailable === false) { throw getAnnotationsFeatureUnavailableErrorMessage(); } const annotationId = request.params.annotationId; - const { deleteAnnotation } = annotationServiceProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { deleteAnnotation } = annotationServiceProvider(context.ml!.mlClient); const resp = await deleteAnnotation(annotationId); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts index 78e05c9a6d07b..8a59c174eb8e7 100644 --- a/x-pack/plugins/ml/server/routes/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/routes/anomaly_detectors.ts @@ -45,7 +45,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs'); + const results = await context.ml!.mlClient.callAsInternalUser('ml.jobs'); return response.ok({ body: results, }); @@ -77,7 +77,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { jobId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobs', { jobId }); + const results = await context.ml!.mlClient.callAsInternalUser('ml.jobs', { jobId }); return response.ok({ body: results, }); @@ -107,7 +107,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats'); + const results = await context.ml!.mlClient.callAsInternalUser('ml.jobStats'); return response.ok({ body: results, }); @@ -139,7 +139,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { jobId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.jobStats', { jobId }); + const results = await context.ml!.mlClient.callAsInternalUser('ml.jobStats', { jobId }); return response.ok({ body: results, }); @@ -175,11 +175,9 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { jobId } = request.params; - const body = request.body; - - const results = await context.ml!.mlClient.callAsCurrentUser('ml.addJob', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.addJob', { jobId, - body, + body: request.body, }); return response.ok({ body: results, @@ -214,7 +212,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { jobId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.updateJob', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.updateJob', { jobId, body: request.body, }); @@ -249,7 +247,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { jobId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.openJob', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.openJob', { jobId, }); return response.ok({ @@ -289,7 +287,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { if (force !== undefined) { options.force = force; } - const results = await context.ml!.mlClient.callAsCurrentUser('ml.closeJob', options); + const results = await context.ml!.mlClient.callAsInternalUser('ml.closeJob', options); return response.ok({ body: results, }); @@ -327,7 +325,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { if (force !== undefined) { options.force = force; } - const results = await context.ml!.mlClient.callAsCurrentUser('ml.deleteJob', options); + const results = await context.ml!.mlClient.callAsInternalUser('ml.deleteJob', options); return response.ok({ body: results, }); @@ -356,7 +354,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.validateDetector', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.validateDetector', { body: request.body, }); return response.ok({ @@ -393,7 +391,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { try { const jobId = request.params.jobId; const duration = request.body.duration; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.forecast', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.forecast', { jobId, duration, }); @@ -432,7 +430,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.records', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.records', { jobId: request.params.jobId, body: request.body, }); @@ -471,7 +469,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.buckets', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.buckets', { jobId: request.params.jobId, timestamp: request.params.timestamp, body: request.body, @@ -511,7 +509,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.overallBuckets', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.overallBuckets', { jobId: request.params.jobId, top_n: request.body.topN, bucket_span: request.body.bucketSpan, @@ -548,7 +546,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.categories', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.categories', { jobId: request.params.jobId, categoryId: request.params.categoryId, }); @@ -582,7 +580,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.modelSnapshots', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.modelSnapshots', { jobId: request.params.jobId, }); return response.ok({ @@ -615,7 +613,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.modelSnapshots', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.modelSnapshots', { jobId: request.params.jobId, snapshotId: request.params.snapshotId, }); @@ -651,7 +649,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.updateModelSnapshot', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.updateModelSnapshot', { jobId: request.params.jobId, snapshotId: request.params.snapshotId, body: request.body, @@ -686,7 +684,7 @@ export function jobRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.deleteModelSnapshot', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.deleteModelSnapshot', { jobId: request.params.jobId, snapshotId: request.params.snapshotId, }); diff --git a/x-pack/plugins/ml/server/routes/calendars.ts b/x-pack/plugins/ml/server/routes/calendars.ts index 9c80651a13999..f5d129abd515e 100644 --- a/x-pack/plugins/ml/server/routes/calendars.ts +++ b/x-pack/plugins/ml/server/routes/calendars.ts @@ -11,32 +11,32 @@ import { calendarSchema, calendarIdSchema, calendarIdsSchema } from './schemas/c import { CalendarManager, Calendar, FormCalendar } from '../models/calendar'; function getAllCalendars(context: RequestHandlerContext) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.getAllCalendars(); } function getCalendar(context: RequestHandlerContext, calendarId: string) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.getCalendar(calendarId); } function newCalendar(context: RequestHandlerContext, calendar: FormCalendar) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.newCalendar(calendar); } function updateCalendar(context: RequestHandlerContext, calendarId: string, calendar: Calendar) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.updateCalendar(calendarId, calendar); } function deleteCalendar(context: RequestHandlerContext, calendarId: string) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.deleteCalendar(calendarId); } function getCalendarsByIds(context: RequestHandlerContext, calendarIds: string) { - const cal = new CalendarManager(context.ml!.mlClient.callAsCurrentUser); + const cal = new CalendarManager(context.ml!.mlClient); return cal.getCalendarsByIds(calendarIds); } diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 24be23332e4cf..3e6c6f5f6a2f8 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -19,6 +19,7 @@ import { } from './schemas/data_analytics_schema'; import { IndexPatternHandler } from '../models/data_frame_analytics/index_patterns'; import { DeleteDataFrameAnalyticsWithIndexStatus } from '../../common/types/data_frame_analytics'; +import { getAuthorizationHeader } from '../lib/request_authorization'; function getIndexPatternId(context: RequestHandlerContext, patternName: string) { const iph = new IndexPatternHandler(context.core.savedObjects.client); @@ -77,7 +78,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics'); + const results = await context.ml!.mlClient.callAsInternalUser('ml.getDataFrameAnalytics'); return response.ok({ body: results, }); @@ -109,7 +110,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics', { + const results = await context.ml!.mlClient.callAsInternalUser('ml.getDataFrameAnalytics', { analyticsId, }); return response.ok({ @@ -138,7 +139,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.getDataFrameAnalyticsStats' ); return response.ok({ @@ -172,7 +173,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.getDataFrameAnalyticsStats', { analyticsId, @@ -212,11 +213,12 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.createDataFrameAnalytics', { body: request.body, analyticsId, + ...getAuthorizationHeader(request), } ); return response.ok({ @@ -249,10 +251,11 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.evaluateDataFrameAnalytics', { body: request.body, + ...getAuthorizationHeader(request), } ); return response.ok({ @@ -286,7 +289,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.explainDataFrameAnalytics', { body: request.body, @@ -335,7 +338,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat // Check if analyticsId is valid and get destination index if (deleteDestIndex || deleteDestIndexPattern) { try { - const dfa = await context.ml!.mlClient.callAsCurrentUser('ml.getDataFrameAnalytics', { + const dfa = await context.ml!.mlClient.callAsInternalUser('ml.getDataFrameAnalytics', { analyticsId, }); if (Array.isArray(dfa.data_frame_analytics) && dfa.data_frame_analytics.length > 0) { @@ -381,7 +384,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat // Delete the data frame analytics try { - await context.ml!.mlClient.callAsCurrentUser('ml.deleteDataFrameAnalytics', { + await context.ml!.mlClient.callAsInternalUser('ml.deleteDataFrameAnalytics', { analyticsId, }); analyticsJobDeleted.success = true; @@ -427,9 +430,12 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser('ml.startDataFrameAnalytics', { - analyticsId, - }); + const results = await context.ml!.mlClient.callAsInternalUser( + 'ml.startDataFrameAnalytics', + { + analyticsId, + } + ); return response.ok({ body: results, }); @@ -465,13 +471,13 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat const options: { analyticsId: string; force?: boolean | undefined } = { analyticsId: request.params.analyticsId, }; - // @ts-ignore TODO: update types + // @ts-expect-error TODO: update types if (request.url?.query?.force !== undefined) { - // @ts-ignore TODO: update types + // @ts-expect-error TODO: update types options.force = request.url.query.force; } - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.stopDataFrameAnalytics', options ); @@ -545,9 +551,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const { getAnalyticsAuditMessages } = analyticsAuditMessagesProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { getAnalyticsAuditMessages } = analyticsAuditMessagesProvider(context.ml!.mlClient); const results = await getAnalyticsAuditMessages(analyticsId); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/data_visualizer.ts b/x-pack/plugins/ml/server/routes/data_visualizer.ts index 04008a896a1a2..818e981835ced 100644 --- a/x-pack/plugins/ml/server/routes/data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/data_visualizer.ts @@ -7,8 +7,9 @@ import { RequestHandlerContext } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { DataVisualizer } from '../models/data_visualizer'; -import { Field } from '../models/data_visualizer/data_visualizer'; +import { Field, HistogramField } from '../models/data_visualizer/data_visualizer'; import { + dataVisualizerFieldHistogramsSchema, dataVisualizerFieldStatsSchema, dataVisualizerOverallStatsSchema, indexPatternTitleSchema, @@ -26,7 +27,7 @@ function getOverallStats( earliestMs: number, latestMs: number ) { - const dv = new DataVisualizer(context.ml!.mlClient.callAsCurrentUser); + const dv = new DataVisualizer(context.ml!.mlClient); return dv.getOverallStats( indexPatternTitle, query, @@ -51,7 +52,7 @@ function getStatsForFields( interval: number, maxExamples: number ) { - const dv = new DataVisualizer(context.ml!.mlClient.callAsCurrentUser); + const dv = new DataVisualizer(context.ml!.mlClient); return dv.getStatsForFields( indexPatternTitle, query, @@ -65,10 +66,68 @@ function getStatsForFields( ); } +function getHistogramsForFields( + context: RequestHandlerContext, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) { + const dv = new DataVisualizer(context.ml!.mlClient); + return dv.getHistogramsForFields(indexPatternTitle, query, fields, samplerShardSize); +} + /** * Routes for the index data visualizer. */ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) { + /** + * @apiGroup DataVisualizer + * + * @api {post} /api/ml/data_visualizer/get_field_stats/:indexPatternTitle Get histograms for fields + * @apiName GetHistogramsForFields + * @apiDescription Returns the histograms on a list fields in the specified index pattern. + * + * @apiSchema (params) indexPatternTitleSchema + * @apiSchema (body) dataVisualizerFieldHistogramsSchema + * + * @apiSuccess {Object} fieldName histograms by field, keyed on the name of the field. + */ + router.post( + { + path: '/api/ml/data_visualizer/get_field_histograms/{indexPatternTitle}', + validate: { + params: indexPatternTitleSchema, + body: dataVisualizerFieldHistogramsSchema, + }, + options: { + tags: ['access:ml:canAccessML'], + }, + }, + mlLicense.basicLicenseAPIGuard(async (context, request, response) => { + try { + const { + params: { indexPatternTitle }, + body: { query, fields, samplerShardSize }, + } = request; + + const results = await getHistogramsForFields( + context, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + return response.ok({ + body: results, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup DataVisualizer * diff --git a/x-pack/plugins/ml/server/routes/datafeeds.ts b/x-pack/plugins/ml/server/routes/datafeeds.ts index 1fa1d408372da..855b64b0ffed0 100644 --- a/x-pack/plugins/ml/server/routes/datafeeds.ts +++ b/x-pack/plugins/ml/server/routes/datafeeds.ts @@ -12,6 +12,7 @@ import { datafeedIdSchema, deleteDatafeedQuerySchema, } from './schemas/datafeeds_schema'; +import { getAuthorizationHeader } from '../lib/request_authorization'; /** * Routes for datafeed service @@ -34,7 +35,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.datafeeds'); + const resp = await context.ml!.mlClient.callAsInternalUser('ml.datafeeds'); return response.ok({ body: resp, @@ -67,7 +68,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.datafeeds', { datafeedId }); + const resp = await context.ml!.mlClient.callAsInternalUser('ml.datafeeds', { datafeedId }); return response.ok({ body: resp, @@ -95,7 +96,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.datafeedStats'); + const resp = await context.ml!.mlClient.callAsInternalUser('ml.datafeedStats'); return response.ok({ body: resp, @@ -128,7 +129,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.datafeedStats', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.datafeedStats', { datafeedId, }); @@ -165,9 +166,10 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.addDatafeed', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.addDatafeed', { datafeedId, body: request.body, + ...getAuthorizationHeader(request), }); return response.ok({ @@ -203,9 +205,10 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.updateDatafeed', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.updateDatafeed', { datafeedId, body: request.body, + ...getAuthorizationHeader(request), }); return response.ok({ @@ -248,7 +251,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { options.force = force; } - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.deleteDatafeed', options); + const resp = await context.ml!.mlClient.callAsInternalUser('ml.deleteDatafeed', options); return response.ok({ body: resp, @@ -285,7 +288,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { const datafeedId = request.params.datafeedId; const { start, end } = request.body; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.startDatafeed', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.startDatafeed', { datafeedId, start, end, @@ -323,7 +326,7 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.stopDatafeed', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.stopDatafeed', { datafeedId, }); @@ -358,8 +361,9 @@ export function dataFeedRoutes({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const datafeedId = request.params.datafeedId; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.datafeedPreview', { + const resp = await context.ml!.mlClient.callAsInternalUser('ml.datafeedPreview', { datafeedId, + ...getAuthorizationHeader(request), }); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/fields_service.ts b/x-pack/plugins/ml/server/routes/fields_service.ts index b0f13df294145..b83f846b1685d 100644 --- a/x-pack/plugins/ml/server/routes/fields_service.ts +++ b/x-pack/plugins/ml/server/routes/fields_service.ts @@ -14,13 +14,13 @@ import { import { fieldsServiceProvider } from '../models/fields_service'; function getCardinalityOfFields(context: RequestHandlerContext, payload: any) { - const fs = fieldsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const fs = fieldsServiceProvider(context.ml!.mlClient); const { index, fieldNames, query, timeFieldName, earliestMs, latestMs } = payload; return fs.getCardinalityOfFields(index, fieldNames, query, timeFieldName, earliestMs, latestMs); } function getTimeFieldRange(context: RequestHandlerContext, payload: any) { - const fs = fieldsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const fs = fieldsServiceProvider(context.ml!.mlClient); const { index, timeFieldName, query } = payload; return fs.getTimeFieldRange(index, timeFieldName, query); } diff --git a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts index 0f389f9505943..b57eda5ad56a1 100644 --- a/x-pack/plugins/ml/server/routes/file_data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/file_data_visualizer.ts @@ -29,7 +29,7 @@ import { } from './schemas/file_data_visualizer_schema'; function analyzeFiles(context: RequestHandlerContext, data: InputData, overrides: InputOverrides) { - const { analyzeFile } = fileDataVisualizerProvider(context.ml!.mlClient.callAsCurrentUser); + const { analyzeFile } = fileDataVisualizerProvider(context.ml!.mlClient); return analyzeFile(data, overrides); } @@ -42,7 +42,7 @@ function importData( ingestPipeline: IngestPipelineWrapper, data: InputData ) { - const { importData: importDataFunc } = importDataProvider(context.ml!.mlClient.callAsCurrentUser); + const { importData: importDataFunc } = importDataProvider(context.ml!.mlClient); return importDataFunc(id, index, settings, mappings, ingestPipeline, data); } diff --git a/x-pack/plugins/ml/server/routes/filters.ts b/x-pack/plugins/ml/server/routes/filters.ts index d5287c349a8fc..dcdb4caa6cd3b 100644 --- a/x-pack/plugins/ml/server/routes/filters.ts +++ b/x-pack/plugins/ml/server/routes/filters.ts @@ -13,32 +13,32 @@ import { FilterManager, FormFilter } from '../models/filter'; // TODO - add function for returning a list of just the filter IDs. // TODO - add function for returning a list of filter IDs plus item count. function getAllFilters(context: RequestHandlerContext) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.getAllFilters(); } function getAllFilterStats(context: RequestHandlerContext) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.getAllFilterStats(); } function getFilter(context: RequestHandlerContext, filterId: string) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.getFilter(filterId); } function newFilter(context: RequestHandlerContext, filter: FormFilter) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.newFilter(filter); } function updateFilter(context: RequestHandlerContext, filterId: string, filter: FormFilter) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.updateFilter(filterId, filter); } function deleteFilter(context: RequestHandlerContext, filterId: string) { - const mgr = new FilterManager(context.ml!.mlClient.callAsCurrentUser); + const mgr = new FilterManager(context.ml!.mlClient); return mgr.deleteFilter(filterId); } diff --git a/x-pack/plugins/ml/server/routes/job_audit_messages.ts b/x-pack/plugins/ml/server/routes/job_audit_messages.ts index 5acc89e7d13be..d4840ed650a32 100644 --- a/x-pack/plugins/ml/server/routes/job_audit_messages.ts +++ b/x-pack/plugins/ml/server/routes/job_audit_messages.ts @@ -39,9 +39,7 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { getJobAuditMessages } = jobAuditMessagesProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { getJobAuditMessages } = jobAuditMessagesProvider(context.ml!.mlClient); const { jobId } = request.params; const { from } = request.query; const resp = await getJobAuditMessages(jobId, from); @@ -76,9 +74,7 @@ export function jobAuditMessagesRoutes({ router, mlLicense }: RouteInitializatio }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { getJobAuditMessages } = jobAuditMessagesProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { getJobAuditMessages } = jobAuditMessagesProvider(context.ml!.mlClient); const { from } = request.query; const resp = await getJobAuditMessages(undefined, from); diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 10d1c9952b540..e03dbb40d623a 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -50,7 +50,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { forceStartDatafeeds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { forceStartDatafeeds } = jobServiceProvider(context.ml!.mlClient); const { datafeedIds, start, end } = request.body; const resp = await forceStartDatafeeds(datafeedIds, start, end); @@ -84,7 +84,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { stopDatafeeds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { stopDatafeeds } = jobServiceProvider(context.ml!.mlClient); const { datafeedIds } = request.body; const resp = await stopDatafeeds(datafeedIds); @@ -118,7 +118,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { deleteJobs } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { deleteJobs } = jobServiceProvider(context.ml!.mlClient); const { jobIds } = request.body; const resp = await deleteJobs(jobIds); @@ -152,7 +152,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { closeJobs } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { closeJobs } = jobServiceProvider(context.ml!.mlClient); const { jobIds } = request.body; const resp = await closeJobs(jobIds); @@ -186,7 +186,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { forceStopAndCloseJob } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { forceStopAndCloseJob } = jobServiceProvider(context.ml!.mlClient); const { jobId } = request.body; const resp = await forceStopAndCloseJob(jobId); @@ -225,7 +225,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { jobsSummary } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobsSummary } = jobServiceProvider(context.ml!.mlClient); const { jobIds } = request.body; const resp = await jobsSummary(jobIds); @@ -259,7 +259,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { jobsWithTimerange } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobsWithTimerange } = jobServiceProvider(context.ml!.mlClient); const resp = await jobsWithTimerange(); return response.ok({ @@ -292,7 +292,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { createFullJobsList } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { createFullJobsList } = jobServiceProvider(context.ml!.mlClient); const { jobIds } = request.body; const resp = await createFullJobsList(jobIds); @@ -322,7 +322,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { getAllGroups } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { getAllGroups } = jobServiceProvider(context.ml!.mlClient); const resp = await getAllGroups(); return response.ok({ @@ -355,7 +355,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { updateGroups } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { updateGroups } = jobServiceProvider(context.ml!.mlClient); const { jobs } = request.body; const resp = await updateGroups(jobs); @@ -385,7 +385,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { deletingJobTasks } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { deletingJobTasks } = jobServiceProvider(context.ml!.mlClient); const resp = await deletingJobTasks(); return response.ok({ @@ -418,7 +418,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { jobsExist } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { jobsExist } = jobServiceProvider(context.ml!.mlClient); const { jobIds } = request.body; const resp = await jobsExist(jobIds); @@ -454,7 +454,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { const { indexPattern } = request.params; const isRollup = request.query.rollup === 'true'; const savedObjectsClient = context.core.savedObjects.client; - const { newJobCaps } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { newJobCaps } = jobServiceProvider(context.ml!.mlClient); const resp = await newJobCaps(indexPattern, isRollup, savedObjectsClient); return response.ok({ @@ -499,7 +499,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { splitFieldValue, } = request.body; - const { newJobLineChart } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { newJobLineChart } = jobServiceProvider(context.ml!.mlClient); const resp = await newJobLineChart( indexPatternTitle, timeField, @@ -553,9 +553,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { splitFieldName, } = request.body; - const { newJobPopulationChart } = jobServiceProvider( - context.ml!.mlClient.callAsCurrentUser - ); + const { newJobPopulationChart } = jobServiceProvider(context.ml!.mlClient); const resp = await newJobPopulationChart( indexPatternTitle, timeField, @@ -593,7 +591,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { getAllJobAndGroupIds } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { getAllJobAndGroupIds } = jobServiceProvider(context.ml!.mlClient); const resp = await getAllJobAndGroupIds(); return response.ok({ @@ -626,7 +624,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { getLookBackProgress } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { getLookBackProgress } = jobServiceProvider(context.ml!.mlClient); const { jobId, start, end } = request.body; const resp = await getLookBackProgress(jobId, start, end); @@ -660,10 +658,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { validateCategoryExamples } = categorizationExamplesProvider( - context.ml!.mlClient.callAsCurrentUser, - context.ml!.mlClient.callAsInternalUser - ); + const { validateCategoryExamples } = categorizationExamplesProvider(context.ml!.mlClient); const { indexPatternTitle, timeField, @@ -716,7 +711,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { topCategories } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { topCategories } = jobServiceProvider(context.ml!.mlClient); const { jobId, count } = request.body; const resp = await topCategories(jobId, count); @@ -750,7 +745,7 @@ export function jobServiceRoutes({ router, mlLicense }: RouteInitialization) { }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const { revertModelSnapshot } = jobServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const { revertModelSnapshot } = jobServiceProvider(context.ml!.mlClient); const { jobId, snapshotId, diff --git a/x-pack/plugins/ml/server/routes/job_validation.ts b/x-pack/plugins/ml/server/routes/job_validation.ts index 0af8141a2a641..e52c6b76e918b 100644 --- a/x-pack/plugins/ml/server/routes/job_validation.ts +++ b/x-pack/plugins/ml/server/routes/job_validation.ts @@ -32,7 +32,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, ) { const { analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs } = payload; - return calculateModelMemoryLimitProvider(context.ml!.mlClient.callAsCurrentUser)( + return calculateModelMemoryLimitProvider(context.ml!.mlClient)( analysisConfig as AnalysisConfig, indexPattern, query, @@ -64,11 +64,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { let errorResp; - const resp = await estimateBucketSpanFactory( - context.ml!.mlClient.callAsCurrentUser, - context.ml!.mlClient.callAsInternalUser, - mlLicense.isSecurityEnabled() === false - )(request.body) + const resp = await estimateBucketSpanFactory(context.ml!.mlClient)(request.body) // this catch gets triggered when the estimation code runs without error // but isn't able to come up with a bucket span estimation. // this doesn't return a HTTP error but an object with an error message @@ -147,10 +143,7 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { - const resp = await validateCardinality( - context.ml!.mlClient.callAsCurrentUser, - request.body - ); + const resp = await validateCardinality(context.ml!.mlClient, request.body); return response.ok({ body: resp, @@ -184,10 +177,9 @@ export function jobValidationRoutes({ router, mlLicense }: RouteInitialization, try { // version corresponds to the version used in documentation links. const resp = await validateJob( - context.ml!.mlClient.callAsCurrentUser, + context.ml!.mlClient, request.body, version, - context.ml!.mlClient.callAsInternalUser, mlLicense.isSecurityEnabled() === false ); diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 88d24a1b86b6d..463babb86304f 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -6,7 +6,7 @@ import { TypeOf } from '@kbn/config-schema'; -import { RequestHandlerContext } from 'kibana/server'; +import { RequestHandlerContext, KibanaRequest } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; import { wrapError } from '../client/error_wrapper'; import { DataRecognizer } from '../models/data_recognizer'; @@ -18,19 +18,17 @@ import { } from './schemas/modules'; import { RouteInitialization } from '../types'; -function recognize(context: RequestHandlerContext, indexPatternTitle: string) { - const dr = new DataRecognizer( - context.ml!.mlClient.callAsCurrentUser, - context.core.savedObjects.client - ); +function recognize( + context: RequestHandlerContext, + request: KibanaRequest, + indexPatternTitle: string +) { + const dr = new DataRecognizer(context.ml!.mlClient, context.core.savedObjects.client, request); return dr.findMatches(indexPatternTitle); } -function getModule(context: RequestHandlerContext, moduleId: string) { - const dr = new DataRecognizer( - context.ml!.mlClient.callAsCurrentUser, - context.core.savedObjects.client - ); +function getModule(context: RequestHandlerContext, request: KibanaRequest, moduleId: string) { + const dr = new DataRecognizer(context.ml!.mlClient, context.core.savedObjects.client, request); if (moduleId === undefined) { return dr.listModules(); } else { @@ -40,6 +38,7 @@ function getModule(context: RequestHandlerContext, moduleId: string) { function setup( context: RequestHandlerContext, + request: KibanaRequest, moduleId: string, prefix?: string, groups?: string[], @@ -53,10 +52,7 @@ function setup( datafeedOverrides?: DatafeedOverride | DatafeedOverride[], estimateModelMemory?: boolean ) { - const dr = new DataRecognizer( - context.ml!.mlClient.callAsCurrentUser, - context.core.savedObjects.client - ); + const dr = new DataRecognizer(context.ml!.mlClient, context.core.savedObjects.client, request); return dr.setup( moduleId, prefix, @@ -73,11 +69,12 @@ function setup( ); } -function dataRecognizerJobsExist(context: RequestHandlerContext, moduleId: string) { - const dr = new DataRecognizer( - context.ml!.mlClient.callAsCurrentUser, - context.core.savedObjects.client - ); +function dataRecognizerJobsExist( + context: RequestHandlerContext, + request: KibanaRequest, + moduleId: string +) { + const dr = new DataRecognizer(context.ml!.mlClient, context.core.savedObjects.client, request); return dr.dataRecognizerJobsExist(moduleId); } @@ -125,7 +122,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { indexPatternTitle } = request.params; - const results = await recognize(context, indexPatternTitle); + const results = await recognize(context, request, indexPatternTitle); return response.ok({ body: results }); } catch (e) { @@ -260,7 +257,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { // the moduleId will be an empty string. moduleId = undefined; } - const results = await getModule(context, moduleId); + const results = await getModule(context, request, moduleId); return response.ok({ body: results }); } catch (e) { @@ -440,6 +437,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { const result = await setup( context, + request, moduleId, prefix, groups, @@ -526,7 +524,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { moduleId } = request.params; - const result = await dataRecognizerJobsExist(context, moduleId); + const result = await dataRecognizerJobsExist(context, request, moduleId); return response.ok({ body: result }); } catch (e) { diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index 94ca0827ccfa5..c7fcebd2a29a5 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -17,7 +17,7 @@ import { import { resultsServiceProvider } from '../models/results_service'; function getAnomaliesTableData(context: RequestHandlerContext, payload: any) { - const rs = resultsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const rs = resultsServiceProvider(context.ml!.mlClient); const { jobIds, criteriaFields, @@ -47,24 +47,24 @@ function getAnomaliesTableData(context: RequestHandlerContext, payload: any) { } function getCategoryDefinition(context: RequestHandlerContext, payload: any) { - const rs = resultsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const rs = resultsServiceProvider(context.ml!.mlClient); return rs.getCategoryDefinition(payload.jobId, payload.categoryId); } function getCategoryExamples(context: RequestHandlerContext, payload: any) { - const rs = resultsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const rs = resultsServiceProvider(context.ml!.mlClient); const { jobId, categoryIds, maxExamples } = payload; return rs.getCategoryExamples(jobId, categoryIds, maxExamples); } function getMaxAnomalyScore(context: RequestHandlerContext, payload: any) { - const rs = resultsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const rs = resultsServiceProvider(context.ml!.mlClient); const { jobIds, earliestMs, latestMs } = payload; return rs.getMaxAnomalyScore(jobIds, earliestMs, latestMs); } function getPartitionFieldsValues(context: RequestHandlerContext, payload: any) { - const rs = resultsServiceProvider(context.ml!.mlClient.callAsCurrentUser); + const rs = resultsServiceProvider(context.ml!.mlClient); const { jobId, searchTerm, criteriaFields, earliestMs, latestMs } = payload; return rs.getPartitionFieldsValues(jobId, searchTerm, criteriaFields, earliestMs, latestMs); } diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 5469c2fefdf33..0c3e186c314cc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -28,6 +28,7 @@ export const dataAnalyticsJobConfigSchema = schema.object({ analysis: schema.any(), analyzed_fields: schema.any(), model_memory_limit: schema.string(), + max_num_threads: schema.maybe(schema.number()), }); export const dataAnalyticsEvaluateSchema = schema.object({ @@ -52,6 +53,7 @@ export const dataAnalyticsExplainSchema = schema.object({ analysis: schema.any(), analyzed_fields: schema.maybe(schema.any()), model_memory_limit: schema.maybe(schema.string()), + max_num_threads: schema.maybe(schema.number()), }); export const analyticsIdSchema = schema.object({ @@ -73,6 +75,7 @@ export const dataAnalyticsJobUpdateSchema = schema.object({ description: schema.maybe(schema.string()), model_memory_limit: schema.maybe(schema.string()), allow_lazy_start: schema.maybe(schema.boolean()), + max_num_threads: schema.maybe(schema.number()), }); export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({ diff --git a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts index b2d665954bd4d..24e45514e1efc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts @@ -11,6 +11,15 @@ export const indexPatternTitleSchema = schema.object({ indexPatternTitle: schema.string(), }); +export const dataVisualizerFieldHistogramsSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + export const dataVisualizerFieldStatsSchema = schema.object({ /** Query to match documents in the index. */ query: schema.any(), diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index d78c1cf3aa6af..410d540ecb8f7 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -60,9 +60,10 @@ export function systemRoutes( }, mlLicense.basicLicenseAPIGuard(async (context, request, response) => { try { + const { callAsCurrentUser, callAsInternalUser } = context.ml!.mlClient; let upgradeInProgress = false; try { - const info = await context.ml!.mlClient.callAsCurrentUser('ml.info'); + const info = await callAsInternalUser('ml.info'); // if ml indices are currently being migrated, upgrade_mode will be set to true // pass this back with the privileges to allow for the disabling of UI controls. upgradeInProgress = info.upgrade_mode === true; @@ -90,7 +91,7 @@ export function systemRoutes( }); } else { const body = request.body; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.privilegeCheck', { body }); + const resp = await callAsCurrentUser('ml.privilegeCheck', { body }); resp.upgradeInProgress = upgradeInProgress; return response.ok({ body: resp, @@ -128,7 +129,7 @@ export function systemRoutes( } const { getCapabilities } = capabilitiesProvider( - context.ml!.mlClient.callAsCurrentUser, + context.ml!.mlClient, mlCapabilities, mlLicense, isMlEnabledInSpace @@ -154,43 +155,15 @@ export function systemRoutes( path: '/api/ml/ml_node_count', validate: false, options: { - tags: ['access:ml:canGetJobs'], + tags: ['access:ml:canGetJobs', 'access:ml:canGetDatafeeds'], }, }, mlLicense.basicLicenseAPIGuard(async (context, request, response) => { try { - // check for basic license first for consistency with other - // security disabled checks - if (mlLicense.isSecurityEnabled() === false) { - return response.ok({ - body: await getNodeCount(context), - }); - } else { - // if security is enabled, check that the user has permission to - // view jobs before calling getNodeCount. - // getNodeCount calls the _nodes endpoint as the internal user - // and so could give the user access to more information than - // they are entitled to. - const requiredPrivileges = [ - 'cluster:monitor/xpack/ml/job/get', - 'cluster:monitor/xpack/ml/job/stats/get', - 'cluster:monitor/xpack/ml/datafeeds/get', - 'cluster:monitor/xpack/ml/datafeeds/stats/get', - ]; - const body = { cluster: requiredPrivileges }; - const resp = await context.ml!.mlClient.callAsCurrentUser('ml.privilegeCheck', { body }); - - if (resp.has_all_requested) { - return response.ok({ - body: await getNodeCount(context), - }); - } else { - // if the user doesn't have permission to create jobs - // return a 403 - return response.forbidden(); - } - } + return response.ok({ + body: await getNodeCount(context), + }); } catch (e) { return response.customError(wrapError(e)); } @@ -214,7 +187,7 @@ export function systemRoutes( }, mlLicense.basicLicenseAPIGuard(async (context, request, response) => { try { - const info = await context.ml!.mlClient.callAsCurrentUser('ml.info'); + const info = await context.ml!.mlClient.callAsInternalUser('ml.info'); const cloudId = cloud && cloud.cloudId; return response.ok({ body: { ...info, cloudId }, diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts index 3fca8ea1ba047..100433b23f7d1 100644 --- a/x-pack/plugins/ml/server/shared.ts +++ b/x-pack/plugins/ml/server/shared.ts @@ -8,3 +8,4 @@ export * from '../common/types/anomalies'; export * from '../common/types/anomaly_detection_jobs'; export * from './lib/capabilities/errors'; export { ModuleSetupPayload } from './shared_services/providers/modules'; +export { getHistogramsForFields } from './models/data_visualizer/'; diff --git a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts index 3ae05152ae630..1140af0b76404 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { Job } from '../../../common/types/anomaly_detection_jobs'; import { SharedServicesChecks } from '../shared_services'; export interface AnomalyDetectorsProvider { anomalyDetectorsProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { jobs(jobId?: string): Promise<{ count: number; jobs: Job[] }>; @@ -22,13 +22,16 @@ export function getAnomalyDetectorsProvider({ getHasMlCapabilities, }: SharedServicesChecks): AnomalyDetectorsProvider { return { - anomalyDetectorsProvider(callAsCurrentUser: LegacyAPICaller, request: KibanaRequest) { + anomalyDetectorsProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { const hasMlCapabilities = getHasMlCapabilities(request); return { async jobs(jobId?: string) { isFullLicense(); await hasMlCapabilities(['canGetJobs']); - return callAsCurrentUser('ml.jobs', jobId !== undefined ? { jobId } : {}); + return mlClusterClient.callAsInternalUser( + 'ml.jobs', + jobId !== undefined ? { jobId } : {} + ); }, }; }, diff --git a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts index e5a42090163f8..c734dcc1583a1 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/job_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/job_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { jobServiceProvider } from '../../models/job_service'; import { SharedServicesChecks } from '../shared_services'; @@ -12,7 +12,7 @@ type OrigJobServiceProvider = ReturnType; export interface JobServiceProvider { jobServiceProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { jobsSummary: OrigJobServiceProvider['jobsSummary']; @@ -24,9 +24,9 @@ export function getJobServiceProvider({ getHasMlCapabilities, }: SharedServicesChecks): JobServiceProvider { return { - jobServiceProvider(callAsCurrentUser: LegacyAPICaller, request: KibanaRequest) { + jobServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { // const hasMlCapabilities = getHasMlCapabilities(request); - const { jobsSummary } = jobServiceProvider(callAsCurrentUser); + const { jobsSummary } = jobServiceProvider(mlClusterClient); return { async jobsSummary(...args) { isFullLicense(); diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index 27935fd6fe21d..33c8d28399a32 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { + ILegacyScopedClusterClient, + KibanaRequest, + SavedObjectsClientContract, +} from 'kibana/server'; import { TypeOf } from '@kbn/config-schema'; import { DataRecognizer } from '../../models/data_recognizer'; import { SharedServicesChecks } from '../shared_services'; @@ -15,7 +19,7 @@ export type ModuleSetupPayload = TypeOf & export interface ModulesProvider { modulesProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract ): { @@ -32,12 +36,12 @@ export function getModulesProvider({ }: SharedServicesChecks): ModulesProvider { return { modulesProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract ) { const hasMlCapabilities = getHasMlCapabilities(request); - const dr = dataRecognizerFactory(callAsCurrentUser, savedObjectsClient); + const dr = dataRecognizerFactory(mlClusterClient, savedObjectsClient, request); return { async recognize(...args) { isFullLicense(); @@ -82,8 +86,9 @@ export function getModulesProvider({ } function dataRecognizerFactory( - callAsCurrentUser: LegacyAPICaller, - savedObjectsClient: SavedObjectsClientContract + mlClusterClient: ILegacyScopedClusterClient, + savedObjectsClient: SavedObjectsClientContract, + request: KibanaRequest ) { - return new DataRecognizer(callAsCurrentUser, savedObjectsClient); + return new DataRecognizer(mlClusterClient, savedObjectsClient, request); } diff --git a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts index e9448a67cd98a..366a1f8b8c6f4 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { resultsServiceProvider } from '../../models/results_service'; import { SharedServicesChecks } from '../shared_services'; @@ -12,7 +12,7 @@ type OrigResultsServiceProvider = ReturnType; export interface ResultsServiceProvider { resultsServiceProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { getAnomaliesTableData: OrigResultsServiceProvider['getAnomaliesTableData']; @@ -24,9 +24,9 @@ export function getResultsServiceProvider({ getHasMlCapabilities, }: SharedServicesChecks): ResultsServiceProvider { return { - resultsServiceProvider(callAsCurrentUser: LegacyAPICaller, request: KibanaRequest) { + resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { const hasMlCapabilities = getHasMlCapabilities(request); - const { getAnomaliesTableData } = resultsServiceProvider(callAsCurrentUser); + const { getAnomaliesTableData } = resultsServiceProvider(mlClusterClient); return { async getAnomaliesTableData(...args) { isFullLicense(); diff --git a/x-pack/plugins/ml/server/shared_services/providers/system.ts b/x-pack/plugins/ml/server/shared_services/providers/system.ts index 00124a67e5237..ec2662014546e 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/system.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/system.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller, KibanaRequest } from 'kibana/server'; +import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; import { SearchResponse, SearchParams } from 'elasticsearch'; import { MlServerLicense } from '../../lib/license'; import { CloudSetup } from '../../../../cloud/server'; @@ -18,7 +18,7 @@ import { SharedServicesChecks } from '../shared_services'; export interface MlSystemProvider { mlSystemProvider( - callAsCurrentUser: LegacyAPICaller, + mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest ): { mlCapabilities(): Promise; @@ -35,8 +35,9 @@ export function getMlSystemProvider( resolveMlCapabilities: ResolveMlCapabilities ): MlSystemProvider { return { - mlSystemProvider(callAsCurrentUser: LegacyAPICaller, request: KibanaRequest) { + mlSystemProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { // const hasMlCapabilities = getHasMlCapabilities(request); + const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; return { async mlCapabilities() { isMinimumLicense(); @@ -52,7 +53,7 @@ export function getMlSystemProvider( } const { getCapabilities } = capabilitiesProvider( - callAsCurrentUser, + mlClusterClient, mlCapabilities, mlLicense, isMlEnabledInSpace @@ -62,7 +63,7 @@ export function getMlSystemProvider( async mlInfo(): Promise { isMinimumLicense(); - const info = await callAsCurrentUser('ml.info'); + const info = await callAsInternalUser('ml.info'); const cloudId = cloud && cloud.cloudId; return { ...info, 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 4c80195d33ace..c0dc67b3373b1 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 @@ -44,12 +44,16 @@ export const AlertsSection = ({ alerts }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d4b8236e0ef49..7b9d7276dd1c5 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -8,6 +8,7 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; +import moment from 'moment'; describe('APMSection', () => { it('renders with transaction series and stats', () => { @@ -18,8 +19,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByTestId } = render( ); @@ -38,8 +42,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByText, getByTestId } = render( ); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 697d4adfa0b75..dce80ed324456 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -21,8 +21,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,20 +30,25 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { +export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const { title = 'APM', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -53,8 +58,15 @@ export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts index 5857021b1537f..edc236c714d32 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts +++ b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts @@ -7,8 +7,6 @@ import { ApmFetchDataResponse } from '../../../../../typings'; export const response: ApmFetchDataResponse = { - title: 'APM', - appLink: '/app/apm', stats: { services: { value: 11, type: 'number' }, diff --git a/x-pack/plugins/observability/public/components/app/section/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/index.test.tsx index 49cb175d0c094..708a5e468dc7c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.test.tsx @@ -20,13 +20,13 @@ describe('SectionContainer', () => { }); it('renders section with app link', () => { const component = render( - +
I am a very nice component
); expect(component.getByText('I am a very nice component')).toBeInTheDocument(); expect(component.getByText('Foo')).toBeInTheDocument(); - expect(component.getByText('View in app')).toBeInTheDocument(); + expect(component.getByText('foo')).toBeInTheDocument(); }); it('renders section with error', () => { const component = render( diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index 3556e8c01ab30..9ba524259ea1c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -4,21 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; import { ErrorPanel } from './error_panel'; import { usePluginContext } from '../../../hooks/use_plugin_context'; +interface AppLink { + label: string; + href?: string; +} + interface Props { title: string; hasError: boolean; children: React.ReactNode; - minHeight?: number; - appLink?: string; - appLinkName?: string; + appLink?: AppLink; } -export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => { +export const SectionContainer = ({ title, appLink, children, hasError }: Props) => { const { core } = usePluginContext(); return ( } extraAction={ - appLink && ( - - - {appLinkName - ? appLinkName - : i18n.translate('xpack.observability.chart.viewInAppLabel', { - defaultMessage: 'View in app', - })} - + appLink?.href && ( + + {appLink.label} ) } diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index f3ba2ef6fa83a..9b232ea33cbfb 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -25,8 +25,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,21 +45,26 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); - const { title, appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const colorsPerItem = getColorPerItem(series); @@ -67,8 +72,15 @@ export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 6276e1ba1baca..9e5fdadaf4e5f 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -18,8 +18,8 @@ import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,17 +46,23 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); + + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Metrics', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const cpuColor = theme.eui.euiColorVis7; const memoryColor = theme.eui.euiColorVis0; @@ -65,9 +71,15 @@ export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 1f8ca6e61f132..73a566460a593 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -30,37 +30,49 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } -export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => { +export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); + + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); const formatter = niceTimeFormatter([min, max]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Uptime', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const downColor = theme.eui.euiColorVis2; const upColor = theme.eui.euiColorLightShade; return ( diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 71c2c942239fd..7170ffe1486dc 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { registerDataHandler, getDataHandler } from './data_handler'; +import moment from 'moment'; const params = { - startTime: '0', - endTime: '1', + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T13:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize: '10s', }; diff --git a/x-pack/plugins/observability/public/data_handler.ts b/x-pack/plugins/observability/public/data_handler.ts index d7f8c471ad9aa..73e34f214da28 100644 --- a/x-pack/plugins/observability/public/data_handler.ts +++ b/x-pack/plugins/observability/public/data_handler.ts @@ -31,6 +31,6 @@ export function getDataHandler(appName: T) { export async function fetchHasData() { const apps: ObservabilityApp[] = ['apm', 'uptime', 'infra_logs', 'infra_metrics']; const promises = apps.map((app) => getDataHandler(app)?.hasData()); - const [apm, uptime, logs, metrics] = await Promise.all(promises); + const [apm, uptime, logs, metrics] = await Promise.allSettled(promises); return { apm, uptime, infra_logs: logs, infra_metrics: metrics }; } diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3674e69ab5702..088fab032d930 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import moment from 'moment'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; @@ -23,7 +22,7 @@ import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_sett import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getParsedDate } from '../../utils/date'; +import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; @@ -33,13 +32,9 @@ interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) { - if (startTime && endTime) { - return getBucketSize({ - start: moment.utc(startTime).valueOf(), - end: moment.utc(endTime).valueOf(), - minInterval: '60s', - }); +function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { + if (start && end) { + return getBucketSize({ start, end, minInterval: '60s' }); } } @@ -62,16 +57,22 @@ export const OverviewPage = ({ routeParams }: Props) => { return ; } - const { - rangeFrom = timePickerTime.from, - rangeTo = timePickerTime.to, - refreshInterval = 10000, - refreshPaused = true, - } = routeParams.query; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const startTime = getParsedDate(rangeFrom); - const endTime = getParsedDate(rangeTo, { roundUp: true }); - const bucketSize = calculatetBucketSize({ startTime, endTime }); + const relativeTime = { + start: routeParams.query.rangeFrom ?? timePickerTime.from, + end: routeParams.query.rangeTo ?? timePickerTime.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start), + end: getAbsoluteTime(relativeTime.end, { roundUp: true }), + }; + + const bucketSize = calculatetBucketSize({ + start: absoluteTime.start, + end: absoluteTime.end, + }); const appEmptySections = getEmptySections({ core }).filter(({ id }) => { if (id === 'alert') { @@ -93,8 +94,8 @@ export const OverviewPage = ({ routeParams }: Props) => { @@ -116,8 +117,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_logs && ( @@ -125,8 +126,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_metrics && ( @@ -134,8 +135,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.apm && ( @@ -143,8 +144,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.uptime && ( diff --git a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts index 7303b78cc0132..6a0e1a64aa115 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts @@ -10,7 +10,6 @@ export const fetchApmData: FetchData = () => { }; const response: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { @@ -607,7 +606,6 @@ const response: ApmFetchDataResponse = { }; export const emptyResponse: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { diff --git a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts index 5bea1fbf19ace..8d1fb4d59c2cc 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts @@ -11,7 +11,6 @@ export const fetchLogsData: FetchData = () => { }; const response: LogsFetchDataResponse = { - title: 'Logs', appLink: "/app/logs/stream?logPosition=(end:'2020-06-30T21:30:00.000Z',start:'2020-06-27T22:00:00.000Z')", stats: { @@ -2319,7 +2318,6 @@ const response: LogsFetchDataResponse = { }; export const emptyResponse: LogsFetchDataResponse = { - title: 'Logs', appLink: '/app/logs', stats: {}, series: {}, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts index 37233b4f6342c..d5a7992ceabd8 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts @@ -11,7 +11,6 @@ export const fetchMetricsData: FetchData = () => { }; const response: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 11, type: 'number' }, @@ -113,7 +112,6 @@ const response: MetricsFetchDataResponse = { }; export const emptyResponse: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 0, type: 'number' }, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts index ab5874f8bfcd4..c4fa09ceb11f7 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts @@ -10,7 +10,6 @@ export const fetchUptimeData: FetchData = () => { }; const response: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { @@ -1191,7 +1190,6 @@ const response: UptimeFetchDataResponse = { }; export const emptyResponse: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index bbda1026606f1..335ce897dce7b 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -9,8 +9,10 @@ import { DEFAULT_APP_CATEGORIES, Plugin as PluginClass, PluginInitializerContext, + CoreStart, } from '../../../../src/core/public'; import { registerDataHandler } from './data_handler'; +import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav'; export interface ObservabilityPluginSetup { dashboard: { register: typeof registerDataHandler }; @@ -43,5 +45,7 @@ export class Plugin implements PluginClass { + const update = jest.fn(); + afterEach(() => { + update.mockClear(); + }); + it('hides overview menu', () => { + const core = ({ + application: { + capabilities: { + 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 }); + }); + it('shows overview menu', () => { + const core = ({ + application: { + capabilities: { + navLinks: { + apm: true, + logs: false, + metrics: false, + uptime: false, + }, + }, + }, + chrome: { navLinks: { update } }, + } as unknown) as CoreStart; + toggleOverviewLinkInNav(core); + expect(update).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 new file mode 100644 index 0000000000000..c33ca45e4fcd8 --- /dev/null +++ b/x-pack/plugins/observability/public/toggle_overview_link_in_nav.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'kibana/public'; + +export function toggleOverviewLinkInNav(core: CoreStart) { + const { apm, logs, metrics, uptime } = core.application.capabilities.navLinks; + const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible); + if (!someVisible) { + core.chrome.navLinks.update('observability-overview', { hidden: true }); + } +} diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 2dafd70896cc5..a3d7308ff9e4a 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -21,11 +21,8 @@ export interface Series { } export interface FetchDataParams { - // The start timestamp in milliseconds of the queried time interval - startTime: string; - // The end timestamp in milliseconds of the queried time interval - endTime: string; - // The aggregation bucket size in milliseconds if applicable to the data source + absoluteTime: { start: number; end: number }; + relativeTime: { start: string; end: string }; bucketSize: string; } @@ -41,7 +38,6 @@ export interface DataHandler { } export interface FetchDataResponse { - title: string; appLink: string; } diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts index fc0bbdae20cb9..bdc89ad6e8fc0 100644 --- a/x-pack/plugins/observability/public/utils/date.ts +++ b/x-pack/plugins/observability/public/utils/date.ts @@ -5,11 +5,9 @@ */ import datemath from '@elastic/datemath'; -export function getParsedDate(range?: string, opts = {}) { - if (range) { - const parsed = datemath.parse(range, opts); - if (parsed) { - return parsed.toISOString(); - } +export function getAbsoluteTime(range: string, opts = {}) { + const parsed = datemath.parse(range, opts); + if (parsed) { + return parsed.valueOf(); } } diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index 8881db0f9196e..33222dd7052e9 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin, CoreStart, PluginInitializerContext } from 'kibana/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { init as initBreadcrumbs } from './application/services/breadcrumb'; import { init as initDocumentation } from './application/services/documentation'; import { init as initHttp } from './application/services/http'; @@ -33,7 +32,7 @@ export class RemoteClustersUIPlugin } = this.initializerContext.config.get(); if (isRemoteClustersUiEnabled) { - const esSection = management.sections.getSection(ManagementSectionId.Data); + const esSection = management.sections.section.data; esSection.registerApp({ id: 'remote_clusters', diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 2b9e9299852f5..2819c28cfb54f 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -6,6 +6,8 @@ // 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/export_types/common/layouts'; export type JobId = string; export type JobStatus = diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index aad3d9b026c6e..d003d4c581699 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -23,10 +23,10 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { ManagementSectionId, ManagementSetup } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; -import { ReportingConfigType, JobId, JobStatusBuckets } from '../common/types'; +import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants'; import { getGeneralErrorToast } from './components'; import { ReportListing } from './components/report_listing'; @@ -115,8 +115,7 @@ export class ReportingPublicPlugin implements Plugin { showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); - - management.sections.getSection(ManagementSectionId.InsightsAndAlerting).registerApp({ + management.sections.section.insightsAndAlerting.registerApp({ id: 'reporting', title: this.title, order: 1, @@ -144,7 +143,7 @@ export class ReportingPublicPlugin implements Plugin { uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); - share.register(csvReportingProvider({ apiClient, toasts, license$ })); + share.register(csvReportingProvider({ apiClient, toasts, license$, uiSettings })); share.register( reportingPDFPNGProvider({ apiClient, 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 ea4ecaa60ab2c..4ad35fd768825 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 @@ -5,22 +5,29 @@ */ import { i18n } from '@kbn/i18n'; +import moment from 'moment-timezone'; import React from 'react'; - -import { ToastsSetup } from 'src/core/public'; +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 { ReportingPanelContent } from '../components/reporting_panel_content'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; import { checkLicense } from '../lib/license_check'; -import { LicensingPluginSetup } from '../../../licensing/public'; -import { ShareContext } from '../../../../../src/plugins/share/public'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingProvider { apiClient: ReportingAPIClient; toasts: ToastsSetup; license$: LicensingPluginSetup['license$']; + uiSettings: IUiSettingsClient; } -export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingProvider) => { +export const csvReportingProvider = ({ + apiClient, + toasts, + license$, + uiSettings, +}: ReportingProvider) => { let toolTipContent = ''; let disabled = true; let hasCSVReporting = false; @@ -33,6 +40,14 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP disabled = !enableLinks; }); + // If the TZ is set to the default "Browser", it will not be useful for + // server-side export. We need to derive the timezone and pass it as a param + // to the export API. + const browserTimezone = + uiSettings.get('dateFormat:tz') === 'Browser' + ? moment.tz.guess() + : uiSettings.get('dateFormat:tz'); + const getShareMenuItems = ({ objectType, objectId, @@ -44,13 +59,19 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP return []; } - const getJobParams = () => { - return { - ...sharingData, - type: objectType, - }; + const jobParams: JobParamsDiscoverCsv = { + browserTimezone, + objectType, + title: sharingData.title as string, + indexPatternId: sharingData.indexPatternId as string, + searchRequest: sharingData.searchRequest as SearchRequest, + fields: sharingData.fields as string[], + metaFields: sharingData.metaFields as string[], + conflictedTypesFields: sharingData.conflictedTypesFields as string[], }; + const getJobParams = () => jobParams; + const shareActions = []; if (hasCSVReporting) { 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 2343947a6d383..e10d04ea5fc6b 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 @@ -7,12 +7,15 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; -import { ToastsSetup, IUiSettingsClient } from 'src/core/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; -import { checkLicense } from '../lib/license_check'; -import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; -import { LicensingPluginSetup } from '../../../licensing/public'; +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 { JobParamsPNG } from '../../server/export_types/png/types'; +import { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; +import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; +import { checkLicense } from '../lib/license_check'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingPDFPNGProvider { apiClient: ReportingAPIClient; @@ -39,6 +42,14 @@ export const reportingPDFPNGProvider = ({ disabled = !enableLinks; }); + // If the TZ is set to the default "Browser", it will not be useful for + // server-side export. We need to derive the timezone and pass it as a param + // to the export API. + const browserTimezone = + uiSettings.get('dateFormat:tz') === 'Browser' + ? moment.tz.guess() + : uiSettings.get('dateFormat:tz'); + const getShareMenuItems = ({ objectType, objectId, @@ -57,7 +68,7 @@ export const reportingPDFPNGProvider = ({ return []; } - const getReportingJobParams = () => { + const getPdfJobParams = (): JobParamsPDF => { // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath // Replace hashes with original RISON values. const relativeUrl = shareableUrl.replace( @@ -65,36 +76,28 @@ export const reportingPDFPNGProvider = ({ '' ); - const browserTimezone = - uiSettings.get('dateFormat:tz') === 'Browser' - ? moment.tz.guess() - : uiSettings.get('dateFormat:tz'); - return { - ...sharingData, objectType, browserTimezone, - relativeUrls: [relativeUrl], + relativeUrls: [relativeUrl], // multi URL for PDF + layout: sharingData.layout as LayoutInstance, + title: sharingData.title as string, }; }; - const getPngJobParams = () => { + const getPngJobParams = (): JobParamsPNG => { // Replace hashes with original RISON values. const relativeUrl = shareableUrl.replace( window.location.origin + apiClient.getServerBasePath(), '' ); - const browserTimezone = - uiSettings.get('dateFormat:tz') === 'Browser' - ? moment.tz.guess() - : uiSettings.get('dateFormat:tz'); - return { - ...sharingData, objectType, browserTimezone, - relativeUrl, + relativeUrl, // single URL for PNG + layout: sharingData.layout as LayoutInstance, + title: sharingData.title as string, }; }; @@ -161,7 +164,7 @@ export const reportingPDFPNGProvider = ({ reportType="printablePdf" objectType={objectType} objectId={objectId} - getJobParams={getReportingJobParams} + getJobParams={getPdfJobParams} isDirty={isDirty} onClose={onClose} /> diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts index c4fa1cd8e4fa6..fb2d9bfdc5838 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts @@ -13,7 +13,6 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); - const setupDeps = reporting.getPluginSetupDeps(); return async function scheduleTask(jobParams, context, request) { const serializedEncryptedHeaders = await crypto.encrypt(request.headers); @@ -21,13 +20,12 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory { + 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); +}; export const runTaskFnFactory: RunTaskFnFactory { - 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 - } - }; - - const fakeRequest = 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 - getBasePath: () => basePath || serverBasePath, - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - } as Hapi.Request); + const { headers } = job; + const fakeRequest = await getRequest(headers, crypto, logger); const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(fakeRequest); const callEndpoint = (endpoint: string, clientParams = {}, options = {}) => @@ -87,62 +76,18 @@ export const runTaskFnFactory: RunTaskFnFactory { - const fieldFormats = await getFieldFormats().fieldFormatServiceFactory(client); - return fieldFormatMapFactory(indexPatternSavedObject, fieldFormats); - }; - const getUiSettings = async (client: IUiSettingsClient) => { - const [separator, quoteValues, timezone] = await Promise.all([ - client.get(CSV_SEPARATOR_SETTING), - client.get(CSV_QUOTE_VALUES_SETTING), - client.get('dateFormat:tz'), - ]); - - if (timezone === 'Browser') { - logger.warn( - i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', { - defaultMessage: 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.', - values: { dateFormatTimezone: 'dateFormat:tz' } - }) - ); // prettier-ignore - } - - return { - separator, - quoteValues, - timezone, - }; - }; - - const [formatsMap, uiSettings] = await Promise.all([ - getFormatsMap(uiSettingsClient), - getUiSettings(uiSettingsClient), - ]); - - const generateCsv = createGenerateCsv(jobLogger); - const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; - - const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv({ - searchRequest, - fields, - metaFields, - conflictedTypesFields, + const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( + job, + config, + uiSettingsClient, callEndpoint, - cancellationToken, - formatsMap, - settings: { - ...uiSettings, - checkForFormulas: config.get('csv', 'checkForFormulas'), - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), - escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), - }, - }); + cancellationToken + ); // @TODO: Consolidate these one-off warnings into the warnings array (max-size reached and csv contains formulas) return { - content_type: 'text/csv', - content: bom + content, + content_type: CONTENT_TYPE_CSV, + content, max_size_reached: maxSizeReached, size, csv_contains_formulas: csvContainsFormulas, diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts similarity index 74% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts index 83aa23de67663..1f0e450da698f 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts @@ -5,25 +5,17 @@ */ import expect from '@kbn/expect'; - -import { - fieldFormats, - FieldFormatsGetConfigFn, - UI_SETTINGS, -} from '../../../../../../../../src/plugins/data/server'; +import { fieldFormats, FieldFormatsGetConfigFn, UI_SETTINGS } from 'src/plugins/data/server'; +import { IndexPatternSavedObject } from '../../types'; import { fieldFormatMapFactory } from './field_format_map'; type ConfigValue = { number: { id: string; params: {} } } | string; describe('field format map', function () { - const indexPatternSavedObject = { - id: 'logstash-*', - type: 'index-pattern', - version: 'abc', + const indexPatternSavedObject: IndexPatternSavedObject = { + timeFieldName: '@timestamp', + title: 'logstash-*', attributes: { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, fields: '[{"name":"field1","type":"number"}, {"name":"field2","type":"number"}]', fieldFormatMap: '{"field1":{"id":"bytes","params":{"pattern":"0,0.[0]b"}}}', }, @@ -35,11 +27,16 @@ describe('field format map', function () { configMock[UI_SETTINGS.FORMAT_NUMBER_DEFAULT_PATTERN] = '0,0.[000]'; const getConfig = ((key: string) => configMock[key]) as FieldFormatsGetConfigFn; const testValue = '4000'; + const mockTimezone = 'Browser'; const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); fieldFormatsRegistry.init(getConfig, {}, [fieldFormats.BytesFormat, fieldFormats.NumberFormat]); - const formatMap = fieldFormatMapFactory(indexPatternSavedObject, fieldFormatsRegistry); + const formatMap = fieldFormatMapFactory( + indexPatternSavedObject, + fieldFormatsRegistry, + mockTimezone + ); it('should build field format map with entry per index pattern field', function () { expect(formatMap.has('field1')).to.be(true); @@ -48,10 +45,10 @@ describe('field format map', function () { }); it('should create custom FieldFormat for fields with configured field formatter', function () { - expect(formatMap.get('field1').convert(testValue)).to.be('3.9KB'); + expect(formatMap.get('field1')!.convert(testValue)).to.be('3.9KB'); }); it('should create default FieldFormat for fields with no field formatter', function () { - expect(formatMap.get('field2').convert(testValue)).to.be('4,000'); + expect(formatMap.get('field2')!.convert(testValue)).to.be('4,000'); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts similarity index 56% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts index 6cb4d0bbb1c65..848cf569bc8d7 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts @@ -5,19 +5,9 @@ */ import _ from 'lodash'; -import { - FieldFormatConfig, - IFieldFormatsRegistry, -} from '../../../../../../../../src/plugins/data/server'; - -interface IndexPatternSavedObject { - attributes: { - fieldFormatMap: string; - }; - id: string; - type: string; - version: string; -} +import { FieldFormat } from 'src/plugins/data/common'; +import { FieldFormatConfig, IFieldFormatsRegistry } from 'src/plugins/data/server'; +import { IndexPatternSavedObject } from '../../types'; /** * Create a map of FieldFormat instances for index pattern fields @@ -28,30 +18,39 @@ interface IndexPatternSavedObject { */ export function fieldFormatMapFactory( indexPatternSavedObject: IndexPatternSavedObject, - fieldFormatsRegistry: IFieldFormatsRegistry + fieldFormatsRegistry: IFieldFormatsRegistry, + timezone: string | undefined ) { - const formatsMap = new Map(); + const formatsMap = new Map(); + + // From here, the browser timezone can't be determined, so we accept a + // timezone field from job params posted to the API. Here is where it gets used. + const serverDateParams = { timezone }; // Add FieldFormat instances for fields with custom formatters if (_.has(indexPatternSavedObject, 'attributes.fieldFormatMap')) { const fieldFormatMap = JSON.parse(indexPatternSavedObject.attributes.fieldFormatMap); Object.keys(fieldFormatMap).forEach((fieldName) => { const formatConfig: FieldFormatConfig = fieldFormatMap[fieldName]; + const formatParams = { + ...formatConfig.params, + ...serverDateParams, + }; if (!_.isEmpty(formatConfig)) { - formatsMap.set( - fieldName, - fieldFormatsRegistry.getInstance(formatConfig.id, formatConfig.params) - ); + formatsMap.set(fieldName, fieldFormatsRegistry.getInstance(formatConfig.id, formatParams)); } }); } - // Add default FieldFormat instances for all other fields + // Add default FieldFormat instances for non-custom formatted fields const indexFields = JSON.parse(_.get(indexPatternSavedObject, 'attributes.fields', '[]')); indexFields.forEach((field: any) => { if (!formatsMap.has(field.name)) { - formatsMap.set(field.name, fieldFormatsRegistry.getDefaultInstance(field.type)); + formatsMap.set( + field.name, + fieldFormatsRegistry.getDefaultInstance(field.type, [], serverDateParams) + ); } }); diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts similarity index 86% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts index bb4e2be86f5df..387066415a1bc 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts @@ -5,13 +5,14 @@ */ import { isNull, isObject, isUndefined } from 'lodash'; +import { FieldFormat } from 'src/plugins/data/common'; import { RawValue } from '../../types'; export function createFormatCsvValues( escapeValue: (value: RawValue, index: number, array: RawValue[]) => string, separator: string, fields: string[], - formatsMap: any + formatsMap: Map ) { return function formatCsvValues(values: Record) { return fields @@ -29,7 +30,9 @@ export function createFormatCsvValues( let formattedValue = value; if (formatsMap.has(field)) { const formatter = formatsMap.get(field); - formattedValue = formatter.convert(value); + if (formatter) { + formattedValue = formatter.convert(value); + } } return formattedValue; diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts new file mode 100644 index 0000000000000..8f72c467b0711 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/server'; +import { ReportingConfig } from '../../../..'; +import { LevelLogger } from '../../../../lib'; + +export const getUiSettings = async ( + timezone: string | undefined, + client: IUiSettingsClient, + config: ReportingConfig, + logger: LevelLogger +) => { + // Timezone + let setTimezone: string; + // look for timezone in job params + if (timezone) { + setTimezone = timezone; + } else { + // if empty, look for timezone in settings + setTimezone = await client.get('dateFormat:tz'); + if (setTimezone === 'Browser') { + // if `Browser`, hardcode it to 'UTC' so the export has data that makes sense + logger.warn( + i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', { + defaultMessage: + 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.', + values: { dateFormatTimezone: 'dateFormat:tz' }, + }) + ); + setTimezone = 'UTC'; + } + } + + // Separator, QuoteValues + const [separator, quoteValues] = await Promise.all([ + client.get('csv:separator'), + client.get('csv:quoteValues'), + ]); + + return { + timezone: setTimezone, + separator, + quoteValues, + escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + scroll: config.get('csv', 'scroll'), + checkForFormulas: config.get('csv', 'checkForFormulas'), + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts similarity index 82% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts index 38b28573d602d..b877023064ac6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts @@ -10,8 +10,10 @@ import { CancellationToken } from '../../../../../common'; import { LevelLogger } from '../../../../lib'; import { ScrollConfig } from '../../../../types'; -async function parseResponse(request: SearchResponse) { - const response = await request; +export type EndpointCaller = (method: string, params: object) => Promise>; + +function parseResponse(request: SearchResponse) { + const response = request; if (!response || !response._scroll_id) { throw new Error( i18n.translate('xpack.reporting.exportTypes.csv.hitIterator.expectedScrollIdErrorMessage', { @@ -39,14 +41,15 @@ async function parseResponse(request: SearchResponse) { export function createHitIterator(logger: LevelLogger) { return async function* hitIterator( scrollSettings: ScrollConfig, - callEndpoint: Function, + callEndpoint: EndpointCaller, searchRequest: SearchParams, cancellationToken: CancellationToken ) { logger.debug('executing search request'); - function search(index: string | boolean | string[] | undefined, body: object) { + async function search(index: string | boolean | string[] | undefined, body: object) { return parseResponse( - callEndpoint('search', { + await callEndpoint('search', { + ignore_unavailable: true, // ignores if the index pattern contains any aliases that point to closed indices index, body, scroll: scrollSettings.duration, @@ -55,10 +58,10 @@ export function createHitIterator(logger: LevelLogger) { ); } - function scroll(scrollId: string | undefined) { + async function scroll(scrollId: string | undefined) { logger.debug('executing scroll request'); return parseResponse( - callEndpoint('scroll', { + await callEndpoint('scroll', { scrollId, scroll: scrollSettings.duration, }) diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts similarity index 55% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts index 019fa3c9c8e9d..2cb10e291619c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts @@ -5,30 +5,68 @@ */ 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 { LevelLogger } from '../../../../lib'; -import { GenerateCsvParams, SavedSearchGeneratorResult } from '../../types'; +import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../../types'; +import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; +import { createEscapeValue } from './escape_value'; +import { fieldFormatMapFactory } from './field_format_map'; import { createFlattenHit } from './flatten_hit'; import { createFormatCsvValues } from './format_csv_values'; -import { createEscapeValue } from './escape_value'; -import { createHitIterator } from './hit_iterator'; +import { getUiSettings } from './get_ui_settings'; +import { createHitIterator, EndpointCaller } from './hit_iterator'; import { MaxSizeStringBuilder } from './max_size_string_builder'; -import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; + +interface SearchRequest { + index: string; + body: + | { + _source: { excludes: string[]; includes: string[] }; + docvalue_fields: string[]; + query: { bool: { filter: any[]; must_not: any[]; should: any[]; must: any[] } } | any; + script_fields: any; + sort: Array<{ [key: string]: { order: string } }>; + stored_fields: string[]; + } + | any; +} + +export interface GenerateCsvParams { + jobParams: { + browserTimezone: string; + }; + searchRequest: SearchRequest; + indexPatternSavedObject: IndexPatternSavedObject; + fields: string[]; + metaFields: string[]; + conflictedTypesFields: string[]; +} export function createGenerateCsv(logger: LevelLogger) { const hitIterator = createHitIterator(logger); - return async function generateCsv({ - searchRequest, - fields, - formatsMap, - metaFields, - conflictedTypesFields, - callEndpoint, - cancellationToken, - settings, - }: GenerateCsvParams): Promise { + return async function generateCsv( + job: GenerateCsvParams, + config: ReportingConfig, + uiSettingsClient: IUiSettingsClient, + callEndpoint: EndpointCaller, + cancellationToken: CancellationToken + ): Promise { + const settings = await getUiSettings( + job.jobParams?.browserTimezone, + uiSettingsClient, + config, + logger + ); const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues); - const builder = new MaxSizeStringBuilder(settings.maxSizeBytes); + const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; + const builder = new MaxSizeStringBuilder(settings.maxSizeBytes, bom); + + const { fields, metaFields, conflictedTypesFields } = job; const header = `${fields.map(escapeValue).join(settings.separator)}\n`; const warnings: string[] = []; @@ -41,11 +79,22 @@ export function createGenerateCsv(logger: LevelLogger) { }; } - const iterator = hitIterator(settings.scroll, callEndpoint, searchRequest, cancellationToken); + const iterator = hitIterator( + settings.scroll, + callEndpoint, + job.searchRequest, + cancellationToken + ); let maxSizeReached = false; let csvContainsFormulas = false; const flattenHit = createFlattenHit(fields, metaFields, conflictedTypesFields); + const formatsMap = await getFieldFormats() + .fieldFormatServiceFactory(uiSettingsClient) + .then((fieldFormats) => + fieldFormatMapFactory(job.indexPatternSavedObject, fieldFormats, settings.timezone) + ); + const formatCsvValues = createFormatCsvValues( escapeValue, settings.separator, @@ -76,7 +125,9 @@ export function createGenerateCsv(logger: LevelLogger) { if (!builder.tryAppend(rows + '\n')) { logger.warn('max Size Reached'); maxSizeReached = true; - cancellationToken.cancel(); + if (cancellationToken) { + cancellationToken.cancel(); + } break; } } diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts similarity index 91% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts index 7a35de1cea19b..e3cd1f32856e6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts @@ -62,6 +62,14 @@ describe('MaxSizeStringBuilder', function () { builder.tryAppend(str); expect(builder.getString()).to.be('a'); }); + + it('should return string with bom character prepended', function () { + const str = 'a'; // each a is one byte + const builder = new MaxSizeStringBuilder(1, '∆'); + builder.tryAppend(str); + builder.tryAppend(str); + expect(builder.getString()).to.be('∆a'); + }); }); describe('getSizeInBytes', function () { diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts similarity index 82% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts index 70bc2030d290c..147031c104c8e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts @@ -8,11 +8,13 @@ export class MaxSizeStringBuilder { private _buffer: Buffer; private _size: number; private _maxSize: number; + private _bom: string; - constructor(maxSizeBytes: number) { + constructor(maxSizeBytes: number, bom = '') { this._buffer = Buffer.alloc(maxSizeBytes); this._size = 0; this._maxSize = maxSizeBytes; + this._bom = bom; } tryAppend(str: string) { @@ -31,6 +33,6 @@ export class MaxSizeStringBuilder { } getString() { - return this._buffer.slice(0, this._size).toString(); + return this._bom + this._buffer.slice(0, this._size).toString(); } } diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts new file mode 100644 index 0000000000000..21e49bd62ccc7 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 ab3e114c7c995..9e86a5bb254a3 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 @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CancellationToken } from '../../../common'; -import { JobParamPostPayload, ScheduledTaskParams, ScrollConfig } from '../../types'; +import { ScheduledTaskParams } from '../../types'; export type RawValue = string | object | null | undefined; @@ -19,17 +18,25 @@ interface SortOptions { unmapped_type: string; } -export interface JobParamPostPayloadDiscoverCsv extends JobParamPostPayload { - state?: { - query: any; - sort: Array>; - docvalue_fields: DocValueField[]; +export interface IndexPatternSavedObject { + title: string; + timeFieldName: string; + fields?: any[]; + attributes: { + fields: string; + fieldFormatMap: string; }; } export interface JobParamsDiscoverCsv { - indexPatternId?: string; - post?: JobParamPostPayloadDiscoverCsv; + browserTimezone: string; + indexPatternId: string; + objectType: string; + title: string; + searchRequest: SearchRequest; + fields: string[]; + metaFields: string[]; + conflictedTypesFields: string[]; } export interface ScheduledTaskParamsCSV extends ScheduledTaskParams { @@ -71,8 +78,6 @@ export interface SearchRequest { | any; } -type EndpointCaller = (method: string, params: any) => Promise; - type FormatsMap = Map< string, { @@ -95,22 +100,3 @@ export interface CsvResultFromSearch { type: string; result: SavedSearchGeneratorResult; } - -export interface GenerateCsvParams { - searchRequest: SearchRequest; - callEndpoint: EndpointCaller; - fields: string[]; - formatsMap: FormatsMap; - metaFields: string[]; - conflictedTypesFields: string[]; - cancellationToken: CancellationToken; - settings: { - separator: string; - quoteValues: boolean; - timezone: string | null; - maxSizeBytes: number; - scroll: ScrollConfig; - checkForFormulas?: boolean; - escapeFormulaValues: boolean; - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts similarity index 76% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts rename to x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts index da9810b03aff6..96fb2033f0954 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts @@ -7,18 +7,18 @@ import { notFound, notImplemented } from 'boom'; import { get } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../../common/constants'; -import { cryptoFactory } from '../../../../lib'; -import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../../types'; +import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; +import { cryptoFactory } from '../../../lib'; +import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../types'; import { JobParamsPanelCsv, SavedObject, + SavedObjectReference, SavedObjectServiceError, SavedSearchObjectAttributesJSON, SearchPanel, VisObjectAttributesJSON, -} from '../../types'; -import { createJobSearch } from './create_job_search'; +} from '../types'; export type ImmediateCreateJobFn = ( jobParams: JobParamsPanelCsv, @@ -26,7 +26,7 @@ export type ImmediateCreateJobFn = ( context: RequestHandlerContext, req: KibanaRequest ) => Promise<{ - type: string | null; + type: string; title: string; jobParams: JobParamsPanelCsv; }>; @@ -73,7 +73,28 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory } // saved search type - return await createJobSearch(timerange, attributes, references, kibanaSavedObjectMeta); + const { searchSource } = kibanaSavedObjectMeta; + if (!searchSource || !references) { + throw new Error('The saved search object is missing configuration fields!'); + } + + const indexPatternMeta = references.find( + (ref: SavedObjectReference) => ref.type === 'index-pattern' + ); + if (!indexPatternMeta) { + throw new Error('Could not find index pattern for the saved search!'); + } + + const sPanel = { + attributes: { + ...attributes, + kibanaSavedObjectMeta: { searchSource }, + }, + indexPatternSavedObjectId: indexPatternMeta.id, + timerange, + }; + + return { panel: sPanel, title: attributes.title, visType: 'search' }; }) .catch((err: Error) => { const boomErr = (err as unknown) as { isBoom: boolean }; @@ -93,7 +114,7 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory return { headers: serializedEncryptedHeaders, jobParams: { ...jobParams, panel, visType }, - type: null, + type: visType, title, }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts deleted file mode 100644 index 02abfb90091a1..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TimeRangeParams } from '../../../../types'; -import { - SavedObjectMeta, - SavedObjectReference, - SavedSearchObjectAttributes, - SearchPanel, -} from '../../types'; - -interface SearchPanelData { - title: string; - visType: string; - panel: SearchPanel; -} - -export async function createJobSearch( - timerange: TimeRangeParams, - attributes: SavedSearchObjectAttributes, - references: SavedObjectReference[], - kibanaSavedObjectMeta: SavedObjectMeta -): Promise { - const { searchSource } = kibanaSavedObjectMeta; - if (!searchSource || !references) { - throw new Error('The saved search object is missing configuration fields!'); - } - - const indexPatternMeta = references.find( - (ref: SavedObjectReference) => ref.type === 'index-pattern' - ); - if (!indexPatternMeta) { - throw new Error('Could not find index pattern for the saved search!'); - } - - const sPanel = { - attributes: { - ...attributes, - kibanaSavedObjectMeta: { searchSource }, - }, - indexPatternSavedObjectId: indexPatternMeta.id, - timerange, - }; - - return { panel: sPanel, title: attributes.title, visType: 'search' }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts index 912ae0809cf92..a7992c34a88f1 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { CancellationToken } from '../../../../common'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { RunTaskFnFactory, ScheduledTaskParams, TaskRunResult } from '../../../types'; -import { CsvResultFromSearch } from '../../csv/types'; +import { createGenerateCsv } from '../../csv/server/generate_csv'; import { JobParamsPanelCsv, SearchPanel } from '../types'; -import { createGenerateCsv } from './lib'; +import { getFakeRequest } from './lib/get_fake_request'; +import { getGenerateCsvParams } from './lib/get_csv_job'; /* * The run function receives the full request which provides the un-encrypted @@ -33,45 +34,47 @@ export const runTaskFnFactory: RunTaskFnFactory = function e reporting, parentLogger ) { + const config = reporting.getConfig(); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = createGenerateCsv(reporting, parentLogger); - return async function runTask(jobId: string | null, job, context, request) { + 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; const jobLogger = logger.clone([jobId === null ? 'immediate' : jobId]); + const generateCsv = createGenerateCsv(jobLogger); + const { isImmediate, panel, visType } = jobParams as JobParamsPanelCsv & { + panel: SearchPanel; + }; - const { jobParams } = job; - const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; + jobLogger.debug(`Execute job generating [${visType}] csv`); - if (!panel) { - i18n.translate( - 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel', - { defaultMessage: 'Failed to access panel metadata for job execution' } - ); + if (isImmediate && req) { + jobLogger.info(`Executing job from Immediate API using request context`); + } else { + jobLogger.info(`Executing job async using encrypted headers`); + req = await getFakeRequest(jobPayload, config.get('encryptionKey')!, jobLogger); } - 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 elasticsearch = reporting.getElasticsearchService(); + const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); - let content: string; - let maxSizeReached = false; - let size = 0; - try { - const generateResults: CsvResultFromSearch = await generateCsv( - context, - request, - visType as string, - panel, - jobParams - ); + const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( + job, + config, + uiConfig, + callAsCurrentUser, + new CancellationToken() // can not be cancelled + ); - ({ - result: { content, maxSizeReached, size }, - } = generateResults); - } catch (err) { - jobLogger.error(`Generate CSV Error! ${err}`); - throw err; + if (csvContainsFormulas) { + jobLogger.warn(`CSV may contain formulas whose values have been escaped`); } if (maxSizeReached) { @@ -83,6 +86,8 @@ export const runTaskFnFactory: RunTaskFnFactory = function e content, max_size_reached: maxSizeReached, size, + csv_contains_formulas: csvContainsFormulas, + warnings, }; }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts deleted file mode 100644 index dd0fb34668e9e..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { badRequest } from 'boom'; -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { ReportingCore } from '../../../..'; -import { LevelLogger } from '../../../../lib'; -import { FakeRequest, JobParamsPanelCsv, SearchPanel, VisPanel } from '../../types'; -import { generateCsvSearch } from './generate_csv_search'; - -export function createGenerateCsv(reporting: ReportingCore, logger: LevelLogger) { - return async function generateCsv( - context: RequestHandlerContext, - request: KibanaRequest | FakeRequest, - visType: string, - panel: VisPanel | SearchPanel, - jobParams: JobParamsPanelCsv - ) { - // This should support any vis type that is able to fetch - // and model data on the server-side - - // This structure will not be needed when the vis data just consists of an - // expression that we could run through the interpreter to get csv - switch (visType) { - case 'search': - return await generateCsvSearch( - reporting, - context, - request as KibanaRequest, - panel as SearchPanel, - jobParams, - logger - ); - default: - throw badRequest(`Unsupported or unrecognized saved object type: ${visType}`); - } - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts deleted file mode 100644 index aee3e40025ff2..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts +++ /dev/null @@ -1,187 +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 { ReportingCore } from '../../../../'; -import { - IUiSettingsClient, - KibanaRequest, - RequestHandlerContext, -} from '../../../../../../../../src/core/server'; -import { - esQuery, - EsQueryConfig, - Filter, - IIndexPattern, - Query, - UI_SETTINGS, -} from '../../../../../../../../src/plugins/data/server'; -import { - CSV_SEPARATOR_SETTING, - CSV_QUOTE_VALUES_SETTING, -} from '../../../../../../../../src/plugins/share/server'; -import { CancellationToken } from '../../../../../common'; -import { LevelLogger } from '../../../../lib'; -import { createGenerateCsv } from '../../../csv/server/lib/generate_csv'; -import { - CsvResultFromSearch, - GenerateCsvParams, - JobParamsDiscoverCsv, - SearchRequest, -} from '../../../csv/types'; -import { IndexPatternField, QueryFilter, SearchPanel, SearchSource } from '../../types'; -import { getDataSource } from './get_data_source'; -import { getFilters } from './get_filters'; - -const getEsQueryConfig = async (config: IUiSettingsClient) => { - const configs = await Promise.all([ - config.get(UI_SETTINGS.QUERY_ALLOW_LEADING_WILDCARDS), - config.get(UI_SETTINGS.QUERY_STRING_OPTIONS), - config.get(UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX), - ]); - const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs; - return { - allowLeadingWildcards, - queryStringOptions, - ignoreFilterIfFieldNotInIndex, - } as EsQueryConfig; -}; - -const getUiSettings = async (config: IUiSettingsClient) => { - const configs = await Promise.all([ - config.get(CSV_SEPARATOR_SETTING), - config.get(CSV_QUOTE_VALUES_SETTING), - ]); - const [separator, quoteValues] = configs; - return { separator, quoteValues }; -}; - -export async function generateCsvSearch( - reporting: ReportingCore, - context: RequestHandlerContext, - req: KibanaRequest, - searchPanel: SearchPanel, - jobParams: JobParamsDiscoverCsv, - logger: LevelLogger -): Promise { - const savedObjectsClient = context.core.savedObjects.client; - const { indexPatternSavedObjectId, timerange } = searchPanel; - const savedSearchObjectAttr = searchPanel.attributes; - const { indexPatternSavedObject } = await getDataSource( - savedObjectsClient, - indexPatternSavedObjectId - ); - - const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const esQueryConfig = await getEsQueryConfig(uiConfig); - - const { - kibanaSavedObjectMeta: { - searchSource: { - filter: [searchSourceFilter], - query: searchSourceQuery, - }, - }, - } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } }; - - const { - timeFieldName: indexPatternTimeField, - title: esIndex, - fields: indexPatternFields, - } = indexPatternSavedObject; - - let payloadQuery: QueryFilter | undefined; - let payloadSort: any[] = []; - let docValueFields: any[] | undefined; - if (jobParams.post && jobParams.post.state) { - ({ - post: { - state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields }, - }, - } = jobParams); - } - - const { includes, timezone, combinedFilter } = getFilters( - indexPatternSavedObjectId, - indexPatternTimeField, - timerange, - savedSearchObjectAttr, - searchSourceFilter, - payloadQuery - ); - - const savedSortConfigs = savedSearchObjectAttr.sort; - const sortConfig = [...payloadSort]; - savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => { - sortConfig.push({ [savedSortField]: { order: savedSortOrder } }); - }); - const scriptFieldsConfig = indexPatternFields - .filter((f: IndexPatternField) => f.scripted) - .reduce((accum: any, curr: IndexPatternField) => { - return { - ...accum, - [curr.name]: { - script: { - source: curr.script, - lang: curr.lang, - }, - }, - }; - }, {}); - - if (indexPatternTimeField) { - if (docValueFields) { - docValueFields = [indexPatternTimeField].concat(docValueFields); - } else { - docValueFields = [indexPatternTimeField]; - } - } - - const searchRequest: SearchRequest = { - index: esIndex, - body: { - _source: { includes }, - docvalue_fields: docValueFields, - query: esQuery.buildEsQuery( - indexPatternSavedObject as IIndexPattern, - (searchSourceQuery as unknown) as Query, - (combinedFilter as unknown) as Filter, - esQueryConfig - ), - script_fields: scriptFieldsConfig, - sort: sortConfig, - }, - }; - - const config = reporting.getConfig(); - const elasticsearch = reporting.getElasticsearchService(); - const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); - const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); - const uiSettings = await getUiSettings(uiConfig); - - const generateCsvParams: GenerateCsvParams = { - searchRequest, - callEndpoint: callCluster, - fields: includes, - formatsMap: new Map(), // there is no field formatting in this API; this is required for generateCsv - metaFields: [], - conflictedTypesFields: [], - cancellationToken: new CancellationToken(), - settings: { - ...uiSettings, - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), - escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), - timezone, - }, - }; - - const generateCsv = createGenerateCsv(logger); - - return { - type: 'CSV from Saved Search', - result: await generateCsv(generateCsvParams), - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts new file mode 100644 index 0000000000000..3271c6fdae24d --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { JobParamsPanelCsv, SearchPanel } from '../../types'; +import { getGenerateCsvParams } from './get_csv_job'; + +describe('Get CSV Job', () => { + let mockJobParams: JobParamsPanelCsv; + let mockSearchPanel: SearchPanel; + let mockSavedObjectsClient: any; + let mockUiSettingsClient: any; + beforeEach(() => { + mockJobParams = { isImmediate: true, savedObjectType: 'search', savedObjectId: '234-ididid' }; + mockSearchPanel = { + indexPatternSavedObjectId: '123-indexId', + attributes: { + title: 'my search', + sort: [], + kibanaSavedObjectMeta: { + searchSource: { query: { isSearchSourceQuery: true }, filter: [] }, + }, + uiState: 56, + }, + timerange: { timezone: 'PST', min: 0, max: 100 }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: null, timeFieldName: null }, + }), + }; + mockUiSettingsClient = { + get: () => ({}), + }; + }); + + it('creates a data structure needed by generateCsv', async () => { + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "PST", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": null, + }, + } + `); + }); + + it('uses query and sort from the payload', async () => { + mockJobParams.post = { + state: { + query: ['this is the query'], + sort: ['this is the sort'], + }, + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "PST", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "0": "this is the query", + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [ + "this is the sort", + ], + }, + "index": null, + }, + } + `); + }); + + it('uses timerange timezone from the payload', async () => { + mockJobParams.post = { + timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 9000 }, + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": null, + }, + } + `); + }); + + it('uses timerange min and max (numeric) when index pattern has timefieldName', async () => { + mockJobParams.post = { + timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 900000000 }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, + }), + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [ + "@test_time", + ], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": "@test_time", + "title": "test search", + }, + "fields": Array [], + "timeFieldName": "@test_time", + "title": "test search", + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [ + "@test_time", + ], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@test_time": Object { + "format": "strict_date_time", + "gte": "1970-01-01T00:00:00Z", + "lte": "1970-01-11T10:00:00Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": "test search", + }, + } + `); + }); + + it('uses timerange min and max (string) when index pattern has timefieldName', async () => { + mockJobParams.post = { + timerange: { + timezone: 'Africa/Timbuktu', + min: '1980-01-01T00:00:00Z', + max: '1990-01-01T00:00:00Z', + }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, + }), + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [ + "@test_time", + ], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": "@test_time", + "title": "test search", + }, + "fields": Array [], + "timeFieldName": "@test_time", + "title": "test search", + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [ + "@test_time", + ], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@test_time": Object { + "format": "strict_date_time", + "gte": "1980-01-01T00:00:00Z", + "lte": "1990-01-01T00:00:00Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": "test search", + }, + } + `); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts new file mode 100644 index 0000000000000..5f1954b80e1bc --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; +import { EsQueryConfig } from 'src/plugins/data/server'; +import { + esQuery, + Filter, + IIndexPattern, + Query, +} from '../../../../../../../../src/plugins/data/server'; +import { + DocValueFields, + IndexPatternField, + JobParamsPanelCsv, + QueryFilter, + SavedSearchObjectAttributes, + SearchPanel, + SearchSource, +} from '../../types'; +import { getDataSource } from './get_data_source'; +import { getFilters } from './get_filters'; +import { GenerateCsvParams } from '../../../csv/server/generate_csv'; + +export const getEsQueryConfig = async (config: IUiSettingsClient) => { + const configs = await Promise.all([ + config.get('query:allowLeadingWildcards'), + config.get('query:queryString:options'), + config.get('courier:ignoreFilterIfFieldNotInIndex'), + ]); + const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs; + return { + allowLeadingWildcards, + queryStringOptions, + ignoreFilterIfFieldNotInIndex, + } as EsQueryConfig; +}; + +/* + * Create a CSV Job object for CSV From SavedObject to use as a job parameter + * for generateCsv + */ +export const getGenerateCsvParams = async ( + jobParams: JobParamsPanelCsv, + panel: SearchPanel, + savedObjectsClient: SavedObjectsClientContract, + uiConfig: IUiSettingsClient +): Promise => { + let timerange; + if (jobParams.post?.timerange) { + timerange = jobParams.post?.timerange; + } else { + timerange = panel.timerange; + } + const { indexPatternSavedObjectId } = panel; + const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes; + const { indexPatternSavedObject } = await getDataSource( + savedObjectsClient, + indexPatternSavedObjectId + ); + const esQueryConfig = await getEsQueryConfig(uiConfig); + + const { + kibanaSavedObjectMeta: { + searchSource: { + filter: [searchSourceFilter], + query: searchSourceQuery, + }, + }, + } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } }; + + const { + timeFieldName: indexPatternTimeField, + title: esIndex, + fields: indexPatternFields, + } = indexPatternSavedObject; + + let payloadQuery: QueryFilter | undefined; + let payloadSort: any[] = []; + let docValueFields: DocValueFields[] | undefined; + if (jobParams.post && jobParams.post.state) { + ({ + post: { + state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields }, + }, + } = jobParams); + } + const { includes, combinedFilter } = getFilters( + indexPatternSavedObjectId, + indexPatternTimeField, + timerange, + savedSearchObjectAttr, + searchSourceFilter, + payloadQuery + ); + + const savedSortConfigs = savedSearchObjectAttr.sort; + const sortConfig = [...payloadSort]; + savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => { + sortConfig.push({ [savedSortField]: { order: savedSortOrder } }); + }); + + const scriptFieldsConfig = + indexPatternFields && + indexPatternFields + .filter((f: IndexPatternField) => f.scripted) + .reduce((accum: any, curr: IndexPatternField) => { + return { + ...accum, + [curr.name]: { + script: { + source: curr.script, + lang: curr.lang, + }, + }, + }; + }, {}); + + const searchRequest = { + index: esIndex, + body: { + _source: { includes }, + docvalue_fields: docValueFields, + query: esQuery.buildEsQuery( + indexPatternSavedObject as IIndexPattern, + (searchSourceQuery as unknown) as Query, + (combinedFilter as unknown) as Filter, + esQueryConfig + ), + script_fields: scriptFieldsConfig, + sort: sortConfig, + }, + }; + + return { + jobParams: { browserTimezone: timerange.timezone }, + indexPatternSavedObject, + searchRequest, + fields: includes, + metaFields: [], + conflictedTypesFields: [], + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts index b7e560853e89e..bf915696c8974 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - IndexPatternSavedObject, - SavedObjectReference, - SavedSearchObjectAttributesJSON, - SearchSource, -} from '../../types'; +import { IndexPatternSavedObject } from '../../../csv/types'; +import { SavedObjectReference, SavedSearchObjectAttributesJSON, SearchSource } from '../../types'; export async function getDataSource( savedObjectsClient: any, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts new file mode 100644 index 0000000000000..09c58806de120 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { KibanaRequest } from 'kibana/server'; +import { cryptoFactory, LevelLogger } from '../../../../lib'; +import { ScheduledTaskParams } from '../../../../types'; +import { JobParamsPanelCsv } from '../../types'; + +export const getFakeRequest = async ( + job: ScheduledTaskParams, + encryptionKey: string, + jobLogger: LevelLogger +) => { + // TODO remove this block: csv from savedobject download is always "sync" + const crypto = cryptoFactory(encryptionKey); + let decryptedHeaders: KibanaRequest['headers']; + const serializedEncryptedHeaders = job.headers; + try { + if (typeof serializedEncryptedHeaders !== 'string') { + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage', + { + defaultMessage: 'Job headers are missing', + } + ) + ); + } + decryptedHeaders = (await crypto.decrypt( + serializedEncryptedHeaders + )) as KibanaRequest['headers']; + } catch (err) { + jobLogger.error(err); + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv_from_savedobject.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 }, + } + ) + ); + } + + return { headers: decryptedHeaders } as KibanaRequest; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts index 26631548cc797..1258b03d3051b 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts @@ -22,7 +22,7 @@ export function getFilters( let timezone: string | null; if (indexPatternTimeField) { - if (!timerange || !timerange.min || !timerange.max) { + if (!timerange || timerange.min == null || timerange.max == null) { throw badRequest( `Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]` ); 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 c182fe49a31f6..0d19a24114f06 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 @@ -95,20 +95,6 @@ export interface SavedObject { references: SavedObjectReference[]; } -/* This object is passed to different helpers in different parts of the code - - packages/kbn-es-query/src/es_query/build_es_query - The structure has redundant parts and json-parsed / json-unparsed versions of the same data - */ -export interface IndexPatternSavedObject { - title: string; - timeFieldName: string; - fields: any[]; - attributes: { - fieldFormatMap: string; - fields: string; - }; -} - export interface VisPanel { indexPatternSavedObjectId?: string; savedSearchObjectId?: string; @@ -122,6 +108,11 @@ export interface SearchPanel { timerange: TimeRangeParams; } +export interface DocValueFields { + field: string; + format: string; +} + export interface SearchSourceQuery { isSearchSourceQuery: boolean; } 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 97441bba70984..773295deea954 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 @@ -9,10 +9,10 @@ import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { scheduleTaskFnFactory } from '../export_types/csv_from_savedobject/server/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/server/execute_job'; -import { getJobParamsFromRequest } from '../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { LevelLogger as Logger } from '../lib'; import { TaskRunResult } from '../types'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; +import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; /* diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts similarity index 87% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts rename to x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts index 5aed02c10b961..e5c1f38241349 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -5,7 +5,10 @@ */ import { KibanaRequest } from 'src/core/server'; -import { JobParamsPanelCsv, JobParamsPostPayloadPanelCsv } from '../../types'; +import { + JobParamsPanelCsv, + JobParamsPostPayloadPanelCsv, +} from '../../export_types/csv_from_savedobject/types'; export function getJobParamsFromRequest( request: KibanaRequest, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 96eef81672610..667c1546c6147 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -50,19 +50,19 @@ export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPa export interface TimeRangeParams { timezone: string; - min: Date | string | number | null; - max: Date | string | number | null; + min?: Date | string | number | null; + max?: Date | string | number | null; } export interface JobParamPostPayload { - timerange: TimeRangeParams; + timerange?: TimeRangeParams; } export interface ScheduledTaskParams { headers?: string; // serialized encrypted headers jobParams: JobParamsType; title: string; - type: string | null; + type: string; } export interface JobSource { @@ -80,6 +80,7 @@ export interface TaskRunResult { content_type: string; content: string | null; size: number; + csv_contains_formulas?: boolean; max_size_reached?: boolean; warnings?: string[]; } diff --git a/x-pack/plugins/rollup/public/plugin.ts b/x-pack/plugins/rollup/public/plugin.ts index b55760c5cc5aa..73ee675b089c8 100644 --- a/x-pack/plugins/rollup/public/plugin.ts +++ b/x-pack/plugins/rollup/public/plugin.ts @@ -16,7 +16,7 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IndexManagementPluginSetup } from '../../index_management/public'; import { IndexPatternManagementSetup } from '../../../../src/plugins/index_pattern_management/public'; // @ts-ignore @@ -75,7 +75,7 @@ export class RollupPlugin implements Plugin { }); } - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: 'rollup_jobs', title: i18n.translate('xpack.rollupJobs.appTitle', { defaultMessage: 'Rollup Jobs' }), order: 4, diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 0daab9d5dbce3..064ff5b6a6711 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -9,10 +9,8 @@ "ui": true, "requiredBundles": [ "home", - "management", "kibanaReact", "spaces", - "esUiShared", - "management" + "esUiShared" ] } diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index bac98d5639755..37b97a8472310 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./account_management_page'); -import { AppMount, AppNavLinkStatus, ScopedHistory } from 'src/core/public'; +import { AppMount, AppNavLinkStatus } from 'src/core/public'; import { UserAPIClient } from '../management'; import { accountManagementApp } from './account_management_app'; @@ -54,7 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index add2db6a3c170..0e262e9089842 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./access_agreement_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { accessAgreementApp } from './access_agreement_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -48,7 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index f0c18a3f1408e..15d55136b405d 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./logged_out_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loggedOutApp } from './logged_out_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -46,7 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index b7119d179b0b6..a6e5a321ef6ec 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./login_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loginApp } from './login_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -51,7 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 279500d14f211..46b1083a2ed14 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { logoutApp } from './logout_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -52,7 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 96e72ead22990..0eed1382c270b 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./overwritten_session_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { overwrittenSessionApp } from './overwritten_session_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -53,7 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./overwritten_session_page') diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 5f07b14ee71ef..30c5f8a361b42 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -7,7 +7,6 @@ jest.mock('./api_keys_grid', () => ({ APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { apiKeysManagementApp } from './api_keys_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -37,7 +36,7 @@ describe('apiKeysManagementApp', () => { basePath: '/some-base-path', element: container, setBreadcrumbs, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/management_service.test.ts b/x-pack/plugins/security/public/management/management_service.test.ts index c707206569bf5..ce93fb7c98f41 100644 --- a/x-pack/plugins/security/public/management/management_service.test.ts +++ b/x-pack/plugins/security/public/management/management_service.test.ts @@ -8,8 +8,9 @@ import { BehaviorSubject } from 'rxjs'; import { ManagementApp, ManagementSetup, - ManagementStart, + DefinedSections, } from '../../../../../src/plugins/management/public'; +import { createManagementSectionMock } from '../../../../../src/plugins/management/public/mocks'; import { SecurityLicenseFeatures } from '../../common/licensing/license_features'; import { ManagementService } from './management_service'; import { usersManagementApp } from './users'; @@ -21,7 +22,7 @@ import { rolesManagementApp } from './roles'; import { apiKeysManagementApp } from './api_keys'; import { roleMappingsManagementApp } from './role_mappings'; -const mockSection = { registerApp: jest.fn() }; +const mockSection = createManagementSectionMock(); describe('ManagementService', () => { describe('setup()', () => { @@ -32,8 +33,10 @@ describe('ManagementService', () => { const managementSetup: ManagementSetup = { sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(mockSection), + register: jest.fn(() => mockSection), + section: { + security: mockSection, + } as DefinedSections, }, }; @@ -88,8 +91,10 @@ describe('ManagementService', () => { const managementSetup: ManagementSetup = { sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(mockSection), + register: jest.fn(() => mockSection), + section: { + security: mockSection, + } as DefinedSections, }, }; @@ -116,6 +121,7 @@ describe('ManagementService', () => { }), } as unknown) as jest.Mocked; }; + mockSection.getApp = jest.fn().mockImplementation((id) => mockApps.get(id)); const mockApps = new Map>([ [usersManagementApp.id, getMockedApp()], [rolesManagementApp.id, getMockedApp()], @@ -123,19 +129,7 @@ describe('ManagementService', () => { [roleMappingsManagementApp.id, getMockedApp()], ] as Array<[string, jest.Mocked]>); - const managementStart: ManagementStart = { - sections: { - getSection: jest - .fn() - .mockReturnValue({ getApp: jest.fn().mockImplementation((id) => mockApps.get(id)) }), - getAllSections: jest.fn(), - getSectionsEnabled: jest.fn(), - }, - }; - - service.start({ - management: managementStart, - }); + service.start(); return { mockApps, diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 148d2855ba9b7..199fd917da071 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -9,8 +9,7 @@ import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { ManagementApp, ManagementSetup, - ManagementStart, - ManagementSectionId, + ManagementSection, } from '../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticationServiceSetup } from '../authentication'; @@ -28,30 +27,26 @@ interface SetupParams { getStartServices: StartServicesAccessor; } -interface StartParams { - management: ManagementStart; -} - export class ManagementService { private license!: SecurityLicense; private licenseFeaturesSubscription?: Subscription; + private securitySection?: ManagementSection; setup({ getStartServices, management, authc, license, fatalErrors }: SetupParams) { this.license = license; + this.securitySection = management.sections.section.security; - const securitySection = management.sections.getSection(ManagementSectionId.Security); - - securitySection.registerApp(usersManagementApp.create({ authc, getStartServices })); - securitySection.registerApp( + this.securitySection.registerApp(usersManagementApp.create({ authc, getStartServices })); + this.securitySection.registerApp( rolesManagementApp.create({ fatalErrors, license, getStartServices }) ); - securitySection.registerApp(apiKeysManagementApp.create({ getStartServices })); - securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); + this.securitySection.registerApp(apiKeysManagementApp.create({ getStartServices })); + this.securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); } - start({ management }: StartParams) { + start() { this.licenseFeaturesSubscription = this.license.features$.subscribe(async (features) => { - const securitySection = management.sections.getSection(ManagementSectionId.Security); + const securitySection = this.securitySection!; const securityManagementAppsStatuses: Array<[ManagementApp, boolean]> = [ [securitySection.getApp(usersManagementApp.id)!, features.showLinks], diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index b4e755507f8c5..04dc9c6dfa950 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -12,7 +12,6 @@ import { findTestSubject } from 'test_utils/find_test_subject'; // This is not required for the tests to pass, but it rather suppresses lengthy // warnings in the console which adds unnecessary noise to the test output. import 'test_utils/stub_web_worker'; -import { ScopedHistory } from 'kibana/public'; import { EditRoleMappingPage } from '.'; import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components'; @@ -28,7 +27,7 @@ import { rolesAPIClientMock } from '../../roles/roles_api_client.mock'; import { RoleComboBox } from '../../role_combo_box'; describe('EditRoleMappingPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); let rolesAPI: PublicMethodsOf; beforeEach(() => { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx index fb81ddb641e1f..727d7bf56e9e2 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx @@ -24,7 +24,7 @@ describe('RoleMappingsGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); coreStart = coreMock.createStart(); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index c95d78f90f51a..e65310ba399ea 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_role_mapping', () => ({ EditRoleMappingPage: (props: any) => `Role Mapping Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { roleMappingsManagementApp } from './role_mappings_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -26,7 +25,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; 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 43387d913e6fc..f6fe2f394fd36 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 @@ -8,7 +8,7 @@ import { ReactWrapper } from 'enzyme'; import React from 'react'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { Capabilities, ScopedHistory } from 'src/core/public'; +import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; @@ -187,7 +187,7 @@ function getProps({ docLinks: new DocumentationLinksService(docLinks), fatalErrors, uiCapabilities: buildUICapabilities(canManageSpaces), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }; } diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index d83d5ef3f6468..005eebbfbf3bb 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -16,7 +16,6 @@ import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/publi import { rolesAPIClientMock } from '../index.mock'; import { ReservedBadge, DisabledBadge } from '../../badges'; import { findTestSubject } from 'test_utils/find_test_subject'; -import { ScopedHistory } from 'kibana/public'; const mock403 = () => ({ body: { statusCode: 403 } }); @@ -42,12 +41,12 @@ const waitForRender = async ( describe('', () => { let apiClientMock: jest.Mocked>; - let history: ScopedHistory; + let history: ReturnType; beforeEach(() => { - history = (scopedHistoryMock.create({ - createHref: jest.fn((location) => location.pathname!), - }) as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); + history.createHref.mockImplementation((location) => location.pathname!); + apiClientMock = rolesAPIClientMock.create(); apiClientMock.getRoles.mockResolvedValue([ { diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index e7f38c86b045e..c45528399db99 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -14,8 +14,6 @@ jest.mock('./edit_role', () => ({ EditRolePage: (props: any) => `Role Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; - import { rolesManagementApp } from './roles_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -40,7 +38,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index 7ee33357b9af4..40ffc508f086b 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -5,7 +5,6 @@ */ import { act } from '@testing-library/react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { EditUserPage } from './edit_user_page'; import React from 'react'; @@ -104,7 +103,7 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { } describe('EditUserPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows reserved users to be viewed', async () => { const user = createUser('reserved_user'); diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index edce7409e28d5..df8fe8cee7699 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -22,7 +22,7 @@ describe('UsersGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); history.createHref = (location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 98906f560e6cb..06bd2eff6aa1e 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_user', () => ({ EditUserPage: (props: any) => `User Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { usersManagementApp } from './users_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -31,7 +30,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 7c57c4dd997a2..8cec4fbc2f5a2 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -33,7 +33,9 @@ describe('Security Plugin', () => { coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup< PluginStartDependencies >, - { licensing: licensingMock.createSetup() } + { + licensing: licensingMock.createSetup(), + } ) ).toEqual({ __legacyCompat: { logoutUrl: '/some-base-path/logout', tenant: '/some-base-path' }, @@ -117,7 +119,6 @@ describe('Security Plugin', () => { }); expect(startManagementServiceMock).toHaveBeenCalledTimes(1); - expect(startManagementServiceMock).toHaveBeenCalledWith({ management: managementStartMock }); }); }); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index da69dd051c11d..bef183bd97e8c 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -139,9 +139,8 @@ export class SecurityPlugin public start(core: CoreStart, { management }: PluginStartDependencies) { this.sessionTimeout.start(); this.navControlService.start({ core }); - if (management) { - this.managementService.start({ management }); + this.managementService.start(); } } diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 631a6f9ab213c..5164099f9ff67 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -162,7 +162,10 @@ describe('API Keys', () => { describe('grantAsInternalUser()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); - const result = await apiKeys.grantAsInternalUser(httpServerMock.createKibanaRequest()); + const result = await apiKeys.grantAsInternalUser(httpServerMock.createKibanaRequest(), { + name: 'test_api_key', + role_descriptors: {}, + }); expect(result).toBeNull(); expect(mockClusterClient.callAsInternalUser).not.toHaveBeenCalled(); @@ -174,21 +177,33 @@ describe('API Keys', () => { id: '123', name: 'key-name', api_key: 'abc123', + expires: '1d', }); const result = await apiKeys.grantAsInternalUser( httpServerMock.createKibanaRequest({ headers: { authorization: `Basic ${encodeToBase64('foo:bar')}`, }, - }) + }), + { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + } ); expect(result).toEqual({ api_key: 'abc123', id: '123', name: 'key-name', + expires: '1d', }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { body: { + api_key: { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + }, grant_type: 'password', username: 'foo', password: 'bar', @@ -208,7 +223,12 @@ describe('API Keys', () => { headers: { authorization: `Bearer foo-access-token`, }, - }) + }), + { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + } ); expect(result).toEqual({ api_key: 'abc123', @@ -217,6 +237,11 @@ describe('API Keys', () => { }); expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.grantAPIKey', { body: { + api_key: { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + }, grant_type: 'access_token', access_token: 'foo-access-token', }, @@ -231,7 +256,12 @@ describe('API Keys', () => { headers: { authorization: `Digest username="foo"`, }, - }) + }), + { + name: 'test_api_key', + role_descriptors: { foo: true }, + expiration: '1d', + } ) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Unsupported scheme \\"Digest\\" for granting API Key"` diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 3b6aee72651e2..19922ce3c890d 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -29,6 +29,7 @@ export interface CreateAPIKeyParams { } interface GrantAPIKeyParams { + api_key: CreateAPIKeyParams; grant_type: 'password' | 'access_token'; username?: string; password?: string; @@ -188,7 +189,7 @@ export class APIKeys { * Tries to grant an API key for the current user. * @param request Request instance. */ - async grantAsInternalUser(request: KibanaRequest) { + async grantAsInternalUser(request: KibanaRequest, createParams: CreateAPIKeyParams) { if (!this.license.isEnabled()) { return null; } @@ -200,7 +201,7 @@ export class APIKeys { `Unable to grant an API Key, request does not contain an authorization header` ); } - const params = this.getGrantParams(authorizationHeader); + const params = this.getGrantParams(createParams, authorizationHeader); // User needs `manage_api_key` or `grant_api_key` privilege to use this API let result: GrantAPIKeyResult; @@ -281,9 +282,13 @@ export class APIKeys { return disabledFeature === 'api_keys'; } - private getGrantParams(authorizationHeader: HTTPAuthorizationHeader): GrantAPIKeyParams { + private getGrantParams( + createParams: CreateAPIKeyParams, + authorizationHeader: HTTPAuthorizationHeader + ): GrantAPIKeyParams { if (authorizationHeader.scheme.toLowerCase() === 'bearer') { return { + api_key: createParams, grant_type: 'access_token', access_token: authorizationHeader.credentials, }; @@ -294,6 +299,7 @@ export class APIKeys { authorizationHeader.credentials ); return { + api_key: createParams, grant_type: 'password', username: basicCredentials.username, password: basicCredentials.password, diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 56d44e6628a87..a125d9a62afb7 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -374,7 +374,10 @@ describe('setupAuthentication()', () => { }); describe('grantAPIKeyAsInternalUser()', () => { - let grantAPIKeyAsInternalUser: (request: KibanaRequest) => Promise; + let grantAPIKeyAsInternalUser: ( + request: KibanaRequest, + params: CreateAPIKeyParams + ) => Promise; beforeEach(async () => { grantAPIKeyAsInternalUser = (await setupAuthentication(mockSetupAuthenticationParams)) .grantAPIKeyAsInternalUser; @@ -384,10 +387,13 @@ describe('setupAuthentication()', () => { const request = httpServerMock.createKibanaRequest(); const apiKeysInstance = jest.requireMock('./api_keys').APIKeys.mock.instances[0]; apiKeysInstance.grantAsInternalUser.mockResolvedValueOnce({ api_key: 'foo' }); - await expect(grantAPIKeyAsInternalUser(request)).resolves.toEqual({ + + const createParams = { name: 'test_key', role_descriptors: {} }; + + await expect(grantAPIKeyAsInternalUser(request, createParams)).resolves.toEqual({ api_key: 'foo', }); - expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request); + expect(apiKeysInstance.grantAsInternalUser).toHaveBeenCalledWith(request, createParams); }); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 659a378388a13..ed631e221b7a3 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -187,7 +187,8 @@ export async function setupAuthentication({ areAPIKeysEnabled: () => apiKeys.areAPIKeysEnabled(), createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), - grantAPIKeyAsInternalUser: (request: KibanaRequest) => apiKeys.grantAsInternalUser(request), + grantAPIKeyAsInternalUser: (request: KibanaRequest, params: CreateAPIKeyParams) => + apiKeys.grantAsInternalUser(request, params), invalidateAPIKey: (request: KibanaRequest, params: InvalidateAPIKeyParams) => apiKeys.invalidate(request, params), invalidateAPIKeyAsInternalUser: (params: InvalidateAPIKeyParams) => diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 4ab00b511b48b..5e38045b88c74 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -43,17 +43,6 @@ describe('#checkSavedObjectsPrivileges', () => { describe('when checking multiple namespaces', () => { const namespaces = [namespace1, namespace2]; - test(`throws an error when Spaces is disabled`, async () => { - mockSpacesService = undefined; - const checkSavedObjectsPrivileges = createFactory(); - - await expect( - checkSavedObjectsPrivileges(actions, namespaces) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"` - ); - }); - test(`throws an error when using an empty namespaces array`, async () => { const checkSavedObjectsPrivileges = createFactory(); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index d9b070c72f946..0c2260542bf72 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -29,21 +29,21 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( namespaceOrNamespaces?: string | string[] ) { const spacesService = getSpacesService(); - if (Array.isArray(namespaceOrNamespaces)) { - if (spacesService === undefined) { - throw new Error( - `Can't check saved object privileges for multiple namespaces if Spaces is disabled` - ); - } else if (!namespaceOrNamespaces.length) { + if (!spacesService) { + // Spaces disabled, authorizing globally + return await checkPrivilegesWithRequest(request).globally(actions); + } else if (Array.isArray(namespaceOrNamespaces)) { + // Spaces enabled, authorizing against multiple spaces + if (!namespaceOrNamespaces.length) { throw new Error(`Can't check saved object privileges for 0 namespaces`); } const spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)); return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); - } else if (spacesService) { + } else { + // Spaces enabled, authorizing against a single space const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); } - return await checkPrivilegesWithRequest(request).globally(actions); }; }; }; 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 c646cd95228f0..1cf879adc5415 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 @@ -27,6 +27,7 @@ const createSecureSavedObjectsClientWrapperOptions = () => { const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), decorateGeneralError: jest.fn().mockReturnValue(generalError), + createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)), isNotFoundError: jest.fn().mockReturnValue(false), } as unknown) as jest.Mocked; const getSpacesService = jest.fn().mockReturnValue(true); @@ -73,7 +74,9 @@ const expectForbiddenError = async (fn: Function, args: Record) => SavedObjectActions['get'] >).mock.calls; const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0]; - const spaceId = args.options?.namespace || 'default'; + const spaceId = args.options?.namespaces + ? args.options?.namespaces[0] + : args.options?.namespace || 'default'; const ACTION = getCalls[0][1]; const types = getCalls.map((x) => x[0]); @@ -100,7 +103,7 @@ const expectSuccess = async (fn: Function, args: Record) => { >).mock.calls; const ACTION = getCalls[0][1]; const types = getCalls.map((x) => x[0]); - const spaceIds = [args.options?.namespace || 'default']; + const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default']; expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); @@ -128,7 +131,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record) => expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( actions, - args.options?.namespace + args.options?.namespace ?? args.options?.namespaces ); }; @@ -344,7 +347,7 @@ describe('#addToNamespaces', () => { ); }); - test(`checks privileges for user, actions, and namespace`, async () => { + test(`checks privileges for user, actions, and namespaces`, async () => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( getMockCheckPrivilegesSuccess // create ); @@ -539,12 +542,12 @@ describe('#find', () => { }); test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); await expectForbiddenError(client.find, { options }); }); test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectForbiddenError(client.find, { options }); }); @@ -552,18 +555,34 @@ describe('#find', () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); const result = await expectSuccess(client.find, { options }); expect(result).toEqual(apiCallReturnValue); }); - test(`checks privileges for user, actions, and namespace`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + test(`throws BadRequestError when searching across namespaces when spaces is disabled`, async () => { + clientOpts = createSecureSavedObjectsClientWrapperOptions(); + clientOpts.getSpacesService.mockReturnValue(undefined); + client = new SecureSavedObjectsClientWrapper(clientOpts); + + // succeed privilege checks by default + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesSuccess + ); + + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( + `"_find across namespaces is not permitted when the Spaces plugin is disabled."` + ); + }); + + test(`checks privileges for user, actions, and namespaces`, async () => { + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectPrivilegeCheck(client.find, { options }); }); test(`filters namespaces that the user doesn't have access to`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectObjectsNamespaceFiltering(client.find, { options }); }); }); 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 969344afae5e3..621299a0f025e 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 @@ -99,7 +99,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async find(options: SavedObjectsFindOptions) { - await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); + if ( + this.getSpacesService() == null && + Array.isArray(options.namespaces) && + options.namespaces.length > 0 + ) { + throw this.errors.createBadRequestError( + `_find across namespaces is not permitted when the Spaces plugin is disabled.` + ); + } + await this.ensureAuthorized(options.type, 'find', options.namespaces, { options }); const response = await this.baseClient.find(options); return await this.redactSavedObjectsNamespaces(response); @@ -293,7 +302,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async redactSavedObjectNamespaces( savedObject: T ): Promise { - if (this.getSpacesService() === undefined || savedObject.namespaces == null) { + if ( + this.getSpacesService() === undefined || + savedObject.namespaces == null || + savedObject.namespaces.length === 0 + ) { return savedObject; } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4e9514feec74f..e5dd109007eab 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -42,7 +42,7 @@ export enum SecurityPageName { network = 'network', timelines = 'timelines', case = 'case', - management = 'management', + administration = 'administration', } export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`; @@ -117,6 +117,7 @@ export const TIMELINE_URL = '/api/timeline'; export const TIMELINE_DRAFT_URL = `${TIMELINE_URL}/_draft`; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import`; +export const TIMELINE_PREPACKAGED_URL = `${TIMELINE_URL}/_prepackaged`; /** * Default signals index key for kibana.dev.yml diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index ed0344207d18f..26a219507c3ae 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -22,10 +22,82 @@ import { EntryMatch, EntryMatchAny, EntriesArray, + Operator, } from '../../../lists/common/schemas'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('build_exceptions_query', () => { + let exclude: boolean; + const makeMatchEntry = ({ + field, + value = 'value-1', + operator = 'included', + }: { + field: string; + value?: string; + operator?: Operator; + }): EntryMatch => { + return { + field, + operator, + type: 'match', + value, + }; + }; + const makeMatchAnyEntry = ({ + field, + operator = 'included', + value = ['value-1', 'value-2'], + }: { + field: string; + operator?: Operator; + value?: string[]; + }): EntryMatchAny => { + return { + field, + operator, + value, + type: 'match_any', + }; + }; + const makeExistsEntry = ({ + field, + operator = 'included', + }: { + field: string; + operator?: Operator; + }): EntryExists => { + return { + field, + operator, + type: 'exists', + }; + }; + const matchEntryWithIncluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + }); + const matchEntryWithExcluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + operator: 'excluded', + }); + const matchAnyEntryWithIncludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + }); + const existsEntryWithIncluded: EntryExists = makeExistsEntry({ + field: 'host.name', + }); + const existsEntryWithExcluded: EntryExists = makeExistsEntry({ + field: 'host.name', + operator: 'excluded', + }); + + beforeEach(() => { + exclude = true; + }); + describe('getLanguageBooleanOperator', () => { test('it returns value as uppercase if language is "lucene"', () => { const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' }); @@ -41,239 +113,376 @@ describe('build_exceptions_query', () => { }); describe('operatorBuilder', () => { - describe('kuery', () => { - test('it returns "not " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); - - expect(operator).toEqual('not '); + describe("when 'exclude' is true", () => { + describe('and langauge is kuery', () => { + test('it returns "not " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); - - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns "NOT " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); }); }); - - describe('lucene', () => { - test('it returns "NOT " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); - - expect(operator).toEqual('NOT '); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); + describe('and language is kuery', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "not " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + }); - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "NOT " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); }); }); }); describe('buildExists', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); }); - - expect(query).toEqual('host.name:*'); }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); }); - - expect(query).toEqual('not host.name:*'); }); }); - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'lucene', - }); - - expect(query).toEqual('_exists_host.name'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); }); + }); - expect(query).toEqual('NOT _exists_host.name'); + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); }); }); }); describe('buildMatch', () => { - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('not host.name:suricata'); }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('host.name:suricata'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', - }); - - expect(query).toEqual('NOT host.name:suricata'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + }); - expect(query).toEqual('host.name:suricata'); + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); }); }); }); describe('buildMatchAny', () => { - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: [], - type: 'match_any', - }, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual(''); - }); + const entryWithIncludedAndNoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: [], + }); + const entryWithIncludedAndOneValue: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata'], + }); + const entryWithExcludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + operator: 'excluded', + }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); }); - - expect(exceptionSegment).toEqual('not host.name:(suricata)'); - }); - - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'lucene', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); }); }); @@ -284,18 +493,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -303,23 +505,13 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 and nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 and nestedFieldB:value-2 }'); }); }); @@ -329,18 +521,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -348,129 +533,157 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 AND nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 AND nestedFieldB:value-2 }'); }); }); }); describe('evaluateValues', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:*'); }); - - expect(result).toEqual('not host.name:*'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:(suricata or auditd)'); }); - - expect(result).toEqual('not host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - - const result = evaluateValues({ - item: list, - language: 'kuery', + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + }); }); - - expect(result).toEqual('not host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; + }); + describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: existsEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT _exists_host.name'); + expect(result).toEqual('host.name:*'); }); - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT host.name:suricata'); + expect(result).toEqual('host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, }); + expect(result).toEqual('host.name:(suricata or auditd)'); + }); + }); - expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('_exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:(suricata OR auditd)'); + }); }); }); }); }); describe('formatQuery', () => { + describe('when query is empty string', () => { + test('it returns query if "exceptions" is empty array', () => { + const formattedQuery = formatQuery({ exceptions: [], query: '', language: 'kuery' }); + expect(formattedQuery).toEqual(''); + }); + test('it returns expected query string when single exception in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:(value-1 or value-2) and not c:*'], + query: '', + language: 'kuery', + }); + expect(formattedQuery).toEqual('(b:(value-1 or value-2) and not c:*)'); + }); + }); + test('it returns query if "exceptions" is empty array', () => { const formattedQuery = formatQuery({ exceptions: [], query: 'a:*', language: 'kuery' }); - expect(formattedQuery).toEqual('a:*'); }); @@ -480,7 +693,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual('(a:* and b:(value-1 or value-2) and not c:*)'); }); @@ -490,7 +702,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual( '(a:* and b:(value-1 or value-2) and not c:*) or (a:* and not d:*)' ); @@ -502,6 +713,7 @@ describe('build_exceptions_query', () => { const query = buildExceptionItemEntries({ language: 'kuery', lists: [], + exclude, }); expect(query).toEqual(''); @@ -511,22 +723,13 @@ describe('build_exceptions_query', () => { // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) // https://www.dcode.fr/boolean-expressions-calculator const payload: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists: payload, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and c:value-3'; @@ -537,28 +740,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; @@ -569,33 +763,20 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'd', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*'; @@ -606,72 +787,151 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'e', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), ]; const query = buildExceptionItemEntries({ language: 'lucene', lists, + exclude, }); const expectedQuery = 'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e'; expect(query).toEqual(expectedQuery); }); - describe('exists', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns empty string if empty lists array passed in', () => { + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: [], + exclude, + }); + + expect(query).toEqual(''); + }); + test('it returns expected query when more than one item in list', () => { + // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) + // https://www.dcode.fr/boolean-expressions-calculator + const payload: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), + ]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: payload, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and not c:value-3'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list item includes nested value', () => { + // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'included', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:*'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) + test('it returns expected query when list includes multiple items and nested "and" values', () => { + // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'excluded', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 } and d:*'; + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when language is "lucene"', () => { + // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], + }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), + ]; + const query = buildExceptionItemEntries({ + language: 'lucene', + lists, + exclude, + }); + const expectedQuery = + 'b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND NOT _exists_e'; + expect(query).toEqual(expectedQuery); + }); + }); + + describe('exists', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { + // Equal to query && !(b) -> (query AND NOT b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, + }); + const expectedQuery = 'not b:*'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list includes single list item with operator of "excluded"', () => { + // Equal to query && !(!b) -> (query AND b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, }); const expectedQuery = 'b:*'; @@ -682,27 +942,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:* and parent:{ c:value-1 }'; @@ -713,38 +963,21 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - { - field: 'd', - operator: 'included', - type: 'match', - value: 'value-2', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' }), + makeMatchEntry({ field: 'd', value: 'value-2' }), ], }, - { - field: 'e', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'e' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*'; @@ -756,17 +989,11 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, - ]; + const lists: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:value'; @@ -777,16 +1004,12 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value'; @@ -797,28 +1020,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value and parent:{ c:valueC }'; @@ -829,42 +1041,23 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', value: 'value' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'e', value: 'valueE' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueC } and not e:valueC'; + const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueD } and not e:valueE'; expect(query).toEqual(expectedQuery); }); @@ -874,19 +1067,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1)'; + const expectedQuery = 'not b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -894,19 +1081,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1)'; + const expectedQuery = 'b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -915,30 +1096,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || c) -> (query AND b AND NOT c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1) and parent:{ c:valueC }'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ c:valueC }'; expect(query).toEqual(expectedQuery); }); @@ -947,24 +1117,15 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchAnyEntry({ field: 'c' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1) and not e:(valueE or value-4)'; + const expectedQuery = 'not b:(value-1 or value-2) and not c:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -985,36 +1146,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1022,7 +1163,7 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value or value-1) and parent:{ c:valueC and d:valueD } and not e:(valueE or value-4))'; + '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and not e:(value-1 or value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); @@ -1033,36 +1174,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1070,9 +1191,85 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value OR value-1) AND parent:{ c:valueC AND d:valueD } AND NOT e:(valueE OR value-4))'; + '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND NOT e:(value-1 OR value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); + + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns original query if lists is empty array', () => { + const query = buildQueryExceptions({ + query: 'host.name: *', + language: 'kuery', + lists: [], + exclude, + }); + const expectedQuery = 'host.name: *'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "kuery"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'kuery', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* and some.parentField:{ nested.field:some value } and some.not.nested.field:some value) or (a:* and b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and e:(value-1 or value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "lucene"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'lucene', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* AND some.parentField:{ nested.field:some value } AND some.not.nested.field:some value) OR (a:* AND b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND e:(value-1 OR value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index a69ee809987f7..a70e6a6638589 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -17,7 +17,8 @@ import { entriesMatch, entriesNested, ExceptionListItemSchema, -} from '../../../lists/common/schemas'; + CreateExceptionListItemSchema, +} from '../shared_imports'; import { Language, Query } from './schemas/common/schemas'; type Operators = 'and' | 'or' | 'not'; @@ -45,32 +46,35 @@ export const getLanguageBooleanOperator = ({ export const operatorBuilder = ({ operator, language, + exclude, }: { operator: Operator; language: Language; + exclude: boolean; }): string => { const not = getLanguageBooleanOperator({ language, value: 'not', }); - switch (operator) { - case 'included': - return `${not} `; - default: - return ''; + if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) { + return `${not} `; + } else { + return ''; } }; export const buildExists = ({ item, language, + exclude, }: { item: EntryExists; language: Language; + exclude: boolean; }): string => { const { operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); switch (language) { case 'kuery': @@ -85,12 +89,14 @@ export const buildExists = ({ export const buildMatch = ({ item, language, + exclude, }: { item: EntryMatch; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); return `${exceptionOperator}${field}:${value}`; }; @@ -98,9 +104,11 @@ export const buildMatch = ({ export const buildMatchAny = ({ item, language, + exclude, }: { item: EntryMatchAny; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; @@ -109,7 +117,7 @@ export const buildMatchAny = ({ return ''; default: const or = getLanguageBooleanOperator({ language, value: 'or' }); - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); const matchAnyValues = value.map((v) => v); return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; @@ -133,16 +141,18 @@ export const buildNested = ({ export const evaluateValues = ({ item, language, + exclude, }: { item: Entry | EntryNested; language: Language; + exclude: boolean; }): string => { if (entriesExists.is(item)) { - return buildExists({ item, language }); + return buildExists({ item, language, exclude }); } else if (entriesMatch.is(item)) { - return buildMatch({ item, language }); + return buildMatch({ item, language, exclude }); } else if (entriesMatchAny.is(item)) { - return buildMatchAny({ item, language }); + return buildMatchAny({ item, language, exclude }); } else if (entriesNested.is(item)) { return buildNested({ item, language }); } else { @@ -163,7 +173,11 @@ export const formatQuery = ({ const or = getLanguageBooleanOperator({ language, value: 'or' }); const and = getLanguageBooleanOperator({ language, value: 'and' }); const formattedExceptions = exceptions.map((exception) => { - return `(${query} ${and} ${exception})`; + if (query === '') { + return `(${exception})`; + } else { + return `(${query} ${and} ${exception})`; + } }); return formattedExceptions.join(` ${or} `); @@ -175,15 +189,17 @@ export const formatQuery = ({ export const buildExceptionItemEntries = ({ lists, language, + exclude, }: { lists: EntriesArray; language: Language; + exclude: boolean; }): string => { const and = getLanguageBooleanOperator({ language, value: 'and' }); const exceptionItem = lists .filter(({ type }) => type !== 'list') .reduce((accum, listItem) => { - const exceptionSegment = evaluateValues({ item: listItem, language }); + const exceptionSegment = evaluateValues({ item: listItem, language, exclude }); return [...accum, exceptionSegment]; }, []); @@ -194,15 +210,22 @@ export const buildQueryExceptions = ({ query, language, lists, + exclude = true, }: { query: Query; language: Language; - lists: ExceptionListItemSchema[] | undefined; + lists: Array | undefined; + exclude?: boolean; }): DataQuery[] => { if (lists != null) { - const exceptions = lists.map((exceptionItem) => - buildExceptionItemEntries({ lists: exceptionItem.entries, language }) - ); + const exceptions = lists.reduce((acc, exceptionItem) => { + return [ + ...acc, + ...(exceptionItem.entries !== undefined + ? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })] + : []), + ]; + }, []); const formattedQuery = formatQuery({ exceptions, language, query }); return [ { diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 6edd2489e90c9..c19ef45605f83 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -456,6 +456,96 @@ describe('get_filter', () => { }); }); + describe('when "excludeExceptions" is false', () => { + test('it should work with a list', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock()], + false + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + + test('it should work with an empty list', () => { + const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], [], false); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + }); + test('it should work with a nested object queries', () => { const esQuery = getQueryFilter( 'category:{ name:Frank and trusted:true }', diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index ef390c3b44939..6584373b806d8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -11,7 +11,10 @@ import { buildEsQuery, Query as DataQuery, } from '../../../../../src/plugins/data/common'; -import { ExceptionListItemSchema } from '../../../lists/common/schemas'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, +} from '../../../lists/common/schemas'; import { buildQueryExceptions } from './build_exceptions_query'; import { Query, Language, Index } from './schemas/common/schemas'; @@ -20,14 +23,20 @@ export const getQueryFilter = ( language: Language, filters: Array>, index: Index, - lists: ExceptionListItemSchema[] + lists: Array, + excludeExceptions: boolean = true ) => { const indexPattern: IIndexPattern = { fields: [], title: index.join(), }; - const queries: DataQuery[] = buildQueryExceptions({ query, language, lists }); + const queries: DataQuery[] = buildQueryExceptions({ + query, + language, + lists, + exclude: excludeExceptions, + }); const config = { allowLeadingWildcards: true, 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 6e43bd645fd7b..542cbe8916032 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 @@ -275,7 +275,12 @@ export type To = t.TypeOf; export const toOrUndefined = t.union([to, t.undefined]); export type ToOrUndefined = t.TypeOf; -export const type = t.keyof({ machine_learning: null, query: null, saved_query: null }); +export const type = t.keyof({ + machine_learning: null, + query: null, + saved_query: null, + threshold: null, +}); export type Type = t.TypeOf; export const typeOrUndefined = t.union([type, t.undefined]); @@ -369,6 +374,17 @@ export type Threat = t.TypeOf; export const threatOrUndefined = t.union([threat, t.undefined]); export type ThreatOrUndefined = t.TypeOf; +export const threshold = t.exact( + t.type({ + field: t.string, + value: PositiveIntegerGreaterThanZero, + }) +); +export type Threshold = t.TypeOf; + +export const thresholdOrUndefined = t.union([threshold, t.undefined]); +export type ThresholdOrUndefined = t.TypeOf; + export const created_at = IsoDateString; export const updated_at = IsoDateString; export const updated_by = t.string; @@ -407,6 +423,11 @@ export const rules_custom_installed = PositiveInteger; export const rules_not_installed = PositiveInteger; export const rules_not_updated = PositiveInteger; +export const timelines_installed = PositiveInteger; +export const timelines_updated = PositiveInteger; +export const timelines_not_installed = PositiveInteger; +export const timelines_not_updated = PositiveInteger; + export const note = t.string; export type Note = t.TypeOf; 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 bf96be5e688fa..aebc3361f6e49 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 @@ -25,6 +25,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, References, @@ -111,6 +112,7 @@ export const addPrepackagedRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts index 793d4b04ed0e5..f844d0e86e1f9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts @@ -8,7 +8,7 @@ import { AddPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { addPrepackagedRuleValidateTypeDependents } from './add_prepackaged_rules_type_dependents'; import { getAddPrepackagedRulesSchemaMock } from './add_prepackaged_rules_schema.mock'; -describe('create_rules_type_dependents', () => { +describe('add_prepackaged_rules_type_dependents', () => { test('saved_id is required when type is saved_query and will not validate without out', () => { const schema: AddPrepackagedRulesSchema = { ...getAddPrepackagedRulesSchemaMock(), @@ -68,4 +68,26 @@ describe('create_rules_type_dependents', () => { const errors = addPrepackagedRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts index 2788c331154d2..6a51f724fc9e6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: AddPrepackagedRulesSchema): string[] return []; }; +export const validateThreshold = (rule: AddPrepackagedRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const addPrepackagedRuleValidateTypeDependents = ( schema: AddPrepackagedRulesSchema ): string[] => { @@ -103,5 +116,6 @@ export const addPrepackagedRuleValidateTypeDependents = ( ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; 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 0debe01e5a4d7..308b3c24010fb 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 @@ -28,6 +28,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, Version, @@ -106,6 +107,7 @@ export const createRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings 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 ebf0b2e591ca9..43f0901912271 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 @@ -65,4 +65,26 @@ describe('create_rules_type_dependents', () => { const errors = createRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: CreateRulesSchema = { + ...getCreateRulesSchemaMock(), + type: 'threshold', + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: CreateRulesSchema = { + ...getCreateRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); 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 aad2a2c4a9206..af665ff8c81d2 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 @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: CreateRulesSchema): string[] => { return []; }; +export const validateThreshold = (rule: CreateRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): string[] => { return [ ...validateAnomalyThreshold(schema), @@ -101,5 +114,6 @@ export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; 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 f61a1546e3e8a..d141ca56828b6 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 @@ -27,6 +27,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, Version, @@ -125,6 +126,7 @@ export const importRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts index f9b989c81e533..4b047ee6b7198 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts @@ -65,4 +65,26 @@ describe('import_rules_type_dependents', () => { const errors = importRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: ImportRulesSchema = { + ...getImportRulesSchemaMock(), + type: 'threshold', + }; + const errors = importRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: ImportRulesSchema = { + ...getImportRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = importRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts index 59191a4fe3121..269181449e9e9 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: ImportRulesSchema): string[] => { return []; }; +export const validateThreshold = (rule: ImportRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const importRuleValidateTypeDependents = (schema: ImportRulesSchema): string[] => { return [ ...validateAnomalyThreshold(schema), @@ -101,5 +114,6 @@ export const importRuleValidateTypeDependents = (schema: ImportRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 070f3ccfd03b0..dd325c1a5034f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -33,6 +33,7 @@ import { enabled, tags, threat, + threshold, throttle, references, to, @@ -89,6 +90,7 @@ export const patchRulesSchema = t.exact( tags, to, threat, + threshold, throttle, timestamp_override, references, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts similarity index 79% rename from x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts rename to x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts index a388e69332072..bafaf6f9e2203 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts @@ -78,4 +78,26 @@ describe('patch_rules_type_dependents', () => { const errors = patchRuleValidateTypeDependents(schema); expect(errors).toEqual(['either "id" or "rule_id" must be set']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts index 554cdb822762f..a229771a7c05c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts @@ -66,6 +66,19 @@ export const validateId = (rule: PatchRulesSchema): string[] => { } }; +export const validateThreshold = (rule: PatchRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const patchRuleValidateTypeDependents = (schema: PatchRulesSchema): string[] => { return [ ...validateId(schema), @@ -73,5 +86,6 @@ export const patchRuleValidateTypeDependents = (schema: PatchRulesSchema): strin ...validateLanguage(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts index 98082c2de838a..4f284eedef3fd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts @@ -28,6 +28,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, version, @@ -114,6 +115,7 @@ export const updateRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts index a63c8243cb5f1..91b11ea758e93 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts @@ -85,4 +85,26 @@ describe('update_rules_type_dependents', () => { const errors = updateRuleValidateTypeDependents(schema); expect(errors).toEqual(['either "id" or "rule_id" must be set']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: UpdateRulesSchema = { + ...getUpdateRulesSchemaMock(), + type: 'threshold', + }; + const errors = updateRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: UpdateRulesSchema = { + ...getUpdateRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = updateRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts index 9204f727b2660..44182d250c801 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts @@ -102,6 +102,19 @@ export const validateId = (rule: UpdateRulesSchema): string[] => { } }; +export const validateThreshold = (rule: UpdateRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const updateRuleValidateTypeDependents = (schema: UpdateRulesSchema): string[] => { return [ ...validateId(schema), @@ -112,5 +125,6 @@ export const updateRuleValidateTypeDependents = (schema: UpdateRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts index fc3f89996daf1..61d3ede852ee1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts @@ -6,14 +6,22 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; -import { PrePackagedRulesSchema, prePackagedRulesSchema } from './prepackaged_rules_schema'; +import { + PrePackagedRulesAndTimelinesSchema, + prePackagedRulesAndTimelinesSchema, +} from './prepackaged_rules_schema'; import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; describe('prepackaged_rules_schema', () => { test('it should validate an empty prepackaged response with defaults', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -22,12 +30,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should not validate an extra invalid field added', () => { - const payload: PrePackagedRulesSchema & { invalid_field: string } = { + const payload: PrePackagedRulesAndTimelinesSchema & { invalid_field: string } = { rules_installed: 0, rules_updated: 0, invalid_field: 'invalid', + timelines_installed: 0, + timelines_updated: 0, }; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -36,8 +46,13 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_installed" number', () => { - const payload: PrePackagedRulesSchema = { rules_installed: -1, rules_updated: 0 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: -1, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -48,8 +63,13 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_updated"', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: -1 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: -1, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -60,9 +80,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_installed" is not there', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; delete payload.rules_installed; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -73,9 +98,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_updated" is not there', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; delete payload.rules_updated; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts index 3b0107c91fee0..73d144500e003 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts @@ -7,14 +7,28 @@ import * as t from 'io-ts'; /* eslint-disable @typescript-eslint/camelcase */ -import { rules_installed, rules_updated } from '../common/schemas'; +import { + rules_installed, + rules_updated, + timelines_installed, + timelines_updated, +} from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -export const prePackagedRulesSchema = t.exact( - t.type({ - rules_installed, - rules_updated, - }) +const prePackagedRulesSchema = t.type({ + rules_installed, + rules_updated, +}); + +const prePackagedTimelinesSchema = t.type({ + timelines_installed, + timelines_updated, +}); + +export const prePackagedRulesAndTimelinesSchema = t.exact( + t.intersection([prePackagedRulesSchema, prePackagedTimelinesSchema]) ); -export type PrePackagedRulesSchema = t.TypeOf; +export type PrePackagedRulesAndTimelinesSchema = t.TypeOf< + typeof prePackagedRulesAndTimelinesSchema +>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts index eeae72209829e..09cb7148fe90a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts @@ -7,21 +7,24 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; import { - PrePackagedRulesStatusSchema, - prePackagedRulesStatusSchema, + PrePackagedRulesAndTimelinesStatusSchema, + prePackagedRulesAndTimelinesStatusSchema, } from './prepackaged_rules_status_schema'; import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; describe('prepackaged_rules_schema', () => { test('it should validate an empty prepackaged response with defaults', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -30,14 +33,17 @@ describe('prepackaged_rules_schema', () => { }); test('it should not validate an extra invalid field added', () => { - const payload: PrePackagedRulesStatusSchema & { invalid_field: string } = { + const payload: PrePackagedRulesAndTimelinesStatusSchema & { invalid_field: string } = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, invalid_field: 'invalid', + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -46,13 +52,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_installed" number', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: -1, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -63,13 +72,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_not_installed"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: -1, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -80,13 +92,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_not_updated"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: -1, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -97,13 +112,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_custom_installed"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: -1, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -114,14 +132,17 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_installed" is not there', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; delete payload.rules_installed; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts index ee8e7b48a58bc..aabdbdd7300f4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts @@ -12,16 +12,29 @@ import { rules_custom_installed, rules_not_installed, rules_not_updated, + timelines_installed, + timelines_not_installed, + timelines_not_updated, } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -export const prePackagedRulesStatusSchema = t.exact( - t.type({ - rules_custom_installed, - rules_installed, - rules_not_installed, - rules_not_updated, - }) +export const prePackagedTimelinesStatusSchema = t.type({ + timelines_installed, + timelines_not_installed, + timelines_not_updated, +}); + +const prePackagedRulesStatusSchema = t.type({ + rules_custom_installed, + rules_installed, + rules_not_installed, + rules_not_updated, +}); + +export const prePackagedRulesAndTimelinesStatusSchema = t.exact( + t.intersection([prePackagedRulesStatusSchema, prePackagedTimelinesStatusSchema]) ); -export type PrePackagedRulesStatusSchema = t.TypeOf; +export type PrePackagedRulesAndTimelinesStatusSchema = t.TypeOf< + typeof prePackagedRulesAndTimelinesStatusSchema +>; 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 c0fec2b2eefc2..4bd18a13e4ebb 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 @@ -44,6 +44,7 @@ import { timeline_title, type, threat, + threshold, throttle, job_status, status_date, @@ -123,6 +124,9 @@ export const dependentRulesSchema = t.partial({ // ML fields anomaly_threshold, machine_learning_job_id, + + // Threshold fields + threshold, }); /** @@ -202,7 +206,7 @@ export const addTimelineTitle = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mi }; export const addQueryFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { - if (typeAndTimelineOnly.type === 'query' || typeAndTimelineOnly.type === 'saved_query') { + if (['query', 'saved_query', 'threshold'].includes(typeAndTimelineOnly.type)) { return [ t.exact(t.type({ query: dependentRulesSchema.props.query })), t.exact(t.type({ language: dependentRulesSchema.props.language })), @@ -225,6 +229,17 @@ export const addMlFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] } }; +export const addThresholdFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { + if (typeAndTimelineOnly.type === 'threshold') { + return [ + t.exact(t.type({ threshold: dependentRulesSchema.props.threshold })), + 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), @@ -233,6 +248,7 @@ export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed ...addTimelineTitle(typeAndTimelineOnly), ...addQueryFields(typeAndTimelineOnly), ...addMlFields(typeAndTimelineOnly), + ...addThresholdFields(typeAndTimelineOnly), ]; if (dependents.length > 1) { diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts index cadc32a37a05d..e5aaee6d3ec74 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts @@ -6,7 +6,7 @@ import * as t from 'io-ts'; -import { exceptionListType, namespaceType } from '../../lists_common_deps'; +import { exceptionListType, namespaceType } from '../../../shared_imports'; export const list = t.exact( t.type({ diff --git a/x-pack/plugins/security_solution/common/detection_engine/types.ts b/x-pack/plugins/security_solution/common/detection_engine/types.ts index 431d716a9f205..7c752bca49dbd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/types.ts @@ -15,5 +15,6 @@ export const RuleTypeSchema = t.keyof({ query: null, saved_query: null, machine_learning: null, + threshold: null, }); export type RuleType = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index f64462f71a87b..fcea86be4ae9e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -120,7 +120,7 @@ describe('data generator', () => { it('creates all events with an empty ancestry array', () => { for (const event of tree.allEvents) { - expect(event.process.Ext.ancestry.length).toEqual(0); + expect(event.process.Ext!.ancestry!.length).toEqual(0); } }); }); @@ -188,24 +188,24 @@ describe('data generator', () => { }; const verifyAncestry = (event: Event, genTree: Tree) => { - if (event.process.Ext.ancestry!.length > 0) { - expect(event.process.parent?.entity_id).toBe(event.process.Ext.ancestry![0]); + if (event.process.Ext!.ancestry!.length > 0) { + expect(event.process.parent?.entity_id).toBe(event.process.Ext!.ancestry![0]); } - for (let i = 0; i < event.process.Ext.ancestry!.length; i++) { - const ancestor = event.process.Ext.ancestry![i]; + for (let i = 0; i < event.process.Ext!.ancestry!.length; i++) { + const ancestor = event.process.Ext!.ancestry![i]; const parent = genTree.children.get(ancestor) || genTree.ancestry.get(ancestor); expect(ancestor).toBe(parent?.lifecycle[0].process.entity_id); // the next ancestor should be the grandparent - if (i + 1 < event.process.Ext.ancestry!.length) { - const grandparent = event.process.Ext.ancestry![i + 1]; + if (i + 1 < event.process.Ext!.ancestry!.length) { + const grandparent = event.process.Ext!.ancestry![i + 1]; expect(grandparent).toBe(parent?.lifecycle[0].process.parent?.entity_id); } } }; it('has ancestry array defined', () => { - expect(tree.origin.lifecycle[0].process.Ext.ancestry!.length).toBe(ANCESTRY_LIMIT); + expect(tree.origin.lifecycle[0].process.Ext!.ancestry!.length).toBe(ANCESTRY_LIMIT); for (const event of tree.allEvents) { verifyAncestry(event, tree); } diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 339e5554ccb12..66e786cb02e63 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -823,7 +823,7 @@ export class EndpointDocGenerator { timestamp, parentEntityID: ancestor.process.entity_id, // add the parent to the ancestry array - ancestry: [ancestor.process.entity_id, ...(ancestor.process.Ext.ancestry ?? [])], + ancestry: [ancestor.process.entity_id, ...(ancestor.process.Ext?.ancestry ?? [])], ancestryArrayLimit: opts.ancestryArraySize, parentPid: ancestor.process.pid, pid: this.randomN(5000), @@ -840,7 +840,7 @@ export class EndpointDocGenerator { parentEntityID: ancestor.process.parent?.entity_id, eventCategory: 'process', eventType: 'end', - ancestry: ancestor.process.Ext.ancestry, + ancestry: ancestor.process.Ext?.ancestry, ancestryArrayLimit: opts.ancestryArraySize, }) ); @@ -864,7 +864,7 @@ export class EndpointDocGenerator { timestamp, ancestor.process.entity_id, ancestor.process.parent?.entity_id, - ancestor.process.Ext.ancestry + ancestor.process.Ext?.ancestry ) ); return events; @@ -914,7 +914,7 @@ export class EndpointDocGenerator { parentEntityID: currentState.event.process.entity_id, ancestry: [ currentState.event.process.entity_id, - ...(currentState.event.process.Ext.ancestry ?? []), + ...(currentState.event.process.Ext?.ancestry ?? []), ], ancestryArrayLimit: opts.ancestryArraySize, }); @@ -938,7 +938,7 @@ export class EndpointDocGenerator { parentEntityID: child.process.parent?.entity_id, eventCategory: 'process', eventType: 'end', - ancestry: child.process.Ext.ancestry, + ancestry: child.process.Ext?.ancestry, ancestryArrayLimit: opts.ancestryArraySize, }); } @@ -984,7 +984,7 @@ export class EndpointDocGenerator { parentEntityID: node.process.parent?.entity_id, eventCategory: eventInfo.category, eventType: eventInfo.creationType, - ancestry: node.process.Ext.ancestry, + ancestry: node.process.Ext?.ancestry, }); } } @@ -1007,7 +1007,7 @@ export class EndpointDocGenerator { ts, node.process.entity_id, node.process.parent?.entity_id, - node.process.Ext.ancestry + node.process.Ext?.ancestry ); } } 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 9b4550f52ff22..f8a6807196557 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -57,7 +57,9 @@ export function ancestryArray(event: ResolverEvent): string[] | undefined { if (isLegacyEvent(event)) { return undefined; } - return event.process.Ext.ancestry; + // this is to guard against the endpoint accidentally not sending the ancestry array + // otherwise the request will fail when really we should just try using the parent entity id + return event.process.Ext?.ancestry; } export function getAncestryAsArray(event: ResolverEvent | undefined): string[] { diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index 42cbc2327fc28..c67ad3665d004 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -12,10 +12,10 @@ import { schema } from '@kbn/config-schema'; export const validateTree = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 0, max: 100 }), - ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }), - events: schema.number({ defaultValue: 100, min: 0, max: 1000 }), - alerts: schema.number({ defaultValue: 100, min: 0, max: 1000 }), + children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + events: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), + alerts: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), afterEvent: schema.maybe(schema.string()), afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), @@ -29,7 +29,7 @@ export const validateTree = { export const validateEvents = { params: schema.object({ id: schema.string() }), query: schema.object({ - events: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -41,7 +41,7 @@ export const validateEvents = { export const validateAlerts = { params: schema.object({ id: schema.string() }), query: schema.object({ - alerts: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterAlert: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -53,7 +53,7 @@ export const validateAlerts = { export const validateAncestry = { params: schema.object({ id: schema.string() }), query: schema.object({ - ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), legacyEndpointID: schema.maybe(schema.string()), }), }; @@ -64,7 +64,7 @@ export const validateAncestry = { export const validateChildren = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 1, max: 100 }), + children: schema.number({ defaultValue: 200, min: 1, max: 10000 }), afterChild: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index b75d4b2190fe8..b477207b1c5a3 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -334,13 +334,13 @@ export interface AlertEvent { start: number; thread?: ThreadFields[]; uptime: number; - Ext: { + Ext?: { /* * The array has a special format. The entity_ids towards the beginning of the array are closer ancestors and the * values towards the end of the array are more distant ancestors (grandparents). Therefore * ancestry_array[0] == process.parent.entity_id and ancestry_array[1] == process.parent.parent.entity_id */ - ancestry: string[]; + ancestry?: string[]; code_signature: Array<{ subject_name: string; trusted: boolean; @@ -539,8 +539,8 @@ export interface EndpointEvent { * values towards the end of the array are more distant ancestors (grandparents). Therefore * ancestry_array[0] == process.parent.entity_id and ancestry_array[1] == process.parent.parent.entity_id */ - Ext: { - ancestry: string[]; + Ext?: { + ancestry?: string[]; }; }; user?: { diff --git a/x-pack/plugins/security_solution/common/index.ts b/x-pack/plugins/security_solution/common/index.ts new file mode 100644 index 0000000000000..b55ca5db30a44 --- /dev/null +++ b/x-pack/plugins/security_solution/common/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 './shared_exports'; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts new file mode 100644 index 0000000000000..1b5b17ef35cae --- /dev/null +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NonEmptyString } from './detection_engine/schemas/types/non_empty_string'; +export { DefaultUuid } from './detection_engine/schemas/types/default_uuid'; +export { DefaultStringArray } from './detection_engine/schemas/types/default_string_array'; +export { exactCheck } from './exact_check'; +export { getPaths, foldLeftRight } from './test_utils'; +export { validate, validateEither } from './validate'; +export { formatErrors } from './format_errors'; diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts new file mode 100644 index 0000000000000..a607906e1b92a --- /dev/null +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ListSchema, + CommentsArray, + CreateCommentsArray, + Comments, + CreateComments, + ExceptionListSchema, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntriesArray, + NamespaceType, + Operator, + OperatorEnum, + OperatorType, + OperatorTypeEnum, + ExceptionListTypeEnum, + exceptionListItemSchema, + exceptionListType, + createExceptionListItemSchema, + listSchema, + entry, + entriesNested, + entriesMatch, + entriesMatchAny, + entriesExists, + entriesList, + namespaceType, + ExceptionListType, + Type, +} from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 9e7a6f46bbcec..021e5a7f00b17 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -7,11 +7,16 @@ /* eslint-disable @typescript-eslint/camelcase, @typescript-eslint/no-empty-interface */ import * as runtimeTypes from 'io-ts'; -import { SavedObjectsClient } from 'kibana/server'; import { stringEnum, unionWithNullType } from '../../utility_types'; import { NoteSavedObject, NoteSavedObjectToReturnRuntimeType } from './note'; import { PinnedEventToReturnSavedObjectRuntimeType, PinnedEventSavedObject } from './pinned_event'; +import { + success, + success_count as successCount, +} from '../../detection_engine/schemas/common/schemas'; +import { PositiveInteger } from '../../detection_engine/schemas/types'; +import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; /* * ColumnHeader Types @@ -353,19 +358,6 @@ export interface AllTimelineSavedObject * Import/export timelines */ -export type ExportTimelineSavedObjectsClient = Pick< - SavedObjectsClient, - | 'get' - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' ->; - export type ExportedGlobalNotes = Array>; export type ExportedEventNotes = NoteSavedObject[]; @@ -393,3 +385,15 @@ export type NotesAndPinnedEventsByTimelineId = Record< string, { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } >; + +export const importTimelineResultSchema = runtimeTypes.exact( + runtimeTypes.type({ + success, + success_count: successCount, + timelines_installed: PositiveInteger, + timelines_updated: PositiveInteger, + errors: runtimeTypes.array(errorSchema), + }) +); + +export type ImportTimelineResultSchema = runtimeTypes.TypeOf; diff --git a/x-pack/plugins/security_solution/common/validate.test.ts b/x-pack/plugins/security_solution/common/validate.test.ts index b2217099fca19..8cd322a25b5c0 100644 --- a/x-pack/plugins/security_solution/common/validate.test.ts +++ b/x-pack/plugins/security_solution/common/validate.test.ts @@ -43,6 +43,6 @@ describe('validateEither', () => { const payload = { a: 'some other value' }; const result = validateEither(schema, payload); - expect(result).toEqual(left('Invalid value "some other value" supplied to "a"')); + expect(result).toEqual(left(new Error('Invalid value "some other value" supplied to "a"'))); }); }); diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index f36df38c2a90d..9745c21a191f0 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -27,9 +27,9 @@ export const validate = ( export const validateEither = ( schema: T, obj: A -): Either => +): Either => pipe( obj, (a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())), - mapLeft((errors) => formatErrors(errors).join(',')) + mapLeft((errors) => new Error(formatErrors(errors).join(','))) ); diff --git a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts index e4f0ec2c4828f..792eee3660429 100644 --- a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts @@ -7,7 +7,7 @@ import { CASES, DETECTIONS, HOSTS, - MANAGEMENT, + ADMINISTRATION, NETWORK, OVERVIEW, TIMELINES, @@ -73,7 +73,7 @@ describe('top-level navigation common to all pages in the Security app', () => { }); it('navigates to the Administration page', () => { - navigateFromHeaderTo(MANAGEMENT); + navigateFromHeaderTo(ADMINISTRATION); cy.url().should('include', ADMINISTRATION_URL); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 20fcae60415ae..a337db7a9bfaa 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -14,7 +14,7 @@ export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; -export const MANAGEMENT = '[data-test-subj="navigation-management"]'; +export const ADMINISTRATION = '[data-test-subj="navigation-administration"]'; export const NETWORK = '[data-test-subj="navigation-network"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 37ce9094dc594..761fd2c1e6a0b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -27,6 +27,8 @@ import { import { drag, drop } from '../tasks/common'; +export const hostExistsQuery = 'host.name: *'; + export const addDescriptionToTimeline = (description: string) => { cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating'); @@ -77,6 +79,7 @@ export const openTimelineSettings = () => { }; export const populateTimeline = () => { + executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) .invoke('text') .then((strCount) => { diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 29d0ab58e8b55..92fc93453b9f1 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -1,6 +1,7 @@ { "id": "securitySolution", "version": "8.0.0", + "extraPublicDirs": ["common"], "kibanaVersion": "kibana", "configPath": ["xpack", "securitySolution"], "requiredPlugins": [ @@ -30,10 +31,5 @@ ], "server": true, "ui": true, - "requiredBundles": [ - "kibanaUtils", - "esUiShared", - "kibanaReact", - "ingestManager" - ] + "requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"] } diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx index 543a4634ceecc..9f0f5351d8a54 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx @@ -61,11 +61,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: 'case', }, - [SecurityPageName.management]: { - id: SecurityPageName.management, + [SecurityPageName.administration]: { + id: SecurityPageName.administration, name: i18n.ADMINISTRATION, href: APP_MANAGEMENT_PATH, disabled: false, - urlKey: SecurityPageName.management, + urlKey: SecurityPageName.administration, }, }; diff --git a/x-pack/plugins/security_solution/public/app/home/setup.tsx b/x-pack/plugins/security_solution/public/app/home/setup.tsx index bf7ce2ddf8b50..3f4b0c19e7035 100644 --- a/x-pack/plugins/security_solution/public/app/home/setup.tsx +++ b/x-pack/plugins/security_solution/public/app/home/setup.tsx @@ -32,7 +32,7 @@ export const Setup: React.FunctionComponent<{ }); }; - ingestManager.success.catch((error: Error) => displayToastWithModal(error.message)); + ingestManager.isInitialized().catch((error: Error) => displayToastWithModal(error.message)); }, [ingestManager, notifications.toasts]); return null; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap index 096df5ceab256..bed5ac6950a2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap @@ -25,6 +25,10 @@ exports[`PageView component should display body header custom element 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -120,6 +124,10 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -331,6 +344,10 @@ exports[`PageView component should display only body if not header props used 1` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -403,6 +420,10 @@ exports[`PageView component should display only header left 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -505,6 +527,10 @@ exports[`PageView component should display only header right but include an empt margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -604,6 +631,10 @@ exports[`PageView component should pass through EuiPage props 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -721,10 +756,11 @@ exports[`PageView component should use custom element for header left and not wr className="euiPageHeader euiPageHeader--responsive endpoint-header" >

diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx index 3d2a1d2d6fc9b..d4753b3a64e24 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx @@ -17,6 +17,7 @@ import { EuiTab, EuiTabs, EuiTitle, + EuiTitleProps, } from '@elastic/eui'; import React, { memo, MouseEventHandler, ReactNode, useMemo } from 'react'; import styled from 'styled-components'; @@ -45,6 +46,9 @@ const StyledEuiPage = styled(EuiPage)` .endpoint-navTabs { margin-left: ${(props) => props.theme.eui.euiSizeM}; } + .endpoint-header-leftSection { + overflow: hidden; + } `; const isStringOrNumber = /(string|number)/; @@ -54,13 +58,15 @@ const isStringOrNumber = /(string|number)/; * Can be used when wanting to customize the `headerLeft` value but still use the standard * title component */ -export const PageViewHeaderTitle = memo<{ children: ReactNode }>(({ children }) => { - return ( - -

{children}

- - ); -}); +export const PageViewHeaderTitle = memo & { children: ReactNode }>( + ({ children, size = 'l', ...otherProps }) => { + return ( + +

{children}

+
+ ); + } +); PageViewHeaderTitle.displayName = 'PageViewHeaderTitle'; @@ -135,7 +141,10 @@ export const PageView = memo( {(headerLeft || headerRight) && ( - + {isStringOrNumber.test(typeof headerLeft) ? ( {headerLeft} ) : ( 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 0a1f95d51e300..a81c5facb0718 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 @@ -67,6 +67,8 @@ interface Props { sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; + // If truthy, the graph viewer (Resolver) is showing + graphEventId: string | undefined; } const EventsViewerComponent: React.FC = ({ @@ -90,6 +92,7 @@ const EventsViewerComponent: React.FC = ({ sort, toggleColumn, utilityBar, + graphEventId, }) => { const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); @@ -191,22 +194,28 @@ const EventsViewerComponent: React.FC = ({ toggleColumn={toggleColumn} /> -
+ { + /** Hide the footer if Resolver is showing. */ + !graphEventId && ( +
+ ) + } ); @@ -237,5 +246,6 @@ export const EventsViewer = React.memo( deepEqual(prevProps.query, nextProps.query) && prevProps.start === nextProps.start && prevProps.sort === nextProps.sort && - prevProps.utilityBar === nextProps.utilityBar + prevProps.utilityBar === nextProps.utilityBar && + prevProps.graphEventId === nextProps.graphEventId ); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index b89d2b8c08625..637f1a48143a9 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -62,6 +62,8 @@ const StatefulEventsViewerComponent: React.FC = ({ updateItemsPerPage, upsertColumn, utilityBar, + // If truthy, the graph viewer (Resolver) is showing + graphEventId, }) => { const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( defaultIndices ?? useUiSetting(DEFAULT_INDEX_KEY) @@ -135,6 +137,7 @@ const StatefulEventsViewerComponent: React.FC = ({ sort={sort} toggleColumn={toggleColumn} utilityBar={utilityBar} + graphEventId={graphEventId} /> ); @@ -145,6 +148,7 @@ const makeMapStateToProps = () => { const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); const getEvents = timelineSelectors.getEventsByIdSelector(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); const mapStateToProps = (state: State, { id, defaultModel }: OwnProps) => { const input: inputsModel.InputsRange = getInputsTimeline(state); const events: TimelineModel = getEvents(state, id) ?? defaultModel; @@ -174,6 +178,9 @@ const makeMapStateToProps = () => { query: getGlobalQuerySelector(state), sort, showCheckboxes, + // Used to determine whether the footer should show (since it is hidden if the graph is showing.) + // `getTimeline` actually returns `TimelineModel | undefined` + graphEventId: (getTimeline(state, id) as TimelineModel | undefined)?.graphEventId, }; }; return mapStateToProps; @@ -213,6 +220,7 @@ export const StatefulEventsViewer = connector( deepEqual(prevProps.pageFilters, nextProps.pageFilters) && prevProps.showCheckboxes === nextProps.showCheckboxes && prevProps.start === nextProps.start && - prevProps.utilityBar === nextProps.utilityBar + prevProps.utilityBar === nextProps.utilityBar && + prevProps.graphEventId === nextProps.graphEventId ) ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 10d510c5f56c3..d5eeef0f1e768 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -251,13 +251,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { - if (shouldCloseAlert && alertData) { - addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id); - } else { - addOrUpdateExceptionItems(enrichExceptionItems()); - } + const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined; + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), alertIdToClose, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]); + }, [ + addOrUpdateExceptionItems, + enrichExceptionItems, + shouldCloseAlert, + shouldBulkCloseAlert, + alertData, + signalIndexName, + ]); const isSubmitButtonDisabled = useCallback( () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, @@ -330,7 +336,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {alertData !== undefined && ( - + )} - + { if (addOrUpdateExceptionItems !== null) { - addOrUpdateExceptionItems(enrichExceptionItems()); + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), undefined, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems]); + }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]); const indexPatternConfig = useCallback(() => { if (exceptionListType === 'endpoint') { @@ -239,10 +241,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - + { expect(result).toEqual(true); }); }); + + describe('#prepareExceptionItemsForBulkClose', () => { + test('it should return no exceptionw when passed in an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual([]); + }); + + test("should not make any updates when the exception entries don't contain 'event.'", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(payload); + }); + + test("should update entry fields when they start with 'event.'", () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.module', + }, + ], + }, + ]; + const expected = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.module', + }, + ], + }, + ]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 481b2736b7597..3d028431de8ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -36,6 +36,7 @@ import { exceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListType, + EntryNested, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { TimelineNonEcsData } from '../../../graphql/types'; @@ -380,6 +381,35 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Maps "event." fields to "signal.original_event.". This is because when a rule is created + * the "event" field is copied over to "original_event". When the user creates an exception, + * they expect it to match against the original_event's fields, not the signal event's. + * @param exceptionItems new or existing ExceptionItem[] + */ +export const prepareExceptionItemsForBulkClose = ( + exceptionItems: Array +): Array => { + return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if (item.entries !== undefined) { + const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { + return { + ...itemEntry, + field: itemEntry.field.startsWith('event.') + ? itemEntry.field.replace(/^event./, 'signal.original_event.') + : itemEntry.field, + }; + }); + return { + ...item, + entries: newEntries, + }; + } else { + return item; + } + }); +}; + /** * Adds new and existing comments to all new exceptionItems if not present already * @param exceptionItems new or existing ExceptionItem[] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 03beee8ab373e..ee3255446b334 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -91,7 +91,7 @@ export const ADD_TO_DETECTIONS_LIST = i18n.translate( export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( 'xpack.securitySolution.exceptions.viewer.emptyPromptTitle', { - defaultMessage: 'You have no exceptions', + defaultMessage: 'This rule has no exceptions', } ); @@ -99,7 +99,7 @@ export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( 'xpack.securitySolution.exceptions.viewer.emptyPromptBody', { defaultMessage: - 'You can add an exception to fine tune the rule so that it suppresses alerts that meet specified conditions. Exceptions leverage detection accuracy, which can help reduce the number of false positives.', + 'You can add exceptions to fine tune the rule so that detection alerts are not created when exception conditions are met. Exceptions improve detection accuracy, which can help reduce the number of false positives.', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 018ca1d29c369..bf07ff21823eb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -9,6 +9,8 @@ import { KibanaServices } from '../../../common/lib/kibana'; import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; import * as listsApi from '../../../../../lists/public/exceptions/api'; +import * as getQueryFilterHelper from '../../../../common/detection_engine/get_query_filter'; +import * as buildAlertStatusFilterHelper from '../../../detections/components/alerts_table/default_config'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock'; @@ -38,11 +40,16 @@ describe('useAddOrUpdateException', () => { let updateExceptionListItem: jest.SpyInstance>; + let getQueryFilter: jest.SpyInstance>; + let buildAlertStatusFilter: jest.SpyInstance>; let addOrUpdateItemsArgs: Parameters; let render: () => RenderHookResult; const onError = jest.fn(); const onSuccess = jest.fn(); const alertIdToClose = 'idToClose'; + const bulkCloseIndex = ['.signals']; const itemsToAdd: CreateExceptionListItemSchema[] = [ { ...getCreateExceptionListItemSchemaMock(), @@ -113,6 +120,10 @@ describe('useAddOrUpdateException', () => { .spyOn(listsApi, 'updateExceptionListItem') .mockResolvedValue(getExceptionListItemSchemaMock()); + getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter'); + + buildAlertStatusFilter = jest.spyOn(buildAlertStatusFilterHelper, 'buildAlertStatusFilter'); + addOrUpdateItemsArgs = [itemsToAddOrUpdate]; render = () => renderHook(() => @@ -244,4 +255,92 @@ describe('useAddOrUpdateException', () => { }); }); }); + + describe('when bulkCloseIndex is passed in', () => { + beforeEach(() => { + addOrUpdateItemsArgs = [itemsToAddOrUpdate, undefined, bulkCloseIndex]; + }); + it('should update the status of only alerts that are open', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(buildAlertStatusFilter).toHaveBeenCalledTimes(1); + expect(buildAlertStatusFilter.mock.calls[0][0]).toEqual('open'); + }); + }); + it('should generate the query filter using exceptions', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(getQueryFilter).toHaveBeenCalledTimes(1); + expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); + expect(getQueryFilter.mock.calls[0][5]).toEqual(false); + }); + }); + it('should update the alert status', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateAlertStatus).toHaveBeenCalledTimes(1); + }); + }); + it('creates new items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(addExceptionListItem).toHaveBeenCalledTimes(2); + expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); + }); + }); + it('updates existing items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateExceptionListItem).toHaveBeenCalledTimes(2); + expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( + itemsToUpdateFormatted[1] + ); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 267a9afd9cf6d..55c3ea35716d5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -16,18 +16,23 @@ import { } from '../../../lists_plugin_deps'; import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { formatExceptionItemForUpdate } from './helpers'; +import { buildAlertStatusFilter } from '../../../detections/components/alerts_table/default_config'; +import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter'; +import { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers'; /** * Adds exception items to the list. Also optionally closes alerts. * * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query * */ export type AddOrUpdateExceptionItemsFunc = ( exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string + alertIdToClose?: string, + bulkCloseIndex?: Index ) => Promise; export type ReturnUseAddOrUpdateException = [ @@ -100,7 +105,8 @@ export const useAddOrUpdateException = ({ const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async ( exceptionItemsToAddOrUpdate, - alertIdToClose + alertIdToClose, + bulkCloseIndex ) => { try { setIsLoading(true); @@ -111,6 +117,23 @@ export const useAddOrUpdateException = ({ }); } + if (bulkCloseIndex != null) { + const filter = getQueryFilter( + '', + 'kuery', + buildAlertStatusFilter('open'), + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), + false + ); + await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + }); + } + await addOrUpdateItems(exceptionItemsToAddOrUpdate); if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index dc5324adbac7d..845ef580ddbe2 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -15,12 +15,14 @@ import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/p import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../../cases/pages/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages'; +import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/pages'; import { SecurityPageName } from '../../../../app/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState, TimelineRouteSpyState, + AdministrationRouteSpyState, } from '../../../utils/route/types'; import { getAppOverviewUrl } from '../../link_to'; @@ -61,6 +63,10 @@ const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => const isAlertsRoutes = (spyState: RouteSpyState) => spyState != null && spyState.pageName === SecurityPageName.detections; +const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => + spyState != null && spyState.pageName === SecurityPageName.administration; + +// eslint-disable-next-line complexity export const getBreadcrumbsForRoute = ( object: RouteSpyState & TabNavigationProps, getUrlForApp: GetUrlForApp @@ -159,6 +165,27 @@ export const getBreadcrumbsForRoute = ( ), ]; } + + if (isAdminRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'administration', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getAdminBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ), + getUrlForApp + ), + ]; + } + if ( spyState != null && object.navTabs && diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index 229e2d2402298..c60feb63241fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -106,12 +106,12 @@ describe('SIEM Navigation', () => { name: 'Cases', urlKey: 'case', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, hosts: { disabled: false, @@ -218,12 +218,12 @@ describe('SIEM Navigation', () => { name: 'Hosts', urlKey: 'host', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, network: { disabled: false, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0489ebba738c8..c17abaad525a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -48,7 +48,7 @@ export type SiemNavTabKey = | SecurityPageName.detections | SecurityPageName.timelines | SecurityPageName.case - | SecurityPageName.management; + | SecurityPageName.administration; export type SiemNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index 1faff2594ce80..5a4aec93dd9aa 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -30,4 +30,4 @@ export type UrlStateType = | 'network' | 'overview' | 'timeline' - | 'management'; + | 'administration'; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 6febf95aae01d..5e40cd00fa69e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -96,6 +96,8 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'timeline'; } else if (pageName === SecurityPageName.case) { return 'case'; + } else if (pageName === SecurityPageName.administration) { + return 'administration'; } return 'overview'; }; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts index 8881a82e5cd1c..f383e18132385 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts @@ -46,7 +46,7 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - management: [], + administration: [], network: [ CONSTANTS.appQuery, CONSTANTS.filters, diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 813907d9af416..2e0ac826c6947 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -8,11 +8,13 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; + import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; -import { useUiSetting, useKibana } from './kibana_react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../cases/containers/utils'; +import { StartServices } from '../../../types'; +import { useUiSetting, useKibana } from './kibana_react'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -23,6 +25,11 @@ export const useTimeZone = (): string => { export const useBasePath = (): string => useKibana().services.http.basePath.get(); +export const useToasts = (): StartServices['notifications']['toasts'] => + useKibana().services.notifications.toasts; + +export const useHttp = (): StartServices['http'] => useKibana().services.http; + interface UserRealm { name: string; type: string; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts index 9276d503176c6..9cf99f2442aab 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/dependencies_start_mock.ts @@ -59,6 +59,9 @@ export const depsStartMock: () => DepsStartMock = () => { return { data: dataMock, - ingestManager: { success: Promise.resolve(true), registerPackageConfigComponent }, + ingestManager: { + isInitialized: () => Promise.resolve(true), + registerPackageConfigComponent, + }, }; }; diff --git a/x-pack/plugins/security_solution/public/common/utils/api/index.ts b/x-pack/plugins/security_solution/public/common/utils/api/index.ts index e47e03ce4e627..ab442d0d09cf9 100644 --- a/x-pack/plugins/security_solution/public/common/utils/api/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/api/index.ts @@ -7,6 +7,7 @@ import { has } from 'lodash/fp'; export interface KibanaApiError { + name: string; message: string; body: { message: string; diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 8656f20c92959..13eb03b07353d 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -12,9 +12,10 @@ import { TimelineType } from '../../../../common/types/timeline'; import { HostsTableType } from '../../../hosts/store/model'; import { NetworkRouteType } from '../../../network/pages/navigation/types'; +import { AdministrationSubTab as AdministrationType } from '../../../management/types'; import { FlowTarget } from '../../../graphql/types'; -export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType; +export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -38,6 +39,10 @@ export interface TimelineRouteSpyState extends RouteSpyState { tabName: TimelineType | undefined; } +export interface AdministrationRouteSpyState extends RouteSpyState { + tabName: AdministrationType | undefined; +} + export type RouteSpyAction = | { type: 'updateSearch'; diff --git a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts new file mode 100644 index 0000000000000..5a3cddb74657d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073 +export const waitForUpdates = async

(wrapper: ReactWrapper

) => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 1213312e2a22c..24bfeaa4dae1a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -53,6 +53,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); @@ -65,6 +66,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); const expected = { @@ -250,6 +252,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); // @ts-ignore @@ -279,6 +282,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); // @ts-ignore @@ -297,6 +301,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); @@ -326,6 +331,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: ecsDataMock, + nonEcsData: [], updateTimelineIsLoading, }); @@ -350,6 +356,7 @@ describe('alert actions', () => { await sendAlertToTimelineAction({ createTimeline, ecsData: ecsDataMock, + nonEcsData: [], updateTimelineIsLoading, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 24f292cf9135b..11c13c2358e94 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable complexity */ + import dateMath from '@elastic/datemath'; -import { getOr, isEmpty } from 'lodash/fp'; +import { get, getOr, isEmpty, find } from 'lodash/fp'; import moment from 'moment'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; @@ -30,6 +32,8 @@ import { replaceTemplateFieldFromMatchFilters, replaceTemplateFieldFromDataProviders, } from './helpers'; +import { KueryFilterQueryKind } from '../../../common/store'; +import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { @@ -99,10 +103,45 @@ export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => { return { to, from }; }; +export const getThresholdAggregationDataProvider = ( + ecsData: Ecs, + nonEcsData: TimelineNonEcsData[] +): DataProvider[] => { + const aggregationField = ecsData.signal?.rule?.threshold.field; + const aggregationValue = + get(aggregationField, ecsData) ?? find(['field', aggregationField], nonEcsData)?.value; + const dataProviderValue = Array.isArray(aggregationValue) + ? aggregationValue[0] + : aggregationValue; + + if (!dataProviderValue) { + return []; + } + + const aggregationFieldId = aggregationField.replace('.', '-'); + + return [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-${aggregationFieldId}-${dataProviderValue}`, + name: ecsData.signal?.rule?.threshold.field, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: aggregationField, + value: dataProviderValue, + operator: ':', + }, + }, + ]; +}; + export const sendAlertToTimelineAction = async ({ apolloClient, createTimeline, ecsData, + nonEcsData, updateTimelineIsLoading, }: SendAlertToTimelineActionProps) => { let openAlertInBasicTimeline = true; @@ -146,7 +185,7 @@ export const sendAlertToTimelineAction = async ({ timeline.timelineType ); - createTimeline({ + return createTimeline({ from, timeline: { ...timeline, @@ -186,8 +225,62 @@ export const sendAlertToTimelineAction = async ({ } } - if (openAlertInBasicTimeline) { - createTimeline({ + if ( + ecsData.signal?.rule?.type?.length && + ecsData.signal?.rule?.type[0] === 'threshold' && + openAlertInBasicTimeline + ) { + return createTimeline({ + from, + timeline: { + ...timelineDefaults, + dataProviders: [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-alert-id-${ecsData._id}`, + name: ecsData._id, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: '_id', + value: ecsData._id, + operator: ':', + }, + }, + ...getThresholdAggregationDataProvider(ecsData, nonEcsData), + ], + id: 'timeline-1', + dateRange: { + start: from, + end: to, + }, + eventType: 'all', + kqlQuery: { + filterQuery: { + kuery: { + kind: ecsData.signal?.rule?.language?.length + ? (ecsData.signal?.rule?.language[0] as KueryFilterQueryKind) + : 'kuery', + expression: ecsData.signal?.rule?.query?.length ? ecsData.signal?.rule?.query[0] : '', + }, + serializedQuery: ecsData.signal?.rule?.query?.length + ? ecsData.signal?.rule?.query[0] + : '', + }, + filterQueryDraft: { + kind: ecsData.signal?.rule?.language?.length + ? (ecsData.signal?.rule?.language[0] as KueryFilterQueryKind) + : 'kuery', + expression: ecsData.signal?.rule?.query?.length ? ecsData.signal?.rule?.query[0] : '', + }, + }, + }, + to, + ruleNote: noteContent, + }); + } else { + return createTimeline({ from, timeline: { ...timelineDefaults, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 319575c9c307f..6f1f2e46dce3d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -309,11 +309,12 @@ export const getAlertActions = ({ displayType: 'icon', iconType: 'timeline', id: 'sendAlertToTimeline', - onClick: ({ ecsData }: TimelineRowActionOnClick) => + onClick: ({ ecsData, data }: TimelineRowActionOnClick) => sendAlertToTimelineAction({ apolloClient, createTimeline, ecsData, + nonEcsData: data, updateTimelineIsLoading, }), width: DEFAULT_ICON_BUTTON_WIDTH, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index b127ff04eca46..34d18b4dedba6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -7,7 +7,7 @@ import ApolloClient from 'apollo-client'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { inputsModel } from '../../../common/store'; @@ -53,6 +53,7 @@ export interface SendAlertToTimelineActionProps { apolloClient?: ApolloClient<{}>; createTimeline: CreateTimeline; ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; updateTimelineIsLoading: UpdateTimelineLoading; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx index b82d1c0a36ab2..41ee91845a8ec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx @@ -403,5 +403,17 @@ describe('helpers', () => { expect(result.description).toEqual('Query'); }); + + it('returns the label for a threshold type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'threshold'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a threshold type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'threshold'); + + expect(result.description).toEqual('Threshold'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index a0d43c3abf5c1..8393f2230dcfe 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -19,6 +19,7 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import { Threshold } from '../../../../../common/detection_engine/schemas/common/schemas'; import { RuleType } from '../../../../../common/detection_engine/types'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; @@ -132,10 +133,10 @@ export const buildThreatDescription = ({ label, threat }: BuildThreatDescription {tactic != null ? tactic.text : ''} - {singleThreat.technique.map((technique) => { + {singleThreat.technique.map((technique, listIndex) => { const myTechnique = techniquesOptions.find((t) => t.id === technique.id); return ( - + [ + { + title: label, + description: ( + <> + {isEmpty(threshold.field[0]) + ? `${i18n.THRESHOLD_RESULTS_ALL} >= ${threshold.value}` + : `${i18n.THRESHOLD_RESULTS_AGGREGATED_BY} ${threshold.field[0]} >= ${threshold.value}`} + + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index 0a7e666d65aef..5a2a44a284e3b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { StepRuleDescriptionComponent, @@ -367,6 +367,52 @@ describe('description_step', () => { }); }); + describe('threshold', () => { + test('returns threshold description when threshold exist and field is empty', () => { + const mockThreshold = { + isNew: false, + threshold: { + field: [''], + value: 100, + }, + }; + const result: ListItems[] = getDescriptionItem( + 'threshold', + 'Threshold label', + mockThreshold, + mockFilterManager + ); + + expect(result[0].title).toEqual('Threshold label'); + expect(React.isValidElement(result[0].description)).toBeTruthy(); + expect(mount(result[0].description as React.ReactElement).html()).toContain( + 'All results >= 100' + ); + }); + + test('returns threshold description when threshold exist and field is set', () => { + const mockThreshold = { + isNew: false, + threshold: { + field: ['user.name'], + value: 100, + }, + }; + const result: ListItems[] = getDescriptionItem( + 'threshold', + 'Threshold label', + mockThreshold, + mockFilterManager + ); + + expect(result[0].title).toEqual('Threshold label'); + expect(React.isValidElement(result[0].description)).toBeTruthy(); + expect(mount(result[0].description as React.ReactElement).html()).toContain( + 'Results aggregated by user.name >= 100' + ); + }); + }); + describe('references', () => { test('returns array of ListItems when references exist', () => { const result: ListItems[] = getDescriptionItem( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 8f3a76c6aea57..51624d04cb58b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -35,6 +35,7 @@ import { buildUrlsDescription, buildNoteDescription, buildRuleTypeDescription, + buildThresholdDescription, } from './helpers'; import { useSiemJobs } from '../../../../common/components/ml_popover/hooks/use_siem_jobs'; import { buildMlJobDescription } from './ml_job_description'; @@ -179,6 +180,9 @@ export const getDescriptionItem = ( (singleThreat: IMitreEnterpriseAttack) => singleThreat.tactic.name !== 'none' ); return buildThreatDescription({ label, threat }); + } else if (field === 'threshold') { + const threshold = get(field, data); + return buildThresholdDescription(label, threshold); } else if (field === 'references') { const urls: string[] = get(field, data); return buildUrlsDescription(label, urls); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx index 3e639ede7a18b..76217964a87cb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx @@ -41,6 +41,13 @@ export const QUERY_TYPE_DESCRIPTION = i18n.translate( } ); +export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.thresholdRuleTypeDescription', + { + defaultMessage: 'Threshold', + } +); + export const ML_JOB_STARTED = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription', { @@ -54,3 +61,17 @@ export const ML_JOB_STOPPED = i18n.translate( defaultMessage: 'Stopped', } ); + +export const THRESHOLD_RESULTS_ALL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAllDescription', + { + defaultMessage: 'All results', + } +); + +export const THRESHOLD_RESULTS_AGGREGATED_BY = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAggregatedByDescription', + { + defaultMessage: 'Results aggregated by', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts index 4d2ba8b861cce..37c1715c05d71 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts @@ -38,7 +38,7 @@ export const CREATE_RULE_ACTION = i18n.translate( export const UPDATE_PREPACKAGED_RULES_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesTitle', { - defaultMessage: 'Update available for Elastic prebuilt rules', + defaultMessage: 'Update available for Elastic prebuilt rules or timeline templates', } ); @@ -46,16 +46,56 @@ export const UPDATE_PREPACKAGED_RULES_MSG = (updateRules: number) => i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesMsg', { values: { updateRules }, defaultMessage: - 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}. Note that this will reload deleted Elastic prebuilt rules.', + 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}', }); +export const UPDATE_PREPACKAGED_TIMELINES_MSG = (updateTimelines: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedTimelinesMsg', { + values: { updateTimelines }, + defaultMessage: + 'You can update {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG = ( + updateRules: number, + updateTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesMsg', + { + values: { updateRules, updateTimelines }, + defaultMessage: + 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}. Note that this will reload deleted Elastic prebuilt rules.', + } + ); + export const UPDATE_PREPACKAGED_RULES = (updateRules: number) => i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesButton', { values: { updateRules }, defaultMessage: - 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} ', + 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}', }); +export const UPDATE_PREPACKAGED_TIMELINES = (updateTimelines: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedTimelinesButton', { + values: { updateTimelines }, + defaultMessage: + 'Update {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES = ( + updateRules: number, + updateTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesButton', + { + values: { updateRules, updateTimelines }, + defaultMessage: + 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + } + ); + export const RELEASE_NOTES_HELP = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.releaseNotesHelp', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx index b5dca70ad9575..5033fcd11dc7c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx @@ -27,6 +27,7 @@ describe('UpdatePrePackagedRulesCallOut', () => { ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx index 0faf4074ed890..3be2b853925f6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiCallOut, EuiButton, EuiLink } from '@elastic/eui'; @@ -14,19 +14,46 @@ import * as i18n from './translations'; interface UpdatePrePackagedRulesCallOutProps { loading: boolean; numberOfUpdatedRules: number; + numberOfUpdatedTimelines: number; updateRules: () => void; } const UpdatePrePackagedRulesCallOutComponent: React.FC = ({ loading, numberOfUpdatedRules, + numberOfUpdatedTimelines, updateRules, }) => { const { services } = useKibana(); + + const prepackagedRulesOrTimelines = useMemo(() => { + if (numberOfUpdatedRules > 0 && numberOfUpdatedTimelines === 0) { + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_RULES_MSG(numberOfUpdatedRules), + buttonTitle: i18n.UPDATE_PREPACKAGED_RULES(numberOfUpdatedRules), + }; + } else if (numberOfUpdatedRules === 0 && numberOfUpdatedTimelines > 0) { + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_TIMELINES_MSG(numberOfUpdatedTimelines), + buttonTitle: i18n.UPDATE_PREPACKAGED_TIMELINES(numberOfUpdatedTimelines), + }; + } else if (numberOfUpdatedRules > 0 && numberOfUpdatedTimelines > 0) + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG( + numberOfUpdatedRules, + numberOfUpdatedTimelines + ), + buttonTitle: i18n.UPDATE_PREPACKAGED_RULES_AND_TIMELINES( + numberOfUpdatedRules, + numberOfUpdatedTimelines + ), + }; + }, [numberOfUpdatedRules, numberOfUpdatedTimelines]); + return (

- {i18n.UPDATE_PREPACKAGED_RULES_MSG(numberOfUpdatedRules)} + {prepackagedRulesOrTimelines?.callOutMessage}

- {i18n.UPDATE_PREPACKAGED_RULES(numberOfUpdatedRules)} + {prepackagedRulesOrTimelines?.buttonTitle} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index 3dad53f532a5b..6546c1ba59d84 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -4,52 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiCard, - EuiFlexGrid, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiLink, - EuiText, -} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiIcon } from '@elastic/eui'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { RuleType } from '../../../../../common/detection_engine/types'; import { FieldHook } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; +import { MlCardDescription } from './ml_card_description'; -const MlCardDescription = ({ - subscriptionUrl, - hasValidLicense = false, -}: { - subscriptionUrl: string; - hasValidLicense?: boolean; -}) => ( - - {hasValidLicense ? ( - i18n.ML_TYPE_DESCRIPTION - ) : ( - - - - ), - }} - /> - )} - -); +const isThresholdRule = (ruleType: RuleType) => ruleType === 'threshold'; interface SelectRuleTypeProps { describedByIds?: string[]; @@ -75,11 +40,39 @@ export const SelectRuleType: React.FC = ({ ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); + const setThreshold = useCallback(() => setType('threshold'), [setType]); const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; const licensingUrl = useKibana().services.application.getUrlForApp('kibana', { path: '#/management/stack/license_management', }); + const querySelectableConfig = useMemo( + () => ({ + isDisabled: isReadOnly, + onClick: setQuery, + isSelected: !isMlRule(ruleType) && !isThresholdRule(ruleType), + }), + [isReadOnly, ruleType, setQuery] + ); + + const mlSelectableConfig = useMemo( + () => ({ + isDisabled: mlCardDisabled, + onClick: setMl, + isSelected: isMlRule(ruleType), + }), + [mlCardDisabled, ruleType, setMl] + ); + + const thresholdSelectableConfig = useMemo( + () => ({ + isDisabled: isReadOnly, + onClick: setThreshold, + isSelected: isThresholdRule(ruleType), + }), + [isReadOnly, ruleType, setThreshold] + ); + return ( = ({ title={i18n.QUERY_TYPE_TITLE} description={i18n.QUERY_TYPE_DESCRIPTION} icon={} - selectable={{ - isDisabled: isReadOnly, - onClick: setQuery, - isSelected: !isMlRule(ruleType), - }} + isDisabled={querySelectableConfig.isDisabled && !querySelectableConfig.isSelected} + selectable={querySelectableConfig} /> @@ -109,12 +99,20 @@ export const SelectRuleType: React.FC = ({ } icon={} - isDisabled={mlCardDisabled} - selectable={{ - isDisabled: mlCardDisabled, - onClick: setMl, - isSelected: isMlRule(ruleType), - }} + isDisabled={mlSelectableConfig.isDisabled && !mlSelectableConfig.isSelected} + selectable={mlSelectableConfig} + /> + + + } + isDisabled={ + thresholdSelectableConfig.isDisabled && !thresholdSelectableConfig.isSelected + } + selectable={thresholdSelectableConfig} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx new file mode 100644 index 0000000000000..2171c93e47d63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { ML_TYPE_DESCRIPTION } from './translations'; + +interface MlCardDescriptionProps { + subscriptionUrl: string; + hasValidLicense?: boolean; +} + +const MlCardDescriptionComponent: React.FC = ({ + subscriptionUrl, + hasValidLicense = false, +}) => ( + + {hasValidLicense ? ( + ML_TYPE_DESCRIPTION + ) : ( + + + + ), + }} + /> + )} + +); + +MlCardDescriptionComponent.displayName = 'MlCardDescriptionComponent'; + +export const MlCardDescription = React.memo(MlCardDescriptionComponent); + +MlCardDescription.displayName = 'MlCardDescription'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts index 8b92d20616f7c..3b85a7dfc765c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts @@ -33,3 +33,17 @@ export const ML_TYPE_DESCRIPTION = i18n.translate( defaultMessage: 'Select ML job to detect anomalous activity.', } ); + +export const THRESHOLD_TYPE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.thresholdTypeTitle', + { + defaultMessage: 'Threshold', + } +); + +export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.thresholdTypeDescription', + { + defaultMessage: 'Aggregate query results to detect when number of matches exceeds threshold.', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 864f953bff1e1..c7d70684b34cf 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -35,12 +35,14 @@ import { MlJobSelect } from '../ml_job_select'; import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; import { NextStep } from '../next_step'; +import { ThresholdInput } from '../threshold_input'; import { Field, Form, - FormDataProvider, getUseField, UseField, + UseMultiFields, + FormDataProvider, useForm, FormSchema, } from '../../../../shared_imports'; @@ -64,6 +66,10 @@ const stepDefineDefaultValue: DefineStepRule = { filters: [], saved_id: undefined, }, + threshold: { + field: [], + value: '200', + }, timeline: { id: null, title: DEFAULT_TIMELINE_TITLE, @@ -84,6 +90,12 @@ MyLabelButton.defaultProps = { flush: 'right', }; +const RuleTypeEuiFormRow = styled(EuiFormRow).attrs<{ $isVisible: boolean }>(({ $isVisible }) => ({ + style: { + display: $isVisible ? 'flex' : 'none', + }, +}))<{ $isVisible: boolean }>``; + const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -97,7 +109,9 @@ const StepDefineRuleComponent: FC = ({ const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); - const [localIsMlRule, setIsMlRule] = useState(false); + const [localRuleType, setLocalRuleType] = useState( + defaultValues?.ruleType || stepDefineDefaultValue.ruleType + ); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const [myStepData, setMyStepData] = useState({ ...stepDefineDefaultValue, @@ -156,6 +170,17 @@ const StepDefineRuleComponent: FC = ({ setOpenTimelineSearch(false); }, []); + const ThresholdInputChildren = useCallback( + ({ thresholdField, thresholdValue }) => ( + + ), + [browserFields] + ); + return isReadOnlyView ? ( = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - + <> = ({ }} /> - - + + <> = ({ }} /> - + + + <> + + {ThresholdInputChildren} + + + = ({ } else if (!deepEqual(index, indicesConfig) && !indexModified) { setIndexModified(true); } + if (myStepData.index !== index) { + setMyStepData((prevValue) => ({ ...prevValue, index })); + } } - if (isMlRule(ruleType) && !localIsMlRule) { - setIsMlRule(true); - clearErrors(); - } else if (!isMlRule(ruleType) && localIsMlRule) { - setIsMlRule(false); + if (ruleType !== localRuleType) { + setLocalRuleType(ruleType); clearErrors(); } - return null; }} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 190d4484b156b..67d795ccf90f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -172,4 +172,36 @@ export const schema: FormSchema = { } ), }, + threshold: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdLabel', + { + defaultMessage: 'Threshold', + } + ), + field: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdFieldLabel', + { + defaultMessage: 'Field', + } + ), + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdFieldHelpText', + { + defaultMessage: 'Select a field to group results by', + } + ), + }, + value: { + type: FIELD_TYPES.NUMBER, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdValueLabel', + { + defaultMessage: 'Threshold', + } + ), + }, + }, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx new file mode 100644 index 0000000000000..81e771ce4dc5b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { BrowserFields } from '../../../../common/containers/source'; +import { getCategorizedFieldNames } from '../../../../timelines/components/edit_data_provider/helpers'; +import { FieldHook, Field } from '../../../../shared_imports'; +import { THRESHOLD_FIELD_PLACEHOLDER } from './translations'; + +const FIELD_COMBO_BOX_WIDTH = 410; + +export interface FieldValueThreshold { + field: string[]; + value: string; +} + +interface ThresholdInputProps { + thresholdField: FieldHook; + thresholdValue: FieldHook; + browserFields: BrowserFields; +} + +const OperatorWrapper = styled(EuiFlexItem)` + align-self: center; +`; + +const fieldDescribedByIds = ['detectionEngineStepDefineRuleThresholdField']; +const valueDescribedByIds = ['detectionEngineStepDefineRuleThresholdValue']; + +const ThresholdInputComponent: React.FC = ({ + thresholdField, + thresholdValue, + browserFields, +}: ThresholdInputProps) => { + const fieldEuiFieldProps = useMemo( + () => ({ + fullWidth: true, + singleSelection: { asPlainText: true }, + noSuggestions: false, + options: getCategorizedFieldNames(browserFields), + placeholder: THRESHOLD_FIELD_PLACEHOLDER, + onCreateOption: undefined, + style: { width: `${FIELD_COMBO_BOX_WIDTH}px` }, + }), + [browserFields] + ); + + return ( + + + + + {'>='} + + + + + ); +}; + +export const ThresholdInput = React.memo(ThresholdInputComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.ts new file mode 100644 index 0000000000000..228848ef12130 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.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 THRESHOLD_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.thresholdField.thresholdFieldPlaceholderText', + { + defaultMessage: 'All results', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx new file mode 100644 index 0000000000000..ce5d19259e9ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx @@ -0,0 +1,109 @@ +/* + * 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, { FormEvent } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { waitForUpdates } from '../../../common/utils/test_utils'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsForm } from './form'; +import { useImportList } from '../../../shared_imports'; + +jest.mock('../../../shared_imports'); +const mockUseImportList = useImportList as jest.Mock; + +const mockFile = ({ + name: 'foo.csv', + path: '/home/foo.csv', +} as unknown) as File; + +const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise = async ( + container, + file +) => { + const fileChange = container.find('EuiFilePicker').prop('onChange'); + act(() => { + if (fileChange) { + fileChange(([file] as unknown) as FormEvent); + } + }); + await waitForUpdates(container); + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).not.toEqual(true); +}; + +describe('ValueListsForm', () => { + let mockImportList: jest.Mock; + + beforeEach(() => { + mockImportList = jest.fn(); + mockUseImportList.mockImplementation(() => ({ + start: mockImportList, + })); + }); + + it('disables upload button when file is absent', () => { + const container = mount( + + + + ); + + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).toEqual(true); + }); + + it('calls importList when upload is clicked', async () => { + const container = mount( + + + + ); + + await mockSelectFile(container, mockFile); + + container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click'); + await waitForUpdates(container); + + expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile })); + }); + + it('calls onError if import fails', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + error: 'whoops', + })); + + const onError = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onError).toHaveBeenCalledWith('whoops'); + }); + + it('calls onSuccess if import succeeds', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + result: { mockResult: true }, + })); + + const onSuccess = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onSuccess).toHaveBeenCalledWith({ mockResult: true }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx new file mode 100644 index 0000000000000..b8416c3242e4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx @@ -0,0 +1,172 @@ +/* + * 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, { useCallback, useState, ReactNode, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiRadioGroup, +} from '@elastic/eui'; + +import { useImportList, ListSchema, Type } from '../../../shared_imports'; +import * as i18n from './translations'; +import { useKibana } from '../../../common/lib/kibana'; + +const InlineRadioGroup = styled(EuiRadioGroup)` + display: flex; + + .euiRadioGroup__item + .euiRadioGroup__item { + margin: 0 0 0 12px; + } +`; + +interface ListTypeOptions { + id: Type; + label: ReactNode; +} + +const options: ListTypeOptions[] = [ + { + id: 'keyword', + label: i18n.KEYWORDS_RADIO, + }, + { + id: 'ip', + label: i18n.IP_RADIO, + }, +]; + +const defaultListType: Type = 'keyword'; + +export interface ValueListsFormProps { + onError: (error: Error) => void; + onSuccess: (response: ListSchema) => void; +} + +export const ValueListsFormComponent: React.FC = ({ onError, onSuccess }) => { + const ctrl = useRef(new AbortController()); + const [files, setFiles] = useState(null); + const [type, setType] = useState(defaultListType); + const filePickerRef = useRef(null); + const { http } = useKibana().services; + const { start: importList, ...importState } = useImportList(); + + // EuiRadioGroup's onChange only infers 'string' from our options + const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]); + + const resetForm = useCallback(() => { + if (filePickerRef.current?.fileInput) { + filePickerRef.current.fileInput.value = ''; + filePickerRef.current.handleChange(); + } + setFiles(null); + setType(defaultListType); + }, [setType]); + + const handleCancel = useCallback(() => { + ctrl.current.abort(); + }, []); + + const handleSuccess = useCallback( + (response: ListSchema) => { + resetForm(); + onSuccess(response); + }, + [resetForm, onSuccess] + ); + const handleError = useCallback( + (error: Error) => { + onError(error); + }, + [onError] + ); + + const handleImport = useCallback(() => { + if (!importState.loading && files && files.length) { + ctrl.current = new AbortController(); + importList({ + file: files[0], + listId: undefined, + http, + signal: ctrl.current.signal, + type, + }); + } + }, [importState.loading, files, importList, http, type]); + + useEffect(() => { + if (!importState.loading && importState.result) { + handleSuccess(importState.result); + } else if (!importState.loading && importState.error) { + handleError(importState.error as Error); + } + }, [handleError, handleSuccess, importState.error, importState.loading, importState.result]); + + useEffect(() => { + return handleCancel; + }, [handleCancel]); + + return ( + + + + + + + + + + + + + + + + {importState.loading && ( + {i18n.CANCEL_BUTTON} + )} + + + + {i18n.UPLOAD_BUTTON} + + + + + + + + + ); +}; + +ValueListsFormComponent.displayName = 'ValueListsFormComponent'; + +export const ValueListsForm = React.memo(ValueListsFormComponent); + +ValueListsForm.displayName = 'ValueListsForm'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx new file mode 100644 index 0000000000000..1fbe0e312bd8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx @@ -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 { ValueListsModal } from './modal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx new file mode 100644 index 0000000000000..daf1cbd68df91 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.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 { mount } from 'enzyme'; + +import { TestProviders } from '../../../common/mock'; +import { ValueListsModal } from './modal'; +import { waitForUpdates } from '../../../common/utils/test_utils'; + +describe('ValueListsModal', () => { + it('renders nothing if showModal is false', () => { + const container = mount( + + + + ); + + expect(container.find('EuiModal')).toHaveLength(0); + }); + + it('renders modal if showModal is true', async () => { + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(container.find('EuiModal')).toHaveLength(1); + }); + + it('calls onClose when modal is closed', async () => { + const onClose = jest.fn(); + const container = mount( + + + + ); + + container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click'); + + await waitForUpdates(container); + + expect(onClose).toHaveBeenCalled(); + }); + + it('renders ValueListsForm and ValueListsTable', async () => { + const container = mount( + + + + ); + + await waitForUpdates(container); + + expect(container.find('ValueListsForm')).toHaveLength(1); + expect(container.find('ValueListsTable')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx new file mode 100644 index 0000000000000..0a935a9cdb1c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '@elastic/eui'; + +import { + ListSchema, + exportList, + useFindLists, + useDeleteList, + useCursor, +} from '../../../shared_imports'; +import { useToasts, useKibana } from '../../../common/lib/kibana'; +import { GenericDownloader } from '../../../common/components/generic_downloader'; +import * as i18n from './translations'; +import { ValueListsTable } from './table'; +import { ValueListsForm } from './form'; + +interface ValueListsModalProps { + onClose: () => void; + showModal: boolean; +} + +export const ValueListsModalComponent: React.FC = ({ + onClose, + showModal, +}) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [cursor, setCursor] = useCursor({ pageIndex, pageSize }); + const { http } = useKibana().services; + const { start: findLists, ...lists } = useFindLists(); + const { start: deleteList, result: deleteResult } = useDeleteList(); + const [exportListId, setExportListId] = useState(); + const toasts = useToasts(); + + const fetchLists = useCallback(() => { + findLists({ cursor, http, pageIndex: pageIndex + 1, pageSize }); + }, [cursor, http, findLists, pageIndex, pageSize]); + + const handleDelete = useCallback( + ({ id }: { id: string }) => { + deleteList({ http, id }); + }, + [deleteList, http] + ); + + useEffect(() => { + if (deleteResult != null) { + fetchLists(); + } + }, [deleteResult, fetchLists]); + + const handleExport = useCallback( + async ({ ids }: { ids: string[] }) => + exportList({ http, listId: ids[0], signal: new AbortController().signal }), + [http] + ); + const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []); + const handleExportComplete = useCallback(() => setExportListId(undefined), []); + + const handleTableChange = useCallback( + ({ page: { index, size } }: { page: { index: number; size: number } }) => { + setPageIndex(index); + setPageSize(size); + }, + [setPageIndex, setPageSize] + ); + const handleUploadError = useCallback( + (error: Error) => { + if (error.name !== 'AbortError') { + toasts.addError(error, { title: i18n.UPLOAD_ERROR }); + } + }, + [toasts] + ); + const handleUploadSuccess = useCallback( + (response: ListSchema) => { + toasts.addSuccess({ + text: i18n.uploadSuccessMessage(response.name), + title: i18n.UPLOAD_SUCCESS_TITLE, + }); + fetchLists(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [toasts] + ); + + useEffect(() => { + if (showModal) { + fetchLists(); + } + }, [showModal, fetchLists]); + + useEffect(() => { + if (!lists.loading && lists.result?.cursor) { + setCursor(lists.result.cursor); + } + }, [lists.loading, lists.result, setCursor]); + + if (!showModal) { + return null; + } + + const pagination = { + pageIndex, + pageSize, + totalItemCount: lists.result?.total ?? 0, + hidePerPageOptions: true, + }; + + return ( + + + + {i18n.MODAL_TITLE} + + + + + + + + + {i18n.CLOSE_BUTTON} + + + + + + ); +}; + +ValueListsModalComponent.displayName = 'ValueListsModalComponent'; + +export const ValueListsModal = React.memo(ValueListsModalComponent); + +ValueListsModal.displayName = 'ValueListsModal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx new file mode 100644 index 0000000000000..d0ed41ea58588 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsTable } from './table'; + +describe('ValueListsTable', () => { + it('renders a row for each list', () => { + const lists = Array(3).fill(getListResponseMock()); + const container = mount( + + + + ); + + expect(container.find('tbody tr')).toHaveLength(3); + }); + + it('calls onChange when pagination is modified', () => { + const lists = Array(6).fill(getListResponseMock()); + const onChange = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container.find('a[data-test-subj="pagination-button-next"]').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ page: expect.objectContaining({ index: 1 }) }) + ); + }); + + it('calls onExport when export is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onExport = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-export-value-list"]') + .simulate('click'); + }); + + expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); + + it('calls onDelete when delete is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onDelete = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-delete-value-list"]') + .simulate('click'); + }); + + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx new file mode 100644 index 0000000000000..07d52603a6fd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx @@ -0,0 +1,103 @@ +/* + * 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 { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui'; + +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { FormattedDate } from '../../../common/components/formatted_date'; +import * as i18n from './translations'; + +type TableProps = EuiBasicTableProps; +type ActionCallback = (item: ListSchema) => void; + +export interface ValueListsTableProps { + lists: TableProps['items']; + loading: boolean; + onChange: TableProps['onChange']; + onExport: ActionCallback; + onDelete: ActionCallback; + pagination: Exclude; +} + +const buildColumns = ( + onExport: ActionCallback, + onDelete: ActionCallback +): TableProps['columns'] => [ + { + field: 'name', + name: i18n.COLUMN_FILE_NAME, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.COLUMN_UPLOAD_DATE, + /* eslint-disable-next-line react/display-name */ + render: (value: ListSchema['created_at']) => ( + + ), + width: '30%', + }, + { + field: 'created_by', + name: i18n.COLUMN_CREATED_BY, + truncateText: true, + width: '20%', + }, + { + name: i18n.COLUMN_ACTIONS, + actions: [ + { + name: i18n.ACTION_EXPORT_NAME, + description: i18n.ACTION_EXPORT_DESCRIPTION, + icon: 'exportAction', + type: 'icon', + onClick: onExport, + 'data-test-subj': 'action-export-value-list', + }, + { + name: i18n.ACTION_DELETE_NAME, + description: i18n.ACTION_DELETE_DESCRIPTION, + icon: 'trash', + type: 'icon', + onClick: onDelete, + 'data-test-subj': 'action-delete-value-list', + }, + ], + width: '15%', + }, +]; + +export const ValueListsTableComponent: React.FC = ({ + lists, + loading, + onChange, + onExport, + onDelete, + pagination, +}) => { + const columns = buildColumns(onExport, onDelete); + return ( + + +

{i18n.TABLE_TITLE}

+ + + + ); +}; + +ValueListsTableComponent.displayName = 'ValueListsTableComponent'; + +export const ValueListsTable = React.memo(ValueListsTableComponent); + +ValueListsTable.displayName = 'ValueListsTable'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts new file mode 100644 index 0000000000000..dca6e43a98143 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts @@ -0,0 +1,138 @@ +/* + * 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 MODAL_TITLE = i18n.translate('xpack.securitySolution.lists.uploadValueListTitle', { + defaultMessage: 'Upload value lists', +}); + +export const FILE_PICKER_LABEL = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListDescription', + { + defaultMessage: 'Upload single value lists to use while writing rules or rule exceptions.', + } +); + +export const FILE_PICKER_PROMPT = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListPrompt', + { + defaultMessage: 'Select or drag and drop a file', + } +); + +export const CLOSE_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.closeValueListsModalTitle', + { + defaultMessage: 'Close', + } +); + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.cancelValueListsUploadTitle', + { + defaultMessage: 'Cancel upload', + } +); + +export const UPLOAD_BUTTON = i18n.translate('xpack.securitySolution.lists.valueListsUploadButton', { + defaultMessage: 'Upload list', +}); + +export const UPLOAD_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.lists.valueListsUploadSuccessTitle', + { + defaultMessage: 'Value list uploaded', + } +); + +export const UPLOAD_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsUploadError', { + defaultMessage: 'There was an error uploading the value list.', +}); + +export const uploadSuccessMessage = (fileName: string) => + i18n.translate('xpack.securitySolution.lists.valueListsUploadSuccess', { + defaultMessage: "Value list '{fileName}' was uploaded", + values: { fileName }, + }); + +export const COLUMN_FILE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.fileNameColumn', + { + defaultMessage: 'Filename', + } +); + +export const COLUMN_UPLOAD_DATE = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.uploadDateColumn', + { + defaultMessage: 'Upload Date', + } +); + +export const COLUMN_CREATED_BY = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.createdByColumn', + { + defaultMessage: 'Created by', + } +); + +export const COLUMN_ACTIONS = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.actionsColumn', + { + defaultMessage: 'Actions', + } +); + +export const ACTION_EXPORT_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionName', + { + defaultMessage: 'Export', + } +); + +export const ACTION_EXPORT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionDescription', + { + defaultMessage: 'Export value list', + } +); + +export const ACTION_DELETE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionName', + { + defaultMessage: 'Remove', + } +); + +export const ACTION_DELETE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionDescription', + { + defaultMessage: 'Remove value list', + } +); + +export const TABLE_TITLE = i18n.translate('xpack.securitySolution.lists.valueListsTable.title', { + defaultMessage: 'Value lists', +}); + +export const LIST_TYPES_RADIO_LABEL = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.listTypesRadioLabel', + { + defaultMessage: 'Type of value list', + } +); + +export const IP_RADIO = i18n.translate('xpack.securitySolution.lists.valueListsForm.ipRadioLabel', { + defaultMessage: 'IP addresses', +}); + +export const KEYWORDS_RADIO = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.keywordsRadioLabel', + { + defaultMessage: 'Keywords', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx new file mode 100644 index 0000000000000..0f8e0fba1e3af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx @@ -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 const useListsConfig = jest.fn().mockReturnValue({}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts new file mode 100644 index 0000000000000..8c72f092918c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.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 { i18n } from '@kbn/i18n'; + +export const LISTS_INDEX_FETCH_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.fetchListsIndex.errorDescription', + { + defaultMessage: 'Failed to retrieve the lists index', + } +); + +export const LISTS_INDEX_CREATE_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.createListsIndex.errorDescription', + { + defaultMessage: 'Failed to create the lists index', + } +); + +export const LISTS_PRIVILEGES_READ_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.readListsPrivileges.errorDescription', + { + defaultMessage: 'Failed to retrieve lists privileges', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx new file mode 100644 index 0000000000000..ea5e075811d4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.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 { useEffect } from 'react'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { useListsIndex } from './use_lists_index'; +import { useListsPrivileges } from './use_lists_privileges'; + +export interface UseListsConfigReturn { + canManageIndex: boolean | null; + canWriteIndex: boolean | null; + enabled: boolean; + loading: boolean; + needsConfiguration: boolean; +} + +export const useListsConfig = (): UseListsConfigReturn => { + const { createIndex, indexExists, loading: indexLoading } = useListsIndex(); + const { canManageIndex, canWriteIndex, loading: privilegesLoading } = useListsPrivileges(); + const { lists } = useKibana().services; + + const enabled = lists != null; + const loading = indexLoading || privilegesLoading; + const needsIndex = indexExists === false; + const needsConfiguration = !enabled || needsIndex || canWriteIndex === false; + + useEffect(() => { + if (canManageIndex && needsIndex) { + createIndex(); + } + }, [canManageIndex, createIndex, needsIndex]); + + return { canManageIndex, canWriteIndex, enabled, loading, needsConfiguration }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx new file mode 100644 index 0000000000000..a9497fd4971c1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useCallback } from 'react'; + +import { useReadListIndex, useCreateListIndex } from '../../../../shared_imports'; +import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana'; +import { isApiError } from '../../../../common/utils/api'; +import * as i18n from './translations'; + +export interface UseListsIndexState { + indexExists: boolean | null; +} + +export interface UseListsIndexReturn extends UseListsIndexState { + loading: boolean; + createIndex: () => void; +} + +export const useListsIndex = (): UseListsIndexReturn => { + const [state, setState] = useState({ + indexExists: null, + }); + const { lists } = useKibana().services; + const http = useHttp(); + const toasts = useToasts(); + const { loading: readLoading, start: readListIndex, ...readListIndexState } = useReadListIndex(); + const { + loading: createLoading, + start: createListIndex, + ...createListIndexState + } = useCreateListIndex(); + const loading = readLoading || createLoading; + + const readIndex = useCallback(() => { + if (lists) { + readListIndex({ http }); + } + }, [http, lists, readListIndex]); + + const createIndex = useCallback(() => { + if (lists) { + createListIndex({ http }); + } + }, [createListIndex, http, lists]); + + // initial read list + useEffect(() => { + if (!readLoading && state.indexExists === null) { + readIndex(); + } + }, [readIndex, readLoading, state.indexExists]); + + // handle read result + useEffect(() => { + if (readListIndexState.result != null) { + setState({ + indexExists: + readListIndexState.result.list_index && readListIndexState.result.list_item_index, + }); + } + }, [readListIndexState.result]); + + // refetch index after creation + useEffect(() => { + if (createListIndexState.result != null) { + readIndex(); + } + }, [createListIndexState.result, readIndex]); + + // handle read error + useEffect(() => { + const error = readListIndexState.error; + if (isApiError(error)) { + setState({ indexExists: false }); + if (error.body.status_code !== 404) { + toasts.addError(error, { + title: i18n.LISTS_INDEX_FETCH_FAILURE, + toastMessage: error.body.message, + }); + } + } + }, [readListIndexState.error, toasts]); + + // handle create error + useEffect(() => { + const error = createListIndexState.error; + if (isApiError(error)) { + toasts.addError(error, { + title: i18n.LISTS_INDEX_CREATE_FAILURE, + toastMessage: error.body.message, + }); + } + }, [createListIndexState.error, toasts]); + + return { loading, createIndex, ...state }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx new file mode 100644 index 0000000000000..fbbcff33402c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useCallback } from 'react'; + +import { useReadListPrivileges } from '../../../../shared_imports'; +import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana'; +import { isApiError } from '../../../../common/utils/api'; +import * as i18n from './translations'; + +export interface UseListsPrivilegesState { + isAuthenticated: boolean | null; + canManageIndex: boolean | null; + canWriteIndex: boolean | null; +} + +export interface UseListsPrivilegesReturn extends UseListsPrivilegesState { + loading: boolean; +} + +interface ListIndexPrivileges { + [indexName: string]: { + all: boolean; + create: boolean; + create_doc: boolean; + create_index: boolean; + delete: boolean; + delete_index: boolean; + index: boolean; + manage: boolean; + manage_follow_index: boolean; + manage_ilm: boolean; + manage_leader_index: boolean; + monitor: boolean; + read: boolean; + read_cross_cluster: boolean; + view_index_metadata: boolean; + write: boolean; + }; +} + +interface ListPrivileges { + is_authenticated: boolean; + lists: { + index: ListIndexPrivileges; + }; + listItems: { + index: ListIndexPrivileges; + }; +} + +const canManageIndex = (indexPrivileges: ListIndexPrivileges): boolean => { + const [indexName] = Object.keys(indexPrivileges); + const privileges = indexPrivileges[indexName]; + if (privileges == null) { + return false; + } + return privileges.manage; +}; + +const canWriteIndex = (indexPrivileges: ListIndexPrivileges): boolean => { + const [indexName] = Object.keys(indexPrivileges); + const privileges = indexPrivileges[indexName]; + if (privileges == null) { + return false; + } + + return privileges.create || privileges.create_doc || privileges.index || privileges.write; +}; + +export const useListsPrivileges = (): UseListsPrivilegesReturn => { + const [state, setState] = useState({ + isAuthenticated: null, + canManageIndex: null, + canWriteIndex: null, + }); + const { lists } = useKibana().services; + const http = useHttp(); + const toasts = useToasts(); + const { loading, start: readListPrivileges, ...privilegesState } = useReadListPrivileges(); + + const readPrivileges = useCallback(() => { + if (lists) { + readListPrivileges({ http }); + } + }, [http, lists, readListPrivileges]); + + // initRead + useEffect(() => { + if (!loading && state.isAuthenticated === null) { + readPrivileges(); + } + }, [loading, readPrivileges, state.isAuthenticated]); + + // handleReadResult + useEffect(() => { + if (privilegesState.result != null) { + try { + const { + is_authenticated: isAuthenticated, + lists: { index: listsPrivileges }, + listItems: { index: listItemsPrivileges }, + } = privilegesState.result as ListPrivileges; + + setState({ + isAuthenticated, + canManageIndex: canManageIndex(listsPrivileges) && canManageIndex(listItemsPrivileges), + canWriteIndex: canWriteIndex(listsPrivileges) && canWriteIndex(listItemsPrivileges), + }); + } catch (e) { + setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false }); + } + } + }, [privilegesState.result]); + + // handleReadError + useEffect(() => { + const error = privilegesState.error; + if (isApiError(error)) { + setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false }); + toasts.addError(error, { + title: i18n.LISTS_PRIVILEGES_READ_FAILURE, + toastMessage: error.body.message, + }); + } + }, [privilegesState.error, toasts]); + + return { loading, ...state }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index 3275391f3f074..f12a5d523bade 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -34,6 +34,9 @@ export const getPrePackagedRulesStatus = async ({ rules_installed: 12, rules_not_installed: 0, rules_not_updated: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }); export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts index 2b4b32bce9c7b..f878b40b99dc3 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts @@ -6,10 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const RULE_FETCH_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.rules', +export const RULE_AND_TIMELINE_FETCH_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.rulesAndTimelines', { - defaultMessage: 'Failed to fetch Rules', + defaultMessage: 'Failed to fetch Rules and Timelines', } ); @@ -20,17 +20,17 @@ export const RULE_ADD_FAILURE = i18n.translate( } ); -export const RULE_PREPACKAGED_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleFailDescription', +export const RULE_AND_TIMELINE_PREPACKAGED_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleAndTimelineFailDescription', { - defaultMessage: 'Failed to installed pre-packaged rules from elastic', + defaultMessage: 'Failed to installed pre-packaged rules and timelines from elastic', } ); -export const RULE_PREPACKAGED_SUCCESS = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleSuccesDescription', +export const RULE_AND_TIMELINE_PREPACKAGED_SUCCESS = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleAndTimelineSuccesDescription', { - defaultMessage: 'Installed pre-packaged rules from elastic', + defaultMessage: 'Installed pre-packaged rules and timelines from elastic', } ); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index c03d19eaf771e..1f75ff0210bd5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -16,6 +16,7 @@ import { rule_name_override, severity_mapping, timestamp_override, + threshold, } from '../../../../../common/detection_engine/schemas/common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { @@ -65,6 +66,7 @@ export const NewRuleSchema = t.intersection([ saved_id: t.string, tags: t.array(t.string), threat: t.array(t.unknown), + threshold, throttle: t.union([t.string, t.null]), to: t.string, updated_by: t.string, @@ -142,6 +144,7 @@ export const RuleSchema = t.intersection([ saved_id: t.string, status: t.string, status_date: t.string, + threshold, timeline_id: t.string, timeline_title: t.string, timestamp_override, @@ -273,4 +276,7 @@ export interface PrePackagedRulesStatusResponse { rules_installed: number; rules_not_installed: number; rules_not_updated: number; + timelines_installed: number; + timelines_not_installed: number; + timelines_not_updated: number; } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx index 4d9e283bfb9cc..9a6ea4f60fdcc 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx @@ -5,7 +5,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { ReturnPrePackagedRules, usePrePackagedRules } from './use_pre_packaged_rules'; +import { ReturnPrePackagedRulesAndTimelines, usePrePackagedRules } from './use_pre_packaged_rules'; import * as api from './api'; jest.mock('./api'); @@ -18,14 +18,15 @@ describe('usePersistRule', () => { test('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: null, - hasIndexWrite: null, - isAuthenticated: null, - hasEncryptionKey: null, - isSignalIndexExists: null, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) ); await waitForNextUpdate(); @@ -39,20 +40,24 @@ describe('usePersistRule', () => { rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); }); }); test('fetch getPrePackagedRulesStatus', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: null, - hasIndexWrite: null, - isAuthenticated: null, - hasEncryptionKey: null, - isSignalIndexExists: null, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -66,6 +71,9 @@ describe('usePersistRule', () => { rulesInstalled: 12, rulesNotInstalled: 0, rulesNotUpdated: 0, + timelinesInstalled: 0, + timelinesNotInstalled: 0, + timelinesNotUpdated: 0, }); }); }); @@ -73,14 +81,15 @@ describe('usePersistRule', () => { test('happy path to createPrePackagedRules', async () => { const spyOnCreatePrepackagedRules = jest.spyOn(api, 'createPrepackagedRules'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -99,6 +108,9 @@ describe('usePersistRule', () => { rulesInstalled: 12, rulesNotInstalled: 0, rulesNotUpdated: 0, + timelinesInstalled: 0, + timelinesNotInstalled: 0, + timelinesNotUpdated: 0, }); }); }); @@ -109,14 +121,15 @@ describe('usePersistRule', () => { throw new Error('Something went wrong'); }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -131,14 +144,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because canUserCrud === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: false, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: false, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -152,14 +166,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because hasIndexWrite === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: false, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: false, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -173,14 +188,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because isAuthenticated === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: false, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: false, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -194,14 +210,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because hasEncryptionKey === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: false, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: false, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -215,14 +232,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because isSignalIndexExists === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: false, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: false, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx index 5f5ee53c29caf..08c85695e9313 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -16,7 +16,14 @@ import * as i18n from './translations'; type Func = () => void; export type CreatePreBuiltRules = () => Promise; -export interface ReturnPrePackagedRules { + +interface ReturnPrePackagedTimelines { + timelinesInstalled: number | null; + timelinesNotInstalled: number | null; + timelinesNotUpdated: number | null; +} + +interface ReturnPrePackagedRules { createPrePackagedRules: null | CreatePreBuiltRules; loading: boolean; loadingCreatePrePackagedRules: boolean; @@ -27,6 +34,9 @@ export interface ReturnPrePackagedRules { rulesNotUpdated: number | null; } +export type ReturnPrePackagedRulesAndTimelines = ReturnPrePackagedRules & + ReturnPrePackagedTimelines; + interface UsePrePackagedRuleProps { canUserCRUD: boolean | null; hasIndexWrite: boolean | null; @@ -50,16 +60,19 @@ export const usePrePackagedRules = ({ isAuthenticated, hasEncryptionKey, isSignalIndexExists, -}: UsePrePackagedRuleProps): ReturnPrePackagedRules => { - const [rulesStatus, setRuleStatus] = useState< +}: UsePrePackagedRuleProps): ReturnPrePackagedRulesAndTimelines => { + const [prepackagedDataStatus, setPrepackagedDataStatus] = useState< Pick< - ReturnPrePackagedRules, + ReturnPrePackagedRulesAndTimelines, | 'createPrePackagedRules' | 'refetchPrePackagedRulesStatus' | 'rulesCustomInstalled' | 'rulesInstalled' | 'rulesNotInstalled' | 'rulesNotUpdated' + | 'timelinesInstalled' + | 'timelinesNotInstalled' + | 'timelinesNotUpdated' > >({ createPrePackagedRules: null, @@ -68,7 +81,11 @@ export const usePrePackagedRules = ({ rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); + const [loadingCreatePrePackagedRules, setLoadingCreatePrePackagedRules] = useState(false); const [loading, setLoading] = useState(true); const [, dispatchToaster] = useStateToaster(); @@ -85,26 +102,33 @@ export const usePrePackagedRules = ({ }); if (isSubscribed) { - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: createElasticRules, refetchPrePackagedRulesStatus: fetchPrePackagedRules, rulesCustomInstalled: prePackagedRuleStatusResponse.rules_custom_installed, rulesInstalled: prePackagedRuleStatusResponse.rules_installed, rulesNotInstalled: prePackagedRuleStatusResponse.rules_not_installed, rulesNotUpdated: prePackagedRuleStatusResponse.rules_not_updated, + timelinesInstalled: prePackagedRuleStatusResponse.timelines_installed, + timelinesNotInstalled: prePackagedRuleStatusResponse.timelines_not_installed, + timelinesNotUpdated: prePackagedRuleStatusResponse.timelines_not_updated, }); } } catch (error) { if (isSubscribed) { - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: null, refetchPrePackagedRulesStatus: null, rulesCustomInstalled: null, rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -149,15 +173,22 @@ export const usePrePackagedRules = ({ iterationTryOfFetchingPrePackagedCount > 100) ) { setLoadingCreatePrePackagedRules(false); - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: createElasticRules, refetchPrePackagedRulesStatus: fetchPrePackagedRules, rulesCustomInstalled: prePackagedRuleStatusResponse.rules_custom_installed, rulesInstalled: prePackagedRuleStatusResponse.rules_installed, rulesNotInstalled: prePackagedRuleStatusResponse.rules_not_installed, rulesNotUpdated: prePackagedRuleStatusResponse.rules_not_updated, + timelinesInstalled: prePackagedRuleStatusResponse.timelines_installed, + timelinesNotInstalled: prePackagedRuleStatusResponse.timelines_not_installed, + timelinesNotUpdated: prePackagedRuleStatusResponse.timelines_not_updated, }); - displaySuccessToast(i18n.RULE_PREPACKAGED_SUCCESS, dispatchToaster); + + displaySuccessToast( + i18n.RULE_AND_TIMELINE_PREPACKAGED_SUCCESS, + dispatchToaster + ); stopTimeOut(); resolve(true); } else { @@ -172,7 +203,11 @@ export const usePrePackagedRules = ({ } catch (error) { if (isSubscribed) { setLoadingCreatePrePackagedRules(false); - errorToToaster({ title: i18n.RULE_PREPACKAGED_FAILURE, error, dispatchToaster }); + errorToToaster({ + title: i18n.RULE_AND_TIMELINE_PREPACKAGED_FAILURE, + error, + dispatchToaster, + }); resolve(false); } } @@ -191,6 +226,6 @@ export const usePrePackagedRules = ({ return { loading, loadingCreatePrePackagedRules, - ...rulesStatus, + ...prepackagedDataStatus, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx index 3256273fb8425..706c2645a4ddd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx @@ -41,7 +41,7 @@ export const useRule = (id: string | undefined): ReturnRule => { } catch (error) { if (isSubscribed) { setRule(null); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index ec1da29de4ba8..0e96f58ee6874 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -49,7 +49,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = } catch (error) { if (isSubscribed) { setRuleStatus(null); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -106,7 +106,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { } catch (error) { if (isSubscribed) { setRuleStatuses([]); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx index 1a1dbc6e2b368..3466472ad7276 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx @@ -67,7 +67,7 @@ export const useRules = ({ } } catch (error) { if (isSubscribed) { - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); if (dispatchRulesInReducer != null) { dispatchRulesInReducer([], {}); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index fa7c85c95d87b..d5aa57ddd8754 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -14,6 +14,7 @@ import { DetectionEnginePageComponent } from './detection_engine'; import { useUserInfo } from '../../components/user_info'; import { useWithSource } from '../../../common/containers/source'; +jest.mock('../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../components/user_info'); jest.mock('../../../common/containers/source'); jest.mock('../../../common/components/link_to'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 11f738320db6e..84cfc744312f9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -34,6 +34,7 @@ import { useUserInfo } from '../../components/user_info'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; +import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; import { LinkButton } from '../../../common/components/links'; @@ -46,7 +47,7 @@ export const DetectionEnginePageComponent: React.FC = ({ }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated: isUserAuthenticated, hasEncryptionKey, @@ -54,9 +55,14 @@ export const DetectionEnginePageComponent: React.FC = ({ signalIndexName, hasIndexWrite, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); const history = useHistory(); const [lastAlerts] = useAlertInfo({}); const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const loading = userInfoLoading || listsConfigLoading; const updateDateRangeCallback = useCallback( ({ x }) => { @@ -90,7 +96,8 @@ export const DetectionEnginePageComponent: React.FC = ({ ); } - if (isSignalIndexExists != null && !isSignalIndexExists && !loading) { + + if (!loading && (isSignalIndexExists === false || needsListsConfiguration)) { return ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 2b86abf4255c6..5d84cf5314029 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -153,6 +153,10 @@ export const mockRuleWithEverything = (id: string): Rule => ({ ], }, ], + threshold: { + field: 'host.name', + value: 50, + }, throttle: 'no_actions', timestamp_override: 'event.ingested', note: '# this is some markdown documentation', @@ -213,6 +217,10 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', }, + threshold: { + field: [''], + value: '100', + }, }); export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 8331346b19ac9..4bb7196e17db5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -51,19 +51,29 @@ export interface RuleFields { queryBar: unknown; index: unknown; ruleType: unknown; + threshold?: unknown; } -type QueryRuleFields = Omit; +type QueryRuleFields = Omit; +type ThresholdRuleFields = Omit; type MlRuleFields = Omit; -const isMlFields = (fields: QueryRuleFields | MlRuleFields): fields is MlRuleFields => - has('anomalyThreshold', fields); +const isMlFields = ( + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields +): fields is MlRuleFields => has('anomalyThreshold', fields); + +const isThresholdFields = ( + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields +): fields is ThresholdRuleFields => has('threshold', fields); export const filterRuleFieldsForType = (fields: T, type: RuleType) => { if (isMlRule(type)) { const { index, queryBar, ...mlRuleFields } = fields; return mlRuleFields; + } else if (type === 'threshold') { + const { anomalyThreshold, machineLearningJobId, ...thresholdRuleFields } = fields; + return thresholdRuleFields; } else { - const { anomalyThreshold, machineLearningJobId, ...queryRuleFields } = fields; + const { anomalyThreshold, machineLearningJobId, threshold, ...queryRuleFields } = fields; return queryRuleFields; } }; @@ -85,6 +95,20 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep anomaly_threshold: ruleFields.anomalyThreshold, machine_learning_job_id: ruleFields.machineLearningJobId, } + : isThresholdFields(ruleFields) + ? { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'threshold' && { + threshold: { + field: ruleFields.threshold?.field[0] ?? '', + value: parseInt(ruleFields.threshold?.value, 10) ?? 0, + }, + }), + } : { index: ruleFields.index, filters: ruleFields.queryBar?.filters, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx index b7a2d017c3666..f7430a56c74d3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => { }; }); +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 6475b6f6b6b54..f6e13786e98d0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -10,6 +10,7 @@ import { useHistory } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { getRulesUrl, @@ -84,12 +85,17 @@ StepDefineRuleAccordion.displayName = 'StepDefineRuleAccordion'; const CreateRulePageComponent: React.FC = () => { const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); @@ -278,7 +284,14 @@ const CreateRulePageComponent: React.FC = () => { return null; } - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } else if (userHasNoPermissions(canUserCRUD)) { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index 11099e8cfc755..0a42602e5fbb2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -15,6 +15,7 @@ import { useUserInfo } from '../../../../components/user_info'; import { useWithSource } from '../../../../../common/containers/source'; import { useParams } from 'react-router-dom'; +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); jest.mock('../../../../../common/containers/source'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 6ab08d94fa781..c74a2a3cf993a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -34,6 +34,7 @@ import { import { SiemSearchBar } from '../../../../../common/components/search_bar'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { useRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { useWithSource } from '../../../../../common/containers/source'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; @@ -105,7 +106,7 @@ export const RuleDetailsPageComponent: FC = ({ }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, @@ -113,6 +114,11 @@ export const RuleDetailsPageComponent: FC = ({ hasIndexWrite, signalIndexName, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams(); const [isLoading, rule] = useRule(ruleId); // This is used to re-trigger api rule status when user de/activate rule @@ -282,7 +288,14 @@ export const RuleDetailsPageComponent: FC = ({ } }, [rule]); - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx index d754329bdd97f..71930e1523549 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx @@ -12,6 +12,7 @@ import { EditRulePage } from './index'; import { useUserInfo } from '../../../../components/user_info'; import { useParams } from 'react-router-dom'; +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 777f7766993d0..87cb5e77697b5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -20,6 +20,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } fr import { useParams, useHistory } from 'react-router-dom'; import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { getRuleDetailsUrl, @@ -74,12 +75,17 @@ const EditRulePageComponent: FC = () => { const history = useHistory(); const [, dispatchToaster] = useStateToaster(); const { - loading: initLoading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const initLoading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams(); const [loading, rule] = useRule(ruleId); @@ -365,7 +371,14 @@ const EditRulePageComponent: FC = () => { return null; } - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } else if (userHasNoPermissions(canUserCRUD)) { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index f8969f06c8ef6..590643f8236ee 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -74,6 +74,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + threshold: { + field: ['host.name'], + value: '50', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', @@ -206,6 +210,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + threshold: { + field: [], + value: '100', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', @@ -235,6 +243,10 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + threshold: { + field: [], + value: '100', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index bf49ed5be90fb..6541b92f575c1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -84,6 +84,10 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ id: rule.timeline_id ?? null, title: rule.timeline_title ?? null, }, + threshold: { + field: rule.threshold?.field ? [rule.threshold.field] : [], + value: `${rule.threshold?.value || 100}`, + }, }); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { @@ -182,6 +186,13 @@ export type PrePackagedRuleStatus = | 'someRuleUninstall' | 'unknown'; +export type PrePackagedTimelineStatus = + | 'timelinesNotInstalled' + | 'timelinesInstalled' + | 'someTimelineUninstall' + | 'timelineNeedUpdate' + | 'unknown'; + export const getPrePackagedRuleStatus = ( rulesInstalled: number | null, rulesNotInstalled: number | null, @@ -221,6 +232,45 @@ export const getPrePackagedRuleStatus = ( } return 'unknown'; }; +export const getPrePackagedTimelineStatus = ( + timelinesInstalled: number | null, + timelinesNotInstalled: number | null, + timelinesNotUpdated: number | null +): PrePackagedTimelineStatus => { + if ( + timelinesNotInstalled != null && + timelinesInstalled === 0 && + timelinesNotInstalled > 0 && + timelinesNotUpdated === 0 + ) { + return 'timelinesNotInstalled'; + } else if ( + timelinesInstalled != null && + timelinesInstalled > 0 && + timelinesNotInstalled === 0 && + timelinesNotUpdated === 0 + ) { + return 'timelinesInstalled'; + } else if ( + timelinesInstalled != null && + timelinesNotInstalled != null && + timelinesInstalled > 0 && + timelinesNotInstalled > 0 && + timelinesNotUpdated === 0 + ) { + return 'someTimelineUninstall'; + } else if ( + timelinesInstalled != null && + timelinesNotInstalled != null && + timelinesNotUpdated != null && + timelinesInstalled > 0 && + timelinesNotInstalled >= 0 && + timelinesNotUpdated > 0 + ) { + return 'timelineNeedUpdate'; + } + return 'unknown'; +}; export const setFieldValue = ( form: FormHook, schema: FormSchema, @@ -236,12 +286,27 @@ export const setFieldValue = ( export const redirectToDetections = ( isSignalIndexExists: boolean | null, isAuthenticated: boolean | null, - hasEncryptionKey: boolean | null + hasEncryptionKey: boolean | null, + needsListsConfiguration: boolean ) => - isSignalIndexExists != null && - isAuthenticated != null && - hasEncryptionKey != null && - (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey); + isSignalIndexExists === false || + isAuthenticated === false || + hasEncryptionKey === false || + needsListsConfiguration; + +const getRuleSpecificRuleParamKeys = (ruleType: RuleType) => { + const queryRuleParams = ['index', 'filters', 'language', 'query', 'saved_id']; + + if (isMlRule(ruleType)) { + return ['anomaly_threshold', 'machine_learning_job_id']; + } + + if (ruleType === 'threshold') { + return ['threshold', ...queryRuleParams]; + } + + return queryRuleParams; +}; export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { const commonRuleParamsKeys = [ @@ -265,9 +330,7 @@ export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { const ruleParamsKeys = [ ...commonRuleParamsKeys, - ...(isMlRule(ruleType) - ? ['anomaly_threshold', 'machine_learning_job_id'] - : ['index', 'filters', 'language', 'query', 'saved_id']), + ...getRuleSpecificRuleParamKeys(ruleType), ].sort(); return ruleParamsKeys; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx index f0ad670ddb665..9e30a735367b3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => { }; }); +jest.mock('../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../common/components/link_to'); jest.mock('../../../components/user_info'); jest.mock('../../../containers/detection_engine/rules'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 9cbc0e2aabfbe..c1d8436a7230e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config'; import { getDetectionEngineUrl, getCreateRuleUrl, @@ -21,8 +22,14 @@ import { useUserInfo } from '../../../components/user_info'; import { AllRules } from './all'; import { ImportDataModal } from '../../../../common/components/import_data_modal'; import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout'; +import { ValueListsModal } from '../../../components/value_lists_management_modal'; import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout'; -import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers'; +import { + getPrePackagedRuleStatus, + getPrePackagedTimelineStatus, + redirectToDetections, + userHasNoPermissions, +} from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { LinkButton } from '../../../../common/components/links'; @@ -33,15 +40,23 @@ type Func = (refreshPrePackagedRule?: boolean) => void; const RulesPageComponent: React.FC = () => { const history = useHistory(); const [showImportModal, setShowImportModal] = useState(false); + const [isValueListsModalShown, setIsValueListsModalShown] = useState(false); + const showValueListsModal = useCallback(() => setIsValueListsModalShown(true), []); + const hideValueListsModal = useCallback(() => setIsValueListsModalShown(false), []); const refreshRulesData = useRef(null); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, hasIndexWrite, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const { createPrePackagedRules, loading: prePackagedRuleLoading, @@ -51,6 +66,9 @@ const RulesPageComponent: React.FC = () => { rulesInstalled, rulesNotInstalled, rulesNotUpdated, + timelinesInstalled, + timelinesNotInstalled, + timelinesNotUpdated, } = usePrePackagedRules({ canUserCRUD, hasIndexWrite, @@ -63,6 +81,12 @@ const RulesPageComponent: React.FC = () => { rulesNotInstalled, rulesNotUpdated ); + + const prePackagedTimelineStatus = getPrePackagedTimelineStatus( + timelinesInstalled, + timelinesNotInstalled, + timelinesNotUpdated + ); const { formatUrl } = useFormatUrl(SecurityPageName.detections); const handleRefreshRules = useCallback(async () => { @@ -88,6 +112,18 @@ const RulesPageComponent: React.FC = () => { refreshRulesData.current = refreshRule; }, []); + const getMissingRulesOrTimelinesButtonTitle = useCallback( + (missingRules: number, missingTimelines: number) => { + if (missingRules > 0 && missingTimelines === 0) + return i18n.RELOAD_MISSING_PREPACKAGED_RULES(missingRules); + else if (missingRules === 0 && missingTimelines > 0) + return i18n.RELOAD_MISSING_PREPACKAGED_TIMELINES(missingTimelines); + else if (missingRules > 0 && missingTimelines > 0) + return i18n.RELOAD_MISSING_PREPACKAGED_RULES_AND_TIMELINES(missingRules, missingTimelines); + }, + [] + ); + const goToNewRule = useCallback( (ev) => { ev.preventDefault(); @@ -96,7 +132,14 @@ const RulesPageComponent: React.FC = () => { [history] ); - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } @@ -104,6 +147,7 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions(canUserCRUD) && } + setShowImportModal(false)} @@ -129,7 +173,8 @@ const RulesPageComponent: React.FC = () => { title={i18n.PAGE_TITLE} > - {prePackagedRuleStatus === 'ruleNotInstalled' && ( + {(prePackagedRuleStatus === 'ruleNotInstalled' || + prePackagedTimelineStatus === 'timelinesNotInstalled') && ( { )} - {prePackagedRuleStatus === 'someRuleUninstall' && ( + {(prePackagedRuleStatus === 'someRuleUninstall' || + prePackagedTimelineStatus === 'someTimelineUninstall') && ( { isDisabled={userHasNoPermissions(canUserCRUD) || loading} onClick={handleCreatePrePackagedRules} > - {i18n.RELOAD_MISSING_PREPACKAGED_RULES(rulesNotInstalled ?? 0)} + {getMissingRulesOrTimelinesButtonTitle( + rulesNotInstalled ?? 0, + timelinesNotInstalled ?? 0 + )} )} + + + {i18n.UPLOAD_VALUE_LISTS} + + { - {prePackagedRuleStatus === 'ruleNeedUpdate' && ( + {(prePackagedRuleStatus === 'ruleNeedUpdate' || + prePackagedTimelineStatus === 'timelineNeedUpdate') && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 050c281d09350..4f292b1bbbab8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -20,6 +20,13 @@ export const IMPORT_RULE = i18n.translate( } ); +export const UPLOAD_VALUE_LISTS = i18n.translate( + 'xpack.securitySolution.lists.detectionEngine.rules.uploadValueListsButton', + { + defaultMessage: 'Upload value lists', + } +); + export const ADD_NEW_RULE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.addNewRuleTitle', { @@ -461,7 +468,7 @@ export const DELETE = i18n.translate( export const LOAD_PREPACKAGED_RULES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.loadPrePackagedRulesButton', { - defaultMessage: 'Load Elastic prebuilt rules', + defaultMessage: 'Load Elastic prebuilt rules and timeline templates', } ); @@ -475,6 +482,29 @@ export const RELOAD_MISSING_PREPACKAGED_RULES = (missingRules: number) => } ); +export const RELOAD_MISSING_PREPACKAGED_TIMELINES = (missingTimelines: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.reloadMissingPrePackagedTimelinesButton', + { + values: { missingTimelines }, + defaultMessage: + 'Install {missingTimelines} Elastic prebuilt {missingTimelines, plural, =1 {timeline} other {timelines}} ', + } + ); + +export const RELOAD_MISSING_PREPACKAGED_RULES_AND_TIMELINES = ( + missingRules: number, + missingTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.reloadMissingPrePackagedRulesAndTimelinesButton', + { + values: { missingRules, missingTimelines }, + defaultMessage: + 'Install {missingRules} Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} and {missingTimelines} Elastic prebuilt {missingTimelines, plural, =1 {timeline} other {timelines}} ', + } + ); + export const IMPORT_RULE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.components.importRuleModal.importRuleTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index f453b5a95994d..e7daff0947b0d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -10,6 +10,7 @@ import { Filter } from '../../../../../../../../src/plugins/data/common'; import { FormData, FormHook } from '../../../../shared_imports'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; +import { FieldValueThreshold } from '../../../components/rules/threshold_input'; import { Author, BuildingBlockType, @@ -99,6 +100,7 @@ export interface DefineStepRule extends StepRuleData { queryBar: FieldValueQueryBar; ruleType: RuleType; timeline: FieldValueTimeline; + threshold: FieldValueThreshold; } export interface ScheduleStepRule extends StepRuleData { @@ -122,6 +124,10 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + threshold?: { + field: string; + value: number; + }; timeline_id?: string; timeline_title?: string; type: RuleType; diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 20978fa3b063c..43c478ff120a0 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -4750,6 +4750,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "threshold", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToAny", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "exceptions_list", "description": "", @@ -10593,21 +10601,13 @@ { "name": "pageIndex", "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "defaultValue": null }, { "name": "pageSize", "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "defaultValue": null } ], diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 27aa02038097e..084d1a63fec75 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -96,9 +96,9 @@ export interface TlsSortField { } export interface PageInfoTimeline { - pageIndex: number; + pageIndex?: Maybe; - pageSize: number; + pageSize?: Maybe; } export interface SortTimeline { @@ -1070,6 +1070,8 @@ export interface RuleField { note?: Maybe; + threshold?: Maybe; + exceptions_list?: Maybe; } @@ -5066,6 +5068,10 @@ export namespace GetTimelineQuery { note: Maybe; + type: Maybe; + + threshold: Maybe; + exceptions_list: Maybe; }; diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index e55fe13e6c9a0..2b37e2b7bf106 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -4,48 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - useApi, - useExceptionList, - usePersistExceptionItem, - usePersistExceptionList, - useFindLists, - addExceptionListItem, - updateExceptionListItem, - fetchExceptionListById, - addExceptionList, - ExceptionIdentifiers, - ExceptionList, - Pagination, - UseExceptionListSuccess, -} from '../../lists/public'; -export { - ListSchema, - CommentsArray, - CreateCommentsArray, - Comments, - CreateComments, - ExceptionListSchema, - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, - Entry, - EntryExists, - EntryNested, - EntryList, - EntriesArray, - NamespaceType, - Operator, - OperatorEnum, - OperatorType, - OperatorTypeEnum, - ExceptionListTypeEnum, - exceptionListItemSchema, - createExceptionListItemSchema, - listSchema, - entry, - entriesNested, - entriesExists, - entriesList, - ExceptionListType, -} from '../../lists/common/schemas'; +// DEPRECATED: Do not add exports to this file; please import from shared_imports instead + +export * from './shared_imports'; diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 4bc586bdee8a9..b07c47a398049 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -3,16 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ManagementStoreGlobalNamespace, ManagementSubTab } from '../types'; +import { ManagementStoreGlobalNamespace, AdministrationSubTab } from '../types'; import { APP_ID } from '../../../common/constants'; import { SecurityPageName } from '../../app/types'; // --[ ROUTING ]--------------------------------------------------------------------------- -export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.management}`; +export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.administration}`; export const MANAGEMENT_ROUTING_ROOT_PATH = ''; -export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.hosts})`; -export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})`; -export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})/:policyId`; +export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.hosts})`; +export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5add6b753a7a9..3636358ebe842 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -14,7 +14,7 @@ import { MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, } from './constants'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { HostIndexUIQueryParams } from '../pages/endpoint_hosts/types'; @@ -47,7 +47,7 @@ export const getHostListPath = ( if (name === 'hostList') { return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; } return `${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; @@ -65,17 +65,17 @@ export const getHostDetailsPath = ( const urlSearch = `${urlQueryParams && !isEmpty(search) ? '&' : ''}${search ?? ''}`; return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; }; export const getPoliciesPath = (search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, })}${appendSearch(search)}`; export const getPolicyDetailPath = (policyId: string, search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, policyId, })}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts new file mode 100644 index 0000000000000..70ccf715eaa09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/common/translations.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. + */ + +import { i18n } from '@kbn/i18n'; + +export const HOSTS_TAB = i18n.translate('xpack.securitySolution.hostsTab', { + defaultMessage: 'Hosts', +}); + +export const POLICIES_TAB = i18n.translate('xpack.securitySolution.policiesTab', { + defaultMessage: 'Policies', +}); diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 6486b1f3be6d1..fb9f97f3f7570 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -18,14 +18,21 @@ import { EuiSelectableProps, EuiIcon, EuiLoadingSpinner, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import onboardingLogo from '../images/security_administration_onboarding.svg'; const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({ textAlign: 'center', }); +const MAX_SIZE_ONBOARDING_LOGO: CSSProperties = Object.freeze({ + maxWidth: 550, + maxHeight: 420, +}); + interface ManagementStep { title: string; children: JSX.Element; @@ -45,7 +52,7 @@ const PolicyEmptyState = React.memo<{ ) : ( - +

@@ -55,26 +62,26 @@ const PolicyEmptyState = React.memo<{ />

- + - + - - + + - + @@ -91,14 +98,14 @@ const PolicyEmptyState = React.memo<{ - + @@ -120,14 +127,20 @@ const PolicyEmptyState = React.memo<{ - + + + + - + - + )} diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index 8495628709d2a..42341b524362d 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -8,15 +8,15 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { SecurityPageName } from '../../app/types'; import { useFormatUrl } from '../../common/components/link_to'; import { getHostListPath, getPoliciesPath } from '../common/routing'; import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; export const ManagementPageView = memo>((options) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); - const { tabName } = useParams<{ tabName: ManagementSubTab }>(); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); + const { tabName } = useParams<{ tabName: AdministrationSubTab }>(); const goToEndpoint = useNavigateByRouterEventHandler( getHostListPath({ name: 'hostList' }, search) @@ -30,11 +30,11 @@ export const ManagementPageView = memo>((options) => } return [ { - name: i18n.translate('xpack.securitySolution.managementTabs.endpoints', { + name: i18n.translate('xpack.securitySolution.managementTabs.hosts', { defaultMessage: 'Hosts', }), - id: ManagementSubTab.hosts, - isSelected: tabName === ManagementSubTab.hosts, + id: AdministrationSubTab.hosts, + isSelected: tabName === AdministrationSubTab.hosts, href: formatUrl(getHostListPath({ name: 'hostList' })), onClick: goToEndpoint, }, @@ -42,8 +42,8 @@ export const ManagementPageView = memo>((options) => name: i18n.translate('xpack.securitySolution.managementTabs.policies', { defaultMessage: 'Policies', }), - id: ManagementSubTab.policies, - isSelected: tabName === ManagementSubTab.policies, + id: AdministrationSubTab.policies, + isSelected: tabName === AdministrationSubTab.policies, href: formatUrl(getPoliciesPath()), onClick: goToPolicies, }, diff --git a/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg b/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg new file mode 100644 index 0000000000000..33bdae381fc1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 10ea271139e49..62efa621e6e3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -61,7 +61,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatus = useHostSelector( policyResponseStatus ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { return [ @@ -106,7 +106,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { path: agentDetailsWithFlyoutPath, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostDetailsPath({ name: 'hostDetails', selected_host: details.host.id }), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index e29d796325bd6..71b3885308558 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -118,7 +118,7 @@ const PolicyResponseFlyoutPanel = memo<{ const responseAttentionCount = useHostSelector(policyResponseFailedOrWarningActionCount); const loading = useHostSelector(policyResponseLoading); const error = useHostSelector(policyResponseError); - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const [detailsUri, detailsRoutePath] = useMemo( () => [ formatUrl( diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts index 28e91331b428d..020e8c9e38ad5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts @@ -6,7 +6,209 @@ import { i18n } from '@kbn/i18n'; -const responseMap = new Map(); +const policyResponses: Array<[string, string]> = [ + [ + 'configure_dns_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_dns_events', + { defaultMessage: 'Configure DNS Events' } + ), + ], + [ + 'configure_elasticsearch_connection', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_elasticsearch_connection', + { defaultMessage: 'Configure Elastic Search Connection' } + ), + ], + [ + 'configure_file_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_file_events', + { defaultMessage: 'Configure File Events' } + ), + ], + [ + 'configure_imageload_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_imageload_events', + { defaultMessage: 'Configure Image Load Events' } + ), + ], + [ + 'configure_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_kernel', { + defaultMessage: 'Configure Kernel', + }), + ], + [ + 'configure_logging', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_logging', { + defaultMessage: 'Configure Logging', + }), + ], + [ + 'configure_malware', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_malware', { + defaultMessage: 'Configure Malware', + }), + ], + [ + 'configure_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_network_events', + { defaultMessage: 'Configure Network Events' } + ), + ], + [ + 'configure_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_process_events', + { defaultMessage: 'Configure Process Events' } + ), + ], + [ + 'configure_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_registry_events', + { defaultMessage: 'Configure Registry Events' } + ), + ], + [ + 'configure_security_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_security_events', + { defaultMessage: 'Configure Security Events' } + ), + ], + [ + 'connect_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connect_kernel', { + defaultMessage: 'Connect Kernel', + }), + ], + [ + 'detect_async_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_async_image_load_events', + { defaultMessage: 'Detect Async Image Load Events' } + ), + ], + [ + 'detect_file_open_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_open_events', + { defaultMessage: 'Detect File Open Events' } + ), + ], + [ + 'detect_file_write_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_write_events', + { defaultMessage: 'Detect File Write Events' } + ), + ], + [ + 'detect_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_network_events', + { defaultMessage: 'Detect Network Events' } + ), + ], + [ + 'detect_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_process_events', + { defaultMessage: 'Detect Process Events' } + ), + ], + [ + 'detect_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_registry_events', + { defaultMessage: 'Detect Registry Events' } + ), + ], + [ + 'detect_sync_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_sync_image_load_events', + { defaultMessage: 'Detect Sync Image Load Events' } + ), + ], + [ + 'download_global_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_global_artifacts', + { defaultMessage: 'Download Global Artifacts' } + ), + ], + [ + 'download_user_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_user_artifacts', + { defaultMessage: 'Download User Artifacts' } + ), + ], + [ + 'load_config', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.load_config', { + defaultMessage: 'Load Config', + }), + ], + [ + 'load_malware_model', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.load_malware_model', + { defaultMessage: 'Load Malware Model' } + ), + ], + [ + 'read_elasticsearch_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_elasticsearch_config', + { defaultMessage: 'Read ElasticSearch Config' } + ), + ], + [ + 'read_events_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_events_config', + { defaultMessage: 'Read Events Config' } + ), + ], + [ + 'read_kernel_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_kernel_config', + { defaultMessage: 'Read Kernel Config' } + ), + ], + [ + 'read_logging_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_logging_config', + { defaultMessage: 'Read Logging Config' } + ), + ], + [ + 'read_malware_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_malware_config', + { defaultMessage: 'Read Malware Config' } + ), + ], + [ + 'workflow', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { + defaultMessage: 'Workflow', + }), + ], +]; + +const responseMap = new Map(policyResponses); + +// Additional values used in the Policy Response UI responseMap.set( 'success', i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.success', { @@ -49,144 +251,6 @@ responseMap.set( defaultMessage: 'Events', }) ); -responseMap.set( - 'configure_elasticsearch_connection', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configureElasticSearchConnection', - { - defaultMessage: 'Configure Elastic Search Connection', - } - ) -); -responseMap.set( - 'configure_logging', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureLogging', { - defaultMessage: 'Configure Logging', - }) -); -responseMap.set( - 'configure_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureKernel', { - defaultMessage: 'Configure Kernel', - }) -); -responseMap.set( - 'configure_malware', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureMalware', { - defaultMessage: 'Configure Malware', - }) -); -responseMap.set( - 'connect_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connectKernel', { - defaultMessage: 'Connect Kernel', - }) -); -responseMap.set( - 'detect_file_open_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileOpenEvents', - { - defaultMessage: 'Detect File Open Events', - } - ) -); -responseMap.set( - 'detect_file_write_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileWriteEvents', - { - defaultMessage: 'Detect File Write Events', - } - ) -); -responseMap.set( - 'detect_image_load_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectImageLoadEvents', - { - defaultMessage: 'Detect Image Load Events', - } - ) -); -responseMap.set( - 'detect_process_events', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.detectProcessEvents', { - defaultMessage: 'Detect Process Events', - }) -); -responseMap.set( - 'download_global_artifacts', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadGlobalArtifacts', - { - defaultMessage: 'Download Global Artifacts', - } - ) -); -responseMap.set( - 'load_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadConfig', { - defaultMessage: 'Load Config', - }) -); -responseMap.set( - 'load_malware_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadMalwareModel', { - defaultMessage: 'Load Malware Model', - }) -); -responseMap.set( - 'read_elasticsearch_config', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.readElasticSearchConfig', - { - defaultMessage: 'Read ElasticSearch Config', - } - ) -); -responseMap.set( - 'read_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readEventsConfig', { - defaultMessage: 'Read Events Config', - }) -); -responseMap.set( - 'read_kernel_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readKernelConfig', { - defaultMessage: 'Read Kernel Config', - }) -); -responseMap.set( - 'read_logging_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readLoggingConfig', { - defaultMessage: 'Read Logging Config', - }) -); -responseMap.set( - 'read_malware_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readMalwareConfig', { - defaultMessage: 'Read Malware Config', - }) -); -responseMap.set( - 'workflow', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { - defaultMessage: 'Workflow', - }) -); -responseMap.set( - 'download_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadModel', { - defaultMessage: 'Download Model', - }) -); -responseMap.set( - 'ingest_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.injestEventsConfig', { - defaultMessage: 'Injest Events Config', - }) -); /** * Maps a server provided value to corresponding i18n'd string. @@ -195,5 +259,13 @@ export function formatResponse(responseString: string) { if (responseMap.has(responseString)) { return responseMap.get(responseString); } - return responseString; + + // Its possible for the UI to receive an Action name that it does not yet have a translation, + // thus we generate a label for it here by making it more user fiendly + responseMap.set( + responseString, + responseString.replace(/_/g, ' ').replace(/\b(\w)/g, (m) => m.toUpperCase()) + ); + + return responseMap.get(responseString); } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 996b987ea2be3..a61088e2edd29 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -13,8 +13,9 @@ import { mockPolicyResultList } from '../../policy/store/policy_list/mock_policy import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { HostInfo, - HostStatus, HostPolicyResponseActionStatus, + HostPolicyResponseAppliedAction, + HostStatus, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppAction } from '../../../../common/store/actions'; @@ -251,6 +252,16 @@ describe('when on the hosts page', () => { ) { malwareResponseConfigurations.concerned_actions.push(downloadModelAction.name); } + + // Add an unknown Action Name - to ensure we handle the format of it on the UI + const unknownAction: HostPolicyResponseAppliedAction = { + status: HostPolicyResponseActionStatus.success, + message: 'test message', + name: 'a_new_unknown_action', + }; + policyResponse.Endpoint.policy.applied.actions.push(unknownAction); + malwareResponseConfigurations.concerned_actions.push(unknownAction.name); + reactTestingLibrary.act(() => { store.dispatch({ type: 'serverReturnedHostPolicyResponse', @@ -564,6 +575,10 @@ describe('when on the hosts page', () => { '?page_index=0&page_size=10&selected_host=1' ); }); + + it('should format unknown policy action names', async () => { + expect(renderResult.getByText('A New Unknown Action')).not.toBeNull(); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 8edeab15d6a09..c5d47e87c3e1b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -89,7 +89,7 @@ export const HostList = () => { policyItemsLoading, endpointPackageVersion, } = useHostSelector(selector); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const dispatch = useDispatch<(a: HostAction) => void>(); @@ -127,12 +127,12 @@ export const HostList = () => { }`, state: { onCancelNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], onCancelUrl: formatUrl(getHostListPath({ name: 'hostList' })), onSaveNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -145,7 +145,7 @@ export const HostList = () => { path: `#/configs/${selectedPolicyId}?openEnrollmentFlyout=true`, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -401,7 +401,7 @@ export const HostList = () => {

@@ -422,7 +422,7 @@ export const HostList = () => { )} {renderTableOrEmptyState} - + ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 30800234ab24c..3e1c0743fb4f1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import React, { memo } from 'react'; import { useHistory, Route, Switch } from 'react-router-dom'; +import { ChromeBreadcrumb } from 'kibana/public'; import { EuiText, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PolicyContainer } from './policy'; @@ -18,10 +20,47 @@ import { import { NotFoundPage } from '../../app/404'; import { HostsContainer } from './endpoint_hosts'; import { getHostListPath } from '../common/routing'; +import { APP_ID, SecurityPageName } from '../../../common/constants'; +import { GetUrlForApp } from '../../common/components/navigation/types'; +import { AdministrationRouteSpyState } from '../../common/utils/route/types'; +import { ADMINISTRATION } from '../../app/home/translations'; +import { AdministrationSubTab } from '../types'; +import { HOSTS_TAB, POLICIES_TAB } from '../common/translations'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { SecurityPageName } from '../../app/types'; import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; +const TabNameMappedToI18nKey: Record = { + [AdministrationSubTab.hosts]: HOSTS_TAB, + [AdministrationSubTab.policies]: POLICIES_TAB, +}; + +export const getBreadcrumbs = ( + params: AdministrationRouteSpyState, + search: string[], + getUrlForApp: GetUrlForApp +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: ADMINISTRATION, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.administration}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, + ]; + + const tabName = params?.tabName; + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + return breadcrumb; +}; + const NoPermissions = memo(() => { return ( <> @@ -40,14 +79,14 @@ const NoPermissions = memo(() => {

} /> - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index ca4d0929f7a7a..8612b15f89857 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -172,7 +172,7 @@ describe('Policy Details', () => { cancelbutton.simulate('click', { button: 0 }); const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ - 'securitySolution:management', + 'securitySolution:administration', { path: policyListPathUrl }, ]); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 2a4f839a4af1f..8fbc167670b41 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -55,7 +55,7 @@ export const PolicyDetails = React.memo(() => { application: { navigateToApp }, }, } = useKibana(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const { state: locationRouteState } = useLocation(); // Store values @@ -149,7 +149,7 @@ export const PolicyDetails = React.memo(() => { {policyApiError?.message} ) : null} - + ); } @@ -168,7 +168,7 @@ export const PolicyDetails = React.memo(() => { defaultMessage="Back to policy list" /> - {policyItem.name} + {policyItem.name}
); @@ -251,7 +251,7 @@ export const PolicyDetails = React.memo(() => { - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 8a77264c354ad..8dbfbeeb5d8d6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -127,7 +127,7 @@ export const PolicyList = React.memo(() => { const { services, notifications } = useKibana(); const history = useHistory(); const location = useLocation(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const [showDelete, setShowDelete] = useState(false); const [policyIdToDelete, setPolicyIdToDelete] = useState(''); @@ -477,7 +477,7 @@ export const PolicyList = React.memo(() => { handleTableChange, paginationSetup, ])} - + ); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index cb21a236ddd7e..86959caaba4f4 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -24,7 +24,7 @@ export type ManagementState = CombinedState<{ /** * The management list of sub-tabs. Changes to these will impact the Router routes. */ -export enum ManagementSubTab { +export enum AdministrationSubTab { hosts = 'hosts', policies = 'policy', } @@ -33,8 +33,8 @@ export enum ManagementSubTab { * The URL route params for the Management Policy List section */ export interface ManagementRoutePolicyListParams { - pageName: SecurityPageName.management; - tabName: ManagementSubTab.policies; + pageName: SecurityPageName.administration; + tabName: AdministrationSubTab.policies; } /** diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 3758bd10bfc8f..7170412cb55ad 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -42,7 +42,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) =>

{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 43d8fb10508b7..4262afd67ba03 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import '../../common/mock/match_media'; +import { waitForUpdates } from '../../common/utils/test_utils'; import { TestProviders } from '../../common/mock'; import { useWithSource } from '../../common/containers/source'; import { @@ -61,7 +62,7 @@ describe('Overview', () => { mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); }); - it('renders the Setup Instructions text', () => { + it('renders the Setup Instructions text', async () => { const wrapper = mount( @@ -69,10 +70,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); - it('does not show Endpoint get ready button when ingest is not enabled', () => { + it('does not show Endpoint get ready button when ingest is not enabled', async () => { const wrapper = mount( @@ -80,10 +82,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); }); - it('shows Endpoint get ready button when ingest is enabled', () => { + it('shows Endpoint get ready button when ingest is enabled', async () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -92,11 +95,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); }); }); - it('it DOES NOT render the Getting started text when an index is available', () => { + it('it DOES NOT render the Getting started text when an index is available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -113,10 +117,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); - test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', () => { + test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -138,10 +144,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true); }); - test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -163,10 +171,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -183,10 +193,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', () => { + test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -206,7 +218,7 @@ describe('Overview', () => { expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when Ingest is NOT available', () => { + test('it does NOT render the Endpoint banner when Ingest is NOT available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -223,6 +235,8 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 62328bd767748..98ea2efe8721e 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -281,7 +281,7 @@ export class Plugin implements IPlugin = (state = initialState, action) => { @@ -18,6 +19,7 @@ export const dataReducer: Reducer = (state = initialS const nextState: DataState = { ...state, databaseDocumentID: action.payload.databaseDocumentID, + resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 630dfe555548f..cf23596db6134 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -53,11 +53,12 @@ describe('data state', () => { describe('when there is a databaseDocumentID but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, ]; }); @@ -104,11 +105,12 @@ describe('data state', () => { }); describe('when there is a pending request for the current databaseDocumentID', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, { type: 'appRequestedResolverData', @@ -160,12 +162,17 @@ describe('data state', () => { describe('when there is a pending request for a different databaseDocumentID than the current one', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; + const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; + const resolverComponentInstanceID2 = 'resolverComponentInstanceID2'; beforeEach(() => { actions = [ // receive the document ID, this would cause the middleware to starts the request { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: firstDatabaseDocumentID }, + payload: { + databaseDocumentID: firstDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID1, + }, }, // this happens when the middleware starts the request { @@ -175,7 +182,10 @@ describe('data state', () => { // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: secondDatabaseDocumentID }, + payload: { + databaseDocumentID: secondDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID2, + }, }, ]; }); @@ -188,6 +198,9 @@ describe('data state', () => { it('should need to abort the request for the databaseDocumentID', () => { expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); }); + it('should use the correct location for the second resolver', () => { + expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); + }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 990b911e5dbd0..9f425217a8d3e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -41,6 +41,13 @@ export function isLoading(state: DataState): boolean { return state.pendingRequestDatabaseDocumentID !== undefined; } +/** + * A string for uniquely identifying the instance of resolver within the app. + */ +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +} + /** * If a request was made and it threw an error or returned a failure response code. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 6e512cfe13f62..64921d214cc1b 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -69,6 +69,11 @@ export const databaseDocumentIDToAbort = composeSelectors( dataSelectors.databaseDocumentIDToAbort ); +export const resolverComponentInstanceID = composeSelectors( + dataStateSelector, + dataSelectors.resolverComponentInstanceID +); + export const processAdjacencies = composeSelectors( dataStateSelector, dataSelectors.processAdjacencies diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 2025762a0605c..064634472bbbe 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -177,6 +177,7 @@ export interface DataState { * The id used for the pending request, if there is one. */ readonly pendingRequestDatabaseDocumentID?: string; + readonly resolverComponentInstanceID: string | undefined; /** * The parameters and response from the last successful request. diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 205180a40d62a..c1ffa42d02abb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -18,6 +18,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const Resolver = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -28,6 +29,11 @@ export const Resolver = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { const context = useKibana(); const store = useMemo(() => { @@ -40,7 +46,11 @@ export const Resolver = React.memo(function ({ */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 3fc62fc318284..000bf23c5f49d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -29,6 +29,7 @@ import { SideEffectContext } from './side_effect_context'; export const ResolverMap = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -39,12 +40,17 @@ export const ResolverMap = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { /** * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); const { processNodePositions, connectingEdgeLineSegments } = useSelector( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index f4fe4fe520c92..061531b82d935 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useCallback, useMemo, useContext, useLayoutEffect, useState } from 'react'; +import React, { memo, useMemo, useContext, useLayoutEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { useHistory, useLocation } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { EuiPanel } from '@elastic/eui'; import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; @@ -21,7 +18,7 @@ import { EventCountsForProcess } from './panels/panel_content_related_counts'; import { ProcessDetails } from './panels/panel_content_process_detail'; import { ProcessListWithCounts } from './panels/panel_content_process_list'; import { RelatedEventDetail } from './panels/panel_content_related_detail'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * The team decided to use this table to determine which breadcrumbs/view to display: @@ -39,14 +36,11 @@ import { CrumbInfo } from './panels/panel_content_utilities'; * @returns {JSX.Element} The "right" table content to show based on the query params as described above */ const PanelContent = memo(function PanelContent() { - const history = useHistory(); - const urlSearch = useLocation().search; const dispatch = useResolverDispatch(); const { timestamp } = useContext(SideEffectContext); - const queryParams: CrumbInfo = useMemo(() => { - return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) }; - }, [urlSearch]); + + const { pushToQueryParams, queryParams } = useResolverQueryParams(); const graphableProcesses = useSelector(selectors.graphableProcesses); const graphableProcessEntityIds = useMemo(() => { @@ -104,35 +98,6 @@ const PanelContent = memo(function PanelContent() { } }, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]); - /** - * This updates the breadcrumb nav and the panel view. It's supplied to each - * panel content view to allow them to dispatch transitions to each other. - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - // We probably don't want to nuke the user's history with a huge - // trail of these, thus `.replace` instead of `.push` - return history.replace(relativeURL); - }, - [history, urlSearch] - ); - const relatedEventStats = useSelector(selectors.relatedEventsStats); const { crumbId, crumbEvent } = queryParams; const relatedStatsForIdFromParams: ResolverNodeStats | undefined = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 374c4c94c7768..4dedafe55bb2c 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -27,8 +27,8 @@ const BetaHeader = styled(`header`)` * The two query parameters we read/write on to control which view the table presents: */ export interface CrumbInfo { - readonly crumbId: string; - readonly crumbEvent: string; + crumbId: string; + crumbEvent: string; } const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 6442735abc8cd..17e7d3df42931 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -10,9 +10,6 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; @@ -22,7 +19,7 @@ import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as selectors from '../store/selectors'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * A record of all known event types (in schema format) to translations @@ -403,35 +400,7 @@ const UnstyledProcessEventDot = React.memo( }); }, [dispatch, selfId]); - const history = useHistory(); - const urlSearch = history.location.search; - - /** - * This updates the breadcrumb nav, the table view - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - - return history.replace(relativeURL); - }, - [history, urlSearch] - ); + const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts new file mode 100644 index 0000000000000..70baef5fa88ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useMemo } from 'react'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import * as selectors from '../store/selectors'; +import { CrumbInfo } from './panels/panel_content_utilities'; + +export function useResolverQueryParams() { + /** + * This updates the breadcrumb nav and the panel view. It's supplied to each + * panel content view to allow them to dispatch transitions to each other. + */ + const history = useHistory(); + const urlSearch = useLocation().search; + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; + const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + [uniqueCrumbIdKey]: newCrumbs.crumbId, + [uniqueCrumbEventKey]: newCrumbs.crumbEvent, + }; + + // If either was passed in as empty, remove it from the record + if (newCrumbs.crumbId === '') { + delete crumbsToPass[uniqueCrumbIdKey]; + } + if (newCrumbs.crumbEvent === '') { + delete crumbsToPass[uniqueCrumbEventKey]; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + // We probably don't want to nuke the user's history with a huge + // trail of these, thus `.replace` instead of `.push` + return history.replace(relativeURL); + }, + [history, urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey] + ); + const queryParams: CrumbInfo = useMemo(() => { + const parsed = querystring.parse(urlSearch.slice(1)); + const crumbEvent = parsed[uniqueCrumbEventKey]; + const crumbId = parsed[uniqueCrumbIdKey]; + return { + crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, + crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + }; + }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + + return { + pushToQueryParams, + queryParams, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index b8ea2049f5c49..642a054e8c519 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -13,17 +13,19 @@ import { useResolverDispatch } from './use_resolver_dispatch'; */ export function useStateSyncingActions({ databaseDocumentID, + resolverComponentInstanceID, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }) { const dispatch = useResolverDispatch(); useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }); - }, [dispatch, databaseDocumentID]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 472006a9e55b1..5d4579b427f18 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from '../common/shared_imports'; + export { getUseField, getFieldValidityAndErrorMessage, @@ -16,6 +18,7 @@ export { FormHook, FormSchema, UseField, + UseMultiFields, useForm, ValidationFunc, VALIDATION_TYPES, @@ -23,3 +26,27 @@ export { export { Field, SelectField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; + +export { + exportList, + useIsMounted, + useCursor, + useApi, + useExceptionList, + usePersistExceptionItem, + usePersistExceptionList, + useFindLists, + useDeleteList, + useImportList, + useCreateListIndex, + useReadListIndex, + useReadListPrivileges, + addExceptionListItem, + updateExceptionListItem, + fetchExceptionListById, + addExceptionList, + ExceptionIdentifiers, + ExceptionList, + Pagination, + UseExceptionListSuccess, +} from '../../lists/public'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index fd5e8bc2434f3..0b5b51d6f1fb2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -118,7 +118,10 @@ const GraphOverlayComponent = ({ - + ( defaultTimelineCount, templateTimelineCount, }); - const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({ + const { + timelineStatus, + templateTimelineType, + templateTimelineFilter, + installPrepackagedTimelines, + } = useTimelineStatus({ timelineType, customTemplateTimelineCount, elasticTemplateTimelineCount, @@ -287,7 +292,13 @@ export const StatefulOpenTimelineComponent = React.memo( focusInput(); }, []); - useEffect(() => refetch(), [refetch]); + useEffect(() => { + const fetchData = async () => { + await installPrepackagedTimelines(); + refetch(); + }; + fetchData(); + }, [refetch, installPrepackagedTimelines]); return !isModal ? ( void; } => { const [selectedTab, setSelectedTab] = useState( TemplateTimelineType.elastic @@ -101,9 +103,16 @@ export const useTimelineStatus = ({ : null; }, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]); + const installPrepackagedTimelines = useCallback(async () => { + if (templateTimelineType === TemplateTimelineType.elastic) { + await installPrepackedTimelines(); + } + }, [templateTimelineType]); + return { timelineStatus, templateTimelineType, templateTimelineFilter, + installPrepackagedTimelines, }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 125ba23a5c5a5..c9c8250922161 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -96,7 +96,7 @@ export const Actions = React.memo( data-test-subj="event-actions-container" > {showCheckboxes && ( - + {loadingEventIds.includes(eventId) ? ( @@ -117,7 +117,7 @@ export const Actions = React.memo( )} - + {loading ? ( @@ -137,7 +137,7 @@ export const Actions = React.memo( {!isEventViewer && ( <> - + ( - + ( ...acc, icon: [ ...acc.icon, - - + + ( return grouped.contextMenu.length > 0 ? [ ...grouped.icon, - - + + ( : grouped.icon; }, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]); + const handlePinClicked = useCallback( + () => + getPinOnClick({ + allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]), + eventId: id, + onPinEvent, + onUnPinEvent, + isEventPinned, + }), + [eventIdToNoteIds, id, isEventPinned, onPinEvent, onUnPinEvent] + ); + return ( ( loadingEventIds={loadingEventIds} noteIds={eventIdToNoteIds[id] || emptyNotes} onEventToggled={onEventToggled} - onPinClicked={getPinOnClick({ - allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]), - eventId: id, - onPinEvent, - onUnPinEvent, - isEventPinned, - })} + onPinClicked={handlePinClicked} showCheckboxes={showCheckboxes} showNotes={showNotes} toggleShowNotes={toggleShowNotes} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index 52bbccbba58e7..317f1ed20119b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -103,9 +103,6 @@ export const getEventType = (event: Ecs): Omit => { return 'raw'; }; -export const showGraphView = (graphEventId?: string) => - graphEventId != null && graphEventId.length > 0; - export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { return ( get(['agent', 'type', 0], ecsData) === 'endpoint' && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 43ea5e905ca8b..1e5eff8b79796 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -17,7 +17,8 @@ import { columnRenderers, rowRenderers } from './renderers'; import { Sort } from './sort'; import { wait } from '../../../../common/lib/helpers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; -import { SELECTOR_TIMELINE_BODY_CLASS_NAME } from '../styles'; +import { SELECTOR_TIMELINE_BODY_CLASS_NAME, TimelineBody } from '../styles'; +import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme'; const testBodyHeight = 700; const mockGetNotesByIds = (eventId: string[]) => []; @@ -33,8 +34,12 @@ jest.mock('react-redux', () => { useSelector: jest.fn(), }; }); + jest.mock('../../../../common/components/link_to'); +// Prevent Resolver from rendering +jest.mock('../../graph_overlay'); + jest.mock( 'react-visibility-sensor', () => ({ children }: { children: (args: { isVisible: boolean }) => React.ReactNode }) => @@ -148,6 +153,29 @@ describe('Body', () => { .exists() ).toEqual(true); }); + describe('when there is a graphEventId', () => { + beforeEach(() => { + props.graphEventId = 'graphEventId'; // any string w/ length > 0 works + }); + it('should not render the timeline body', () => { + const wrapper = mount( + + + + ); + + // The value returned if `wrapper.find` returns a `TimelineBody` instance. + type TimelineBodyEnzymeWrapper = ReactWrapper>; + + // The first TimelineBody component + const timelineBody: TimelineBodyEnzymeWrapper = wrapper + .find('[data-test-subj="timeline-body"]') + .first() as TimelineBodyEnzymeWrapper; + + // the timeline body still renders, but it gets a `display: none` style via `styled-components`. + expect(timelineBody.props().visible).toBe(false); + }); + }); }); describe('action on event', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index b474e4047eadd..6bf2b5e2a391e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -26,7 +26,6 @@ import { EventsTable, TimelineBody, TimelineBodyGlobalStyle } from '../styles'; import { ColumnHeaders } from './column_headers'; import { getActionsColumnWidth } from './column_headers/helpers'; import { Events } from './events'; -import { showGraphView } from './helpers'; import { ColumnRenderer } from './renderers/column_renderer'; import { RowRenderer } from './renderers/row_renderer'; import { Sort } from './sort'; @@ -146,7 +145,7 @@ export const Body = React.memo( return ( <> - {showGraphView(graphEventId) && ( + {graphEventId && ( )} ( data-timeline-id={id} bodyHeight={height} ref={containerElementRef} - visible={show && !showGraphView(graphEventId)} + visible={show && !graphEventId} > - - + @@ -58,7 +59,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -106,7 +109,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -154,7 +159,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -202,7 +209,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -250,7 +259,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -298,7 +309,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -346,7 +359,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -394,7 +409,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -442,7 +459,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -490,7 +509,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -527,6 +548,10 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` ) +
`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index 8e1c02bad50a3..71cf81c00dc09 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiContextMenu, EuiText, EuiPopover, @@ -139,21 +140,33 @@ const AddDataProviderPopoverComponent: React.FC = ( [browserFields, handleDataProviderEdited, timelineId, timelineType] ); - const button = useMemo( - () => ( - { + if (timelineType === TimelineType.template) { + return ( + + {ADD_FIELD_LABEL} + + ); + } + + return ( + - {ADD_FIELD_LABEL} - - ), - [handleOpenPopover] - ); + {`+ ${ADD_FIELD_LABEL}`} + + ); + }, [handleOpenPopover, timelineType]); const content = useMemo(() => { if (timelineType === TimelineType.template) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index c9dd906cee59b..1142bbc214d74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -82,10 +82,10 @@ const Parens = styled.span` `} `; -const AndOrBadgeContainer = styled.div` - width: 121px; - display: flex; - justify-content: flex-end; +const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` + span { + visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; + } `; const LastAndOrBadgeInGroup = styled.div` @@ -113,10 +113,6 @@ const ParensContainer = styled(EuiFlexItem)` align-self: center; `; -const AddDataProviderContainer = styled.div` - padding-right: 9px; -`; - const getDataProviderValue = (dataProvider: DataProvidersAnd) => dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; @@ -152,15 +148,9 @@ export const Providers = React.memo( - {groupIndex === 0 ? ( - - - - ) : ( - - - - )} + + + {'('} @@ -300,6 +290,9 @@ export const Providers = React.memo( {')'} + {groupIndex === dataProviderGroups.length - 1 && ( + + )} ))} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index 93af374b15b56..aa3ce88acc200 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { FilterManager, IIndexPattern } from 'src/plugins/data/public'; import deepEqual from 'fast-deep-equal'; -import { showGraphView } from '../body/helpers'; import { DataProviders } from '../data_providers'; import { DataProvider } from '../data_providers/data_provider'; import { @@ -80,7 +79,7 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - {show && !showGraphView(graphEventId) && ( + {show && !graphEventId && ( <> { // return events on empty search const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) ? ' ' : kqlQueryTimeline; + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; return { columns, dataProviders, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 360737ce41d2d..78a46e04a6952 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -180,6 +180,34 @@ describe('Timeline', () => { 'All events' ); }); + + it('it shows the timeline footer', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(true); + }); + describe('when there is a graphEventId', () => { + beforeEach(() => { + props.graphEventId = 'graphEventId'; // any string w/ length > 0 works + }); + it('should not show the timeline footer', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="timeline-footer"]').exists()).toEqual(false); + }); + }); }); describe('event wire up', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index ee48f97164b86..c1e97dcaef86a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -282,27 +282,33 @@ export const TimelineComponent: React.FC = ({ toggleColumn={toggleColumn} /> - -