diff --git a/.eslintrc.js b/.eslintrc.js index f279c0dd8a25a2..86fbcfb13d0abe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,6 +2,8 @@ const { resolve } = require('path'); const { readdirSync } = require('fs'); const dedent = require('dedent'); +const restrictedModules = { paths: ['gulp-util'] }; + module.exports = { extends: ['@elastic/eslint-config-kibana', '@elastic/eslint-config-kibana/jest'], @@ -17,6 +19,11 @@ module.exports = { }, }, + rules: { + 'no-restricted-imports': [2, restrictedModules], + 'no-restricted-modules': [2, restrictedModules], + }, + overrides: [ /** * Prettier @@ -116,7 +123,7 @@ module.exports = { 'packages/kbn-ui-framework/generator-kui/**/*', 'packages/kbn-ui-framework/Gruntfile.js', 'packages/kbn-es/src/**/*', - 'x-pack/{dev-tools,gulp_helpers,scripts,test,build_chromium}/**/*', + 'x-pack/{dev-tools,tasks,scripts,test,build_chromium}/**/*', 'x-pack/**/{__tests__,__test__,__jest__,__fixtures__,__mocks__}/**/*', 'x-pack/**/*.test.js', 'x-pack/gulpfile.js', diff --git a/.i18nrc.json b/.i18nrc.json index d45b944ff0348a..d53285671b7686 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -1,9 +1,10 @@ { "paths": { - "kbn": "src/core_plugins/kibana", - "common.server": "src/server", "common.ui": "src/ui", - "xpack.idxMgmt": "xpack/plugins/index_management" + "inputControl":"src/core_plugins/input_control_vis", + "kbn": "src/core_plugins/kibana", + "statusPage": "src/core_plugins/status_page", + "xpack.idxMgmt": "x-pack/plugins/index_management" }, "exclude": [ "src/ui/ui_render/bootstrap/app_bootstrap.js", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37be08bf25a2bf..7f79fd011c4bb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -271,6 +271,13 @@ You can get all build options using the following command: yarn build --help ``` +macOS users on a machine with a discrete graphics card may see significant speedups (up to 2x) when running tests by changing your terminal emulator's GPU settings. In iTerm2: +- Open Preferences (Command + ,) +- In the General tab, under the "Magic" section, ensure "GPU rendering" is checked +- Open "Advanced GPU Settings..." +- Uncheck the "Prefer integrated to discrete GPU" option +- Restart iTerm + ### Debugging Server Code `yarn debug` will start the server with Node's inspect flag. Kibana's development mode will start three processes. Chrome's developer tools can be configured to connect to all three under the connection tab. diff --git a/docs/api.asciidoc b/docs/api.asciidoc index 8376c81ff18127..afe7722a0cec5c 100644 --- a/docs/api.asciidoc +++ b/docs/api.asciidoc @@ -29,12 +29,14 @@ entirely. * <> * <> +* <> * <> * <> -- include::api/role-management.asciidoc[] include::api/saved-objects.asciidoc[] +include::api/dashboard-import.asciidoc[] include::api/logstash-configuration-management.asciidoc[] include::api/url-shortening.asciidoc[] diff --git a/docs/api/dashboard-import.asciidoc b/docs/api/dashboard-import.asciidoc new file mode 100644 index 00000000000000..43ed037daf13b2 --- /dev/null +++ b/docs/api/dashboard-import.asciidoc @@ -0,0 +1,17 @@ +[[dashboard-import-api]] +== Dashboard Import API + +The dashboard import/export APIs allow people to import dashboards along with +all of their corresponding saved objects such as visualizations, saved +searches, and index patterns. + +Traditionally, developers would perform this level of integration by writing +documents directly to the `.kibana` index. *Do not do this!* Writing directly +to the `.kibana` index is not safe and it _will_ result in corrupted data that +permanently breaks Kibana in a future version. + +* <> +* <> + +include::dashboard-import/import.asciidoc[] +include::dashboard-import/export.asciidoc[] diff --git a/docs/api/dashboard-import/export.asciidoc b/docs/api/dashboard-import/export.asciidoc new file mode 100644 index 00000000000000..ddafeb35f7cc0c --- /dev/null +++ b/docs/api/dashboard-import/export.asciidoc @@ -0,0 +1,38 @@ +[[dashboard-import-api-export]] +=== Export Dashboard + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +The dashboard export API allows people to export dashboards along with all of +their corresponding saved objects such as visualizations, saved searches, and +index patterns. + +==== Request + +`GET /api/kibana/dashboards/export` + +==== Query Parameters + +`dashboard` (optional):: + (array|string) The id(s) of the dashboard(s) to export + +==== Response body + +The response body will have a top level `objects` property that contains an +array of saved objects. The order of these objects is not guaranteed. You +should use this exact response body as the request body for the corresponding +<>. + +==== Examples + +The following example exports all saved objects associated with and including +the dashboard with id `942dcef0-b2cd-11e8-ad8e-85441f0c2e5c`. + +[source,js] +-------------------------------------------------- +GET api/kibana/dashboards/export?dashboard=942dcef0-b2cd-11e8-ad8e-85441f0c2e5c +-------------------------------------------------- +// KIBANA + +A successful call returns a response code of `200` along with the exported +objects as the response body. diff --git a/docs/api/dashboard-import/import.asciidoc b/docs/api/dashboard-import/import.asciidoc new file mode 100644 index 00000000000000..e95d15f1b20d0a --- /dev/null +++ b/docs/api/dashboard-import/import.asciidoc @@ -0,0 +1,96 @@ +[[dashboard-import-api-import]] +=== Import Dashboard + +experimental[This functionality is *experimental* and may be changed or removed completely in a future release.] + +The dashboard import API allows people to import dashboards along with all of +their corresponding saved objects such as visualizations, saved searches, and +index patterns. + +==== Request + +`POST /api/kibana/dashboards/import` + +==== Query Parameters + +`force` (optional):: + (boolean) Overwrite any existing objects on id conflict +`exclude` (optional):: + (array) Saved object types that should not be imported + +==== Request Body + +The request body is JSON, but you should not manually construct a payload to +this endpoint. Instead, use the complete response body from the +<> as the request body to +this import API. + +==== Response body + +The response body will have a top level `objects` property that contains an +array of the saved objects that were created. + +==== Examples + +The following example imports saved objects associated with and including the +dashboard with id `942dcef0-b2cd-11e8-ad8e-85441f0c2e5c`. + +[source,js] +-------------------------------------------------- +POST api/kibana/dashboards/import?exclude=index-pattern +{ + "objects": [ + { + "id": "80b956f0-b2cd-11e8-ad8e-85441f0c2e5c", + "type": "visualization", + "updated_at": "2018-09-07T18:40:33.247Z", + "version": 1, + "attributes": { + "title": "Count Example", + "visState": "{\"title\":\"Count Example\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + }, + { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "type": "index-pattern", + "updated_at": "2018-09-07T18:39:47.683Z", + "version": 1, + "attributes": { + "title": "kibana_sample_data_logs", + "timeFieldName": "timestamp", + "fields": "", + "fieldFormatMap": "{\"hour_of_day\":{}}" + } + }, + { + "id": "942dcef0-b2cd-11e8-ad8e-85441f0c2e5c", + "type": "dashboard", + "updated_at": "2018-09-07T18:41:05.887Z", + "version": 1, + "attributes": { + "title": "Example Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0-alpha1\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"80b956f0-b2cd-11e8-ad8e-85441f0c2e5c\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + } + ] +} +-------------------------------------------------- +// KIBANA + +A response code of `200` will be returned even if there are errors importing +individual saved objects. In that case, error information will be returned in +the response body on an object-by-object basis. diff --git a/docs/apm/getting-started.asciidoc b/docs/apm/getting-started.asciidoc index 2df3d5efa5f760..684c420c1e5f55 100644 --- a/docs/apm/getting-started.asciidoc +++ b/docs/apm/getting-started.asciidoc @@ -16,12 +16,7 @@ configuration is required. If you also use Elastic Stack for logging and server-level metrics, you can optionally import the APM dashboards that come with the APM Server. You can use these APM-specific visualizations to correlate APM data with other data sources. -To get the dashboards, run the following command on the APM server: - -[source,shell] ----------------------------------------------------------- -./apm-server setup ----------------------------------------------------------- +To get the dashboards, click the "Load Kibana objects" button at the bottom of the Getting Started guides for APM in Kibana. For more setup information, see {apm-get-started-ref}/index.html[Getting Started with APM]. diff --git a/docs/development/security/rbac.asciidoc b/docs/development/security/rbac.asciidoc index a3508b8aced355..5651c776360c1a 100644 --- a/docs/development/security/rbac.asciidoc +++ b/docs/development/security/rbac.asciidoc @@ -1,7 +1,7 @@ [[development-security-rbac]] === Role-based access control -Role-based access control (RBAC) in {kib} relies upon the {ref}/security-api-privileges.html[privilege APIs] that Elasticsearch exposes. This {kib} to define the privileges that {kib} wishes to grant to users, assign them to the relevant users using roles, and then authorize the user to perform a specific action. This is handled within a secured instance of the `SavedObjectsClient` and available transparently to consumers when using `request.getSavedObjectsClient()` or `savedObjects.getScopedSavedObjectsClient()`. +Role-based access control (RBAC) in {kib} relies upon the {xpack-ref}/security-privileges.html#application-privileges[application privileges] that Elasticsearch exposes. This allows {kib} to define the privileges that {kib} wishes to grant to users, assign them to the relevant users using roles, and then authorize the user to perform a specific action. This is handled within a secured instance of the `SavedObjectsClient` and available transparently to consumers when using `request.getSavedObjectsClient()` or `savedObjects.getScopedSavedObjectsClient()`. [[development-rbac-privileges]] ==== {kib} Privileges @@ -76,7 +76,7 @@ Roles that grant <> should be managed using the < { icon: 'my_icon', description: 'Cool new chart', visConfig: { - template: ReactComponent + component: ReactComponent } }); } diff --git a/docs/development/visualize/development-embedding-visualizations.asciidoc b/docs/development/visualize/development-embedding-visualizations.asciidoc index 3734d3775ed1f6..ecfa93ba9e5b2a 100644 --- a/docs/development/visualize/development-embedding-visualizations.asciidoc +++ b/docs/development/visualize/development-embedding-visualizations.asciidoc @@ -35,7 +35,7 @@ For a more complex use-case you usually want to use that method. `params` is a parameter object specifying several parameters, that influence rendering. You will find a detailed description of all the parameters in the inline docs -in the {repo}blob/{branch}/src/ui/public/visualize/loader/loader.js[loader source code]. +in the {repo}blob/{branch}/src/ui/public/visualize/loader/types.ts[loader source code]. Both methods return an `EmbeddedVisualizeHandler`, that gives you some access to the visualization. The `embedVisualizationWithSavedObject` method will return diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 1d31d32d7f4245..2c265ed21048df 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -43,6 +43,7 @@ working on big documents. Set this property to `false` to disable highlighting. the Elasticsearch cluster. This setting constrains the length of the segment list. Long segment lists can significantly increase request processing time. `courier:ignoreFilterIfFieldNotInIndex`:: Set this property to `true` to skip filters that apply to fields that don't exist in a visualization's index. Useful when dashboards consist of visualizations from multiple index patterns. +`courier:maxConcurrentShardRequests`:: Controls the {ref}/search-multi-search.html[max_concurrent_shard_requests] setting used for _msearch requests sent by Kibana. Set to 0 to disable this config and use the Elasticsearch default. `fields:popularLimit`:: This setting governs how many of the top most popular fields are shown. `histogram:barTarget`:: When date histograms use the `auto` interval, Kibana attempts to generate this number of bars. `histogram:maxBars`:: Date histograms are not generated with more bars than the value of this property, scaling values diff --git a/docs/migration/migrate_7_0.asciidoc b/docs/migration/migrate_7_0.asciidoc index 60272acc8ad285..c7e521c812326a 100644 --- a/docs/migration/migrate_7_0.asciidoc +++ b/docs/migration/migrate_7_0.asciidoc @@ -56,3 +56,9 @@ considered unique based on its persistent UUID, which is written to the path.dat *Details:* The `/shorten` API has been deprecated since 6.5, when it was replaced by the `/api/shorten_url` API. *Impact:* The '/shorten' API has been removed. Use the '/api/shorten_url' API instead. + +[float] +=== Deprecated kibana.yml setting logging.useUTC has been replaced with logging.timezone +*Details:* Any timezone can now be specified by canonical id. + +*Impact:* The logging.useUTC flag will have to be replaced with a timezone id. If set to true the id is `UTC`. diff --git a/docs/monitoring/cluster-alerts.asciidoc b/docs/monitoring/cluster-alerts.asciidoc index 15df791ae2746f..fce76965dd9ae7 100644 --- a/docs/monitoring/cluster-alerts.asciidoc +++ b/docs/monitoring/cluster-alerts.asciidoc @@ -46,8 +46,6 @@ To receive email notifications for the Cluster Alerts: 1. Configure an email account as described in {xpack-ref}/actions-email.html#configuring-email[Configuring Email Accounts]. -2. Navigate to the *Management* page in {kib}. -3. Go to the *Advanced Settings* page, find the `xpack:defaultAdminEmail` -setting, and enter your email address. +2. Configure the `xpack.monitoring.cluster_alerts.email_notifications.email_address` setting in `kibana.yml` with your email address. Email notifications are sent only when Cluster Alerts are triggered and resolved. diff --git a/docs/plugins/known-plugins.asciidoc b/docs/plugins/known-plugins.asciidoc index 179644060d5e31..45b5ae16758c54 100644 --- a/docs/plugins/known-plugins.asciidoc +++ b/docs/plugins/known-plugins.asciidoc @@ -17,6 +17,7 @@ This list of plugins is not guaranteed to work on your version of Kibana. Instea * https://github.com/samtecspg/conveyor[Conveyor] - Simple (GUI) interface for importing data into Elasticsearch. * https://github.com/TrumanDu/indices_view[Indices View] - View indices related information. * https://github.com/johtani/analyze-api-ui-plugin[Analyze UI] (johtani) - UI for elasticsearch _analyze API +* https://github.com/TrumanDu/cleaner[Cleaner] (TrumanDu)- Setting index ttl. [float] === Timelion Extensions @@ -28,6 +29,7 @@ This list of plugins is not guaranteed to work on your version of Kibana. Instea * https://github.com/JuanCarniglia/area3d_vis[3D Graph] (JuanCarniglia) * https://github.com/TrumanDu/bmap[Bmap](TrumanDu) - integrated echarts for map visualization * https://github.com/mstoyano/kbn_c3js_vis[C3JS Visualizations] (mstoyano) +* https://github.com/aaronoah/kibana_calendar_vis[Calendar Visualization] (aaronoah) * https://github.com/elo7/cohort[Cohort analysis] (elo7) * https://github.com/DeanF/health_metric_vis[Colored Metric Visualization] (deanf) * https://github.com/JuanCarniglia/dendrogram_vis[Dendrogram] (JuanCarniglia) diff --git a/docs/security/securing-kibana.asciidoc b/docs/security/securing-kibana.asciidoc index e3673a67537880..412d2f5bcf6eb8 100644 --- a/docs/security/securing-kibana.asciidoc +++ b/docs/security/securing-kibana.asciidoc @@ -85,8 +85,9 @@ You can manage privileges on the *Management / Security / Roles* page in {kib}. If you're using the native realm with Basic Authentication, you can assign roles using the *Management / Security / Users* page in {kib} or the -{ref}/security-api.html#security-user-apis[user management APIs]. For example, the following -creates a user named `jacknich` and assigns it the `kibana_user` role: +{ref}/security-api.html#security-user-apis[user management APIs]. For example, +the following creates a user named `jacknich` and assigns it the `kibana_user` +role: [source,js] -------------------------------------------------------------------------------- diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index fdbbee30ecad75..31ebe5ac6034e9 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -19,10 +19,10 @@ xpack.apm.ui.enabled:: Set to `false` to hide the APM plugin {kib} from the menu apm_oss.indexPattern:: Index pattern is used for integrations with Machine Learning and Kuery Bar. It must match all apm indices. Defaults to `apm-*`. -apm_oss.errorIndices:: Matcher for indices containing error documents. Defaults to `apm-\*-error-*`. +apm_oss.errorIndices:: Matcher for indices containing error documents. Defaults to `apm-*`. -apm_oss.onboardingIndices:: Matcher for indices containing onboarding documents. Defaults to `apm-\*-onboarding-*`. +apm_oss.onboardingIndices:: Matcher for indices containing onboarding documents. Defaults to `apm-*`. -apm_oss.spanIndices:: Matcher for indices containing span documents. Defaults to `apm-\*-span-*`. +apm_oss.spanIndices:: Matcher for indices containing span documents. Defaults to `apm-*`. -apm_oss.transactionIndices:: Matcher for indices containing transaction documents. Defaults to `apm-\*-transaction-*`. +apm_oss.transactionIndices:: Matcher for indices containing transaction documents. Defaults to `apm-*`. diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index 52f7fc30bfc35d..9be86f53852f2e 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -76,8 +76,9 @@ Defaults to `3000` (3 seconds). [[xpack-reporting-browser]]`xpack.reporting.capture.browser.type`:: Specifies the browser to use to capture screenshots. Valid options are `phantom` and `chromium`. When `chromium` is set, the settings specified in the <> -are respected. -Defaults to `phantom`. +are respected. This setting will be deprecated in 7.0, when Phantom support is removed. +Defaults to `chromium`. + [float] [[reporting-chromium-settings]] diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 1f09e5dc6e2a66..7a217053363131 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -73,7 +73,7 @@ error messages. [[logging-verbose]]`logging.verbose:`:: *Default: false* Set the value of this setting to `true` to log all events, including system usage information and all requests. Supported on Elastic Cloud Enterprise. -`logging.useUTC`:: *Default: true* Set the value of this setting to `false` to log events using the timezone of the server, rather than UTC. +`logging.timezone`:: *Default: UTC* Set to the canonical timezone id (e.g. `US/Pacific`) to log events using that timezone. A list of timezones can be referenced at https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. `map.includeElasticMapsService:`:: *Default: true* Turns on or off whether layers from the Elastic Maps Service should be included in the vector and tile layer option list. By turning this off, only the layers that are configured here will be included. diff --git a/package.json b/package.json index d1451ba2553fe9..89ce8842c3cc52 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "url": "https://github.com/elastic/kibana.git" }, "dependencies": { - "@elastic/eui": "3.6.1", + "@elastic/eui": "3.7.0", "@elastic/filesaver": "1.1.2", "@elastic/numeral": "2.3.2", "@elastic/ui-ace": "0.2.3", @@ -157,14 +157,14 @@ "react-grid-layout": "^0.16.2", "react-input-range": "^1.3.0", "react-markdown": "^3.1.4", - "react-redux": "^5.0.6", + "react-redux": "^5.0.7", "react-router-dom": "4.2.2", "react-sizeme": "^2.3.6", "react-toggle": "4.0.2", "reactcss": "1.2.3", - "redux": "3.7.2", + "redux": "4.0.0", "redux-actions": "2.2.1", - "redux-thunk": "2.2.0", + "redux-thunk": "2.3.0", "regression": "2.0.0", "request": "^2.85.0", "reselect": "^3.0.1", @@ -224,28 +224,28 @@ "@types/glob": "^5.0.35", "@types/hapi-latest": "npm:@types/hapi@17.0.12", "@types/has-ansi": "^3.0.0", - "@types/jest": "^22.2.3", + "@types/jest": "^23.3.1", "@types/joi": "^10.4.4", - "@types/jquery": "3.3.1", + "@types/jquery": "^3.3.6", "@types/js-yaml": "^3.11.1", "@types/listr": "^0.13.0", "@types/lodash": "^3.10.1", "@types/minimatch": "^2.0.29", "@types/node": "^8.10.20", "@types/prop-types": "^15.5.3", + "@types/puppeteer": "^1.6.2", "@types/react": "^16.3.14", "@types/react-dom": "^16.0.5", - "@types/react-redux": "^5.0.6", - "@types/redux": "^3.6.31", + "@types/react-redux": "^6.0.6", "@types/redux-actions": "^2.2.1", "@types/sinon": "^5.0.0", "@types/strip-ansi": "^3.0.0", - "@types/supertest": "^2.0.4", + "@types/supertest": "^2.0.5", "@types/type-detect": "^4.0.1", "angular-mocks": "1.4.7", "babel-eslint": "8.1.2", - "babel-jest": "^22.4.3", - "backport": "4.2.0", + "babel-jest": "^23.4.2", + "backport": "4.4.1", "chai": "3.5.0", "chance": "1.0.10", "cheerio": "0.22.0", @@ -260,7 +260,7 @@ "eslint-config-prettier": "^2.9.0", "eslint-plugin-babel": "4.1.2", "eslint-plugin-import": "2.8.0", - "eslint-plugin-jest": "^21.6.2", + "eslint-plugin-jest": "^21.22.0", "eslint-plugin-mocha": "4.11.0", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "1.2.1", @@ -285,8 +285,8 @@ "husky": "0.8.1", "image-diff": "1.6.0", "istanbul-instrumenter-loader": "3.0.0", - "jest": "^22.4.3", - "jest-cli": "^22.4.3", + "jest": "^23.5.0", + "jest-cli": "^23.5.0", "jest-raw-loader": "^1.0.1", "jimp": "0.2.28", "jsdom": "9.9.1", @@ -315,19 +315,20 @@ "postcss": "^7.0.2", "prettier": "^1.14.0", "proxyquire": "1.7.11", + "regenerate": "^1.4.0", "simple-git": "1.37.0", "sinon": "^5.0.7", "strip-ansi": "^3.0.1", - "supertest": "3.0.0", - "supertest-as-promised": "4.0.2", + "supertest": "^3.1.0", + "supertest-as-promised": "^4.0.2", "tree-kill": "^1.1.0", - "ts-jest": "^22.4.6", + "ts-jest": "^23.1.4", "ts-loader": "^3.5.0", - "ts-node": "^6.0.3", - "tslint": "^5.10.0", - "tslint-config-prettier": "^1.12.0", + "ts-node": "^7.0.1", + "tslint": "^5.11.0", + "tslint-config-prettier": "^1.15.0", "tslint-plugin-prettier": "^1.3.0", - "typescript": "^2.9.2", + "typescript": "^3.0.3", "vinyl-fs": "^3.0.2", "xml2js": "^0.4.19", "xmlbuilder": "9.0.4", diff --git a/packages/eslint-config-kibana/.eslintrc.js b/packages/eslint-config-kibana/.eslintrc.js index 0cfc3b266b0ac5..c14d0268e1578e 100644 --- a/packages/eslint-config-kibana/.eslintrc.js +++ b/packages/eslint-config-kibana/.eslintrc.js @@ -56,7 +56,7 @@ module.exports = { 'no-iterator': 'error', 'no-loop-func': 'error', 'no-multi-spaces': 'off', - 'no-multi-str': 'error', + 'no-multi-str': 'off', 'no-nested-ternary': 'error', 'no-new': 'off', 'no-path-concat': 'off', diff --git a/packages/eslint-config-kibana/package.json b/packages/eslint-config-kibana/package.json index 0c04ea50a4c06a..19574bb2df0588 100644 --- a/packages/eslint-config-kibana/package.json +++ b/packages/eslint-config-kibana/package.json @@ -19,7 +19,7 @@ "eslint": "^4.1.0", "eslint-plugin-babel": "^4.1.1", "eslint-plugin-import": "^2.6.0", - "eslint-plugin-jest": "^21.0.0", + "eslint-plugin-jest": "^21.22.0", "eslint-plugin-mocha": "^4.9.0", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "^1.2.1", diff --git a/packages/kbn-datemath/package.json b/packages/kbn-datemath/package.json index 5338b65d83f2dc..c469661070816f 100644 --- a/packages/kbn-datemath/package.json +++ b/packages/kbn-datemath/package.json @@ -5,8 +5,9 @@ "license": "Apache-2.0", "private": true, "main": "target/index.js", + "typings": "target/index.d.ts", "scripts": { - "build": "babel src --out-dir target", + "build": "babel src --out-dir target --copy-files", "kbn:bootstrap": "yarn build", "kbn:watch": "yarn build --watch" }, diff --git a/packages/kbn-datemath/src/index.d.ts b/packages/kbn-datemath/src/index.d.ts new file mode 100644 index 00000000000000..e3389fb255700f --- /dev/null +++ b/packages/kbn-datemath/src/index.d.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +declare module '@kbn/datemath' { + const dateMath: { + parse: any; + unitsMap: any; + units: string[]; + unitsAsc: string[]; + unitsDesc: string[]; + }; + export default dateMath; +} diff --git a/packages/kbn-datemath/src/index.js b/packages/kbn-datemath/src/index.js index 17d91a530fdb38..6576a458fe77b6 100644 --- a/packages/kbn-datemath/src/index.js +++ b/packages/kbn-datemath/src/index.js @@ -19,9 +19,20 @@ import moment from 'moment'; -const units = ['y', 'M', 'w', 'd', 'h', 'm', 's', 'ms']; -const unitsDesc = units; -const unitsAsc = [...unitsDesc].reverse(); +const unitsMap = { + ms: { weight: 1, type: 'fixed', base: 1 }, + s: { weight: 2, type: 'fixed', base: 1000 }, + m: { weight: 3, type: 'mixed', base: 1000 * 60 }, + h: { weight: 4, type: 'mixed', base: 1000 * 60 * 60 }, + d: { weight: 5, type: 'mixed', base: 1000 * 60 * 60 * 24 }, + w: { weight: 6, type: 'calendar' }, + M: { weight: 7, type: 'calendar' }, + // q: { weight: 8, type: 'calendar' }, // TODO: moment duration does not support quarter + y: { weight: 9, type: 'calendar' }, +}; +const units = Object.keys(unitsMap).sort((a, b) => unitsMap[b].weight - unitsMap[a].weight); +const unitsDesc = [...units]; +const unitsAsc = [...units].reverse(); const isDate = d => Object.prototype.toString.call(d) === '[object Date]'; @@ -142,6 +153,7 @@ function parseDateMath(mathString, time, roundUp) { export default { parse: parse, + unitsMap: Object.freeze(unitsMap), units: Object.freeze(units), unitsAsc: Object.freeze(unitsAsc), unitsDesc: Object.freeze(unitsDesc), diff --git a/packages/kbn-datemath/tsconfig.json b/packages/kbn-datemath/tsconfig.json new file mode 100644 index 00000000000000..c23b6635a5c19e --- /dev/null +++ b/packages/kbn-datemath/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "./target" + }, + "include": [ + "./src/**/*.ts" + ] +} diff --git a/packages/kbn-dev-utils/src/index.js b/packages/kbn-dev-utils/src/index.js index c8c3d28aa357af..4e2d1b62a92f71 100644 --- a/packages/kbn-dev-utils/src/index.js +++ b/packages/kbn-dev-utils/src/index.js @@ -18,4 +18,4 @@ */ export { withProcRunner } from './proc_runner'; -export { ToolingLog, pickLevelFromFlags } from './tooling_log'; +export { ToolingLog, ToolingLogTextWriter, pickLevelFromFlags } from './tooling_log'; diff --git a/packages/kbn-dev-utils/src/proc_runner/__tests__/proc.sh b/packages/kbn-dev-utils/src/proc_runner/__tests__/proc.sh deleted file mode 100755 index 5c038cd76807d1..00000000000000 --- a/packages/kbn-dev-utils/src/proc_runner/__tests__/proc.sh +++ /dev/null @@ -1,3 +0,0 @@ -#! /usr/bin/node - -console.log(process.argv.join(' ')); diff --git a/packages/kbn-dev-utils/src/proc_runner/with_proc_runner.test.js b/packages/kbn-dev-utils/src/proc_runner/with_proc_runner.test.js new file mode 100644 index 00000000000000..4d0329de7c32ad --- /dev/null +++ b/packages/kbn-dev-utils/src/proc_runner/with_proc_runner.test.js @@ -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 { ToolingLog } from '../tooling_log'; +import { withProcRunner } from './with_proc_runner'; +import { ProcRunner } from './proc_runner'; + +it('passes proc runner to a function', async () => { + await withProcRunner(new ToolingLog(), proc => { + expect(proc).toBeInstanceOf(ProcRunner); + }); +}); + +it('calls procRunner.teardown() if function returns synchronously', async () => { + let teardownSpy; + await withProcRunner(new ToolingLog(), proc => { + teardownSpy = jest.spyOn(proc, 'teardown'); + }); + + expect(teardownSpy).toHaveBeenCalled(); +}); + +it('calls procRunner.teardown() if function throw synchronous error, and rejects with the error', async () => { + const error = new Error('foo'); + let teardownSpy; + + await expect( + withProcRunner(new ToolingLog(), proc => { + teardownSpy = jest.spyOn(proc, 'teardown'); + throw error; + }) + ).rejects.toThrowError(error); + + expect(teardownSpy).toHaveBeenCalled(); +}); + +it('waits for promise to resolve before tearing down proc', async () => { + let teardownSpy; + + await withProcRunner(new ToolingLog(), async proc => { + await new Promise(resolve => setTimeout(resolve, 500)); + teardownSpy = jest.spyOn(proc, 'teardown'); + }); + + expect(teardownSpy).not.toBe(undefined); + expect(teardownSpy).toHaveBeenCalled(); +}); + +it('waits for promise to reject before tearing down proc and rejecting with the error', async () => { + const error = new Error('foo'); + let teardownSpy; + + await expect( + withProcRunner(new ToolingLog(), async proc => { + await new Promise(resolve => setTimeout(resolve, 500)); + teardownSpy = jest.spyOn(proc, 'teardown'); + throw error; + }) + ).rejects.toThrowError(error); + + expect(teardownSpy).not.toBe(undefined); + expect(teardownSpy).toHaveBeenCalled(); +}); diff --git a/packages/kbn-i18n/GUIDELINE.md b/packages/kbn-i18n/GUIDELINE.md index 22e6fd9823c94d..77e54fc1240f4b 100644 --- a/packages/kbn-i18n/GUIDELINE.md +++ b/packages/kbn-i18n/GUIDELINE.md @@ -7,16 +7,16 @@ The message ids chosen for message keys are descriptive of the string, and its role in the interface (button, label, header, etc.). Each message id ends with a descriptive type. Types are defined at the end of message id by combining to the last segment using camel case. The following types are supported: -- header -- label -- button -- dropDown -- placeholder -- tooltip -- aria -- errorMessage -- toggleSwitch -- link and etc. +- Title +- Label +- ButtonLabel +- DropDown +- Placeholder +- Tooltip +- AriaLabel +- ErrorMessage +- ToggleSwitch +- LinkLabel and etc. There is one more complex case, when we have to divide a single expression into different labels. @@ -45,7 +45,7 @@ For example: ```js { - 'kbn.management.editIndexPattern.scripted.deprecationLangLabel.painlessLink': 'Painless' + 'kbn.management.editIndexPattern.scripted.deprecationLangLabel.painlessLinkLabel': 'Painless' } ``` @@ -83,7 +83,7 @@ In case when `indicesLength` has value 1, the result string will be "`1 index`". The message ids chosen for message keys should always be descriptive of the string, and its role in the interface (button label, title, etc.). Think of them as long variable names. When you have to change a message id, adding a progressive number to the existing key should always be used as a last resort. -- Message id should start with namespace (`kbn`, `common.ui` and etc.). +- Message id should start with namespace that identifies a functional area of the app (`common.ui` or `common.server`) or a plugin (`kbn`, `vega`, etc.). For example: @@ -97,10 +97,10 @@ The message ids chosen for message keys should always be descriptive of the stri - Each message id should end with a type. For example: ```js - 'kbn.management.editIndexPattern.createIndexButton' - 'kbn.management.editIndexPattern.mappingConflictHeader' + 'kbn.management.editIndexPattern.createIndexButtonLabel' + 'kbn.management.editIndexPattern.mappingConflictTitle' 'kbn.management.editIndexPattern.mappingConflictLabel' - 'kbn.management.editIndexPattern.fields.filterAria' + 'kbn.management.editIndexPattern.fields.filterAriaLabel' 'kbn.management.editIndexPattern.fields.filterPlaceholder' 'kbn.management.editIndexPattern.refreshTooltip' 'kbn.management.editIndexPattern.fields.allTypesDropDown' @@ -153,12 +153,12 @@ Each message id should end with a type of the message. | type | example message id | | --- | --- | -| header | `kbn.management.createIndexPatternHeader` | +| header | `kbn.management.createIndexPatternTitle` | | label | `kbn.management.createIndexPatternLabel ` | -| button | `kbn.management.editIndexPattern.scripted.addFieldButton` | +| button | `kbn.management.editIndexPattern.scripted.addFieldButtonLabel` | | drop down | `kbn.management.editIndexPattern.fields.allTypesDropDown` | | placeholder | `kbn.management.createIndexPattern.stepTime.options.patternPlaceholder` | -| `aria-label` attribute | `kbn.management.editIndexPattern.removeAria` | +| `aria-label` attribute | `kbn.management.editIndexPattern.removeAriaLabel` | | tooltip | `kbn.management.editIndexPattern.removeTooltip` | | error message | `kbn.management.createIndexPattern.step.invalidCharactersErrorMessage` | | toggleSwitch | `kbn.management.createIndexPattern.includeSystemIndicesToggleSwitch` | @@ -170,7 +170,7 @@ For example: ```js

@@ -192,7 +192,7 @@ For example: ```js - + ``` @@ -221,7 +221,7 @@ For example: ```js @@ -283,13 +283,19 @@ Splitting sentences into several keys often inadvertently presumes a grammar, a ### Unit tests -When testing React component that use the injectI18n higher-order component, use the shallowWithIntl helper function defined in test_utils/enzyme_helpers to render the component. This will shallow render the component with Enzyme and inject the necessary context and props to use the intl mock defined in test_utils/mocks/intl. +Testing React component that uses the `injectI18n` higher-order component is more complicated because `injectI18n()` creates a wrapper component around the original component. + +With shallow rendering only top level component is rendered, that is a wrapper itself, not the original component. Since we want to test the rendering of the original component, we need to access it via the wrapper's `WrappedComponent` property. Its value will be the component we passed into `injectI18n()`. + +When testing such component, use the `shallowWithIntl` helper function defined in `test_utils/enzyme_helpers` and pass the component's `WrappedComponent` property to render the wrapped component. This will shallow render the component with Enzyme and inject the necessary context and props to use the `intl` mock defined in `test_utils/mocks/intl`. + +Use the `mountWithIntl` helper function to mount render the component. For example, there is a component that is wrapped by `injectI18n`, like in the `AddFilter` component: ```js // ... -export class AddFilterComponent extends Component { +class AddFilterUi extends Component { // ... render() { const { filter } = this.state; @@ -311,16 +317,16 @@ export class AddFilterComponent extends Component { } } -export const AddFilter = injectI18n(AddFilterComponent); +export const AddFilter = injectI18n(AddFilterUi); ``` -To test the `AddFilterComponent` component it is needed to render it using `shallowWithIntl` function to pass `intl` object into the `props`. +To test the `AddFilter` component it is needed to render its `WrappedComponent` property using `shallowWithIntl` function to pass `intl` object into the `props`. ```js // ... it('should render normally', async () => { const component = shallowWithIntl( - {}}/> + {}}/> ); expect(component).toMatchSnapshot(); diff --git a/packages/kbn-plugin-generator/sao_template/template/package.json b/packages/kbn-plugin-generator/sao_template/template/package.json index 70608f3c2d79aa..f61e589960cd8d 100755 --- a/packages/kbn-plugin-generator/sao_template/template/package.json +++ b/packages/kbn-plugin-generator/sao_template/template/package.json @@ -24,7 +24,7 @@ "eslint": "^4.11.0", "eslint-plugin-babel": "^4.1.1", "eslint-plugin-import": "^2.3.0", - "eslint-plugin-jest": "^21.3.2", + "eslint-plugin-jest": "^21.22.0", "eslint-plugin-mocha": "^4.9.0", "eslint-plugin-no-unsanitized": "^3.0.2", "eslint-plugin-prefer-object-spread": "^1.2.1", diff --git a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/build_action_test_plugin/translations/es.json b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/build_action_test_plugin/translations/es.json index 2f0e09a5542ed4..b553d7b7d5fe86 100644 --- a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/build_action_test_plugin/translations/es.json +++ b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/build_action_test_plugin/translations/es.json @@ -1,4 +1,4 @@ { - "UI-WELCOME_MESSAGE": "Cargando Kibana", - "UI-WELCOME_ERROR": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." -} \ No newline at end of file + "common.ui.welcomeMessage": "Cargando Kibana", + "common.ui.welcomeError": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." +} diff --git a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_build_test_plugin/translations/es.json b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_build_test_plugin/translations/es.json index 2f0e09a5542ed4..b553d7b7d5fe86 100644 --- a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_build_test_plugin/translations/es.json +++ b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_build_test_plugin/translations/es.json @@ -1,4 +1,4 @@ { - "UI-WELCOME_MESSAGE": "Cargando Kibana", - "UI-WELCOME_ERROR": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." -} \ No newline at end of file + "common.ui.welcomeMessage": "Cargando Kibana", + "common.ui.welcomeError": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." +} diff --git a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_package_test_plugin/translations/es.json b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_package_test_plugin/translations/es.json index 2f0e09a5542ed4..b553d7b7d5fe86 100644 --- a/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_package_test_plugin/translations/es.json +++ b/packages/kbn-plugin-helpers/tasks/build/__fixtures__/create_package_test_plugin/translations/es.json @@ -1,4 +1,4 @@ { - "UI-WELCOME_MESSAGE": "Cargando Kibana", - "UI-WELCOME_ERROR": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." -} \ No newline at end of file + "common.ui.welcomeMessage": "Cargando Kibana", + "common.ui.welcomeError": "Kibana no se cargó correctamente. Heck la salida del servidor para obtener más información." +} diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 959d8ed6ba91c2..4128527ef757f7 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -35202,7 +35202,7 @@ exports.runScriptInPackage = exports.installInDir = undefined; */ let installInDir = exports.installInDir = (() => { var _ref = _asyncToGenerator(function* (directory, extraArgs = []) { - const options = ['install', '--check-files', '--non-interactive', '--mutex file', ...extraArgs]; + const options = ['install', '--check-files', '--non-interactive', '--mutex=file', ...extraArgs]; // We pass the mutex flag to ensure only one instance of yarn runs at any // given time (e.g. to avoid conflicts). yield (0, _child_process.spawn)('yarn', options, { diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index f7faefa5f9d464..ada22294060cd0 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -20,7 +20,7 @@ "@types/globby": "^6.1.0", "@types/has-ansi": "^3.0.0", "@types/indent-string": "^3.0.0", - "@types/jest": "^22.1.3", + "@types/jest": "^23.3.1", "@types/lodash.clonedeepwith": "^4.5.3", "@types/log-symbols": "^2.0.0", "@types/mkdirp": "^0.5.2", @@ -60,7 +60,7 @@ "strong-log-transformer": "^1.0.6", "tempy": "^0.2.1", "ts-loader": "^3.5.0", - "typescript": "^2.9.2", + "typescript": "^3.0.3", "webpack": "^3.11.0", "wrap-ansi": "^3.0.1", "write-pkg": "^3.1.0" diff --git a/packages/kbn-pm/src/commands/bootstrap.test.ts b/packages/kbn-pm/src/commands/bootstrap.test.ts index 92785ebc91d3d7..66bdc6d82ecda0 100644 --- a/packages/kbn-pm/src/commands/bootstrap.test.ts +++ b/packages/kbn-pm/src/commands/bootstrap.test.ts @@ -53,6 +53,7 @@ const noop = () => { afterEach(() => { jest.resetAllMocks(); + jest.restoreAllMocks(); }); test('handles dependencies of dependencies', async () => { @@ -96,8 +97,6 @@ test('handles dependencies of dependencies', async () => { rootPath: '', }); - logMock.mockRestore(); - expect(mockInstallInDir.mock.calls).toMatchSnapshot('install in dir'); expect(logMock.mock.calls).toMatchSnapshot('logs'); }); @@ -127,8 +126,6 @@ test('does not run installer if no deps in package', async () => { rootPath: '', }); - logMock.mockRestore(); - expect(mockInstallInDir.mock.calls).toMatchSnapshot('install in dir'); expect(logMock.mock.calls).toMatchSnapshot('logs'); }); @@ -143,7 +140,7 @@ test('handles "frozen-lockfile"', async () => { const projects = new Map([['kibana', kibana]]); const projectGraph = buildProjectGraph(projects); - const logMock = jest.spyOn(console, 'log').mockImplementation(noop); + jest.spyOn(console, 'log').mockImplementation(noop); await BootstrapCommand.run(projects, projectGraph, { extraArgs: [], @@ -153,8 +150,6 @@ test('handles "frozen-lockfile"', async () => { rootPath: '', }); - logMock.mockRestore(); - expect(mockInstallInDir.mock.calls).toMatchSnapshot('install in dir'); }); @@ -177,7 +172,7 @@ test('calls "kbn:bootstrap" scripts and links executables after installing deps' const projects = new Map([['kibana', kibana], ['bar', bar]]); const projectGraph = buildProjectGraph(projects); - const logMock = jest.spyOn(console, 'log').mockImplementation(noop); + jest.spyOn(console, 'log').mockImplementation(noop); await BootstrapCommand.run(projects, projectGraph, { extraArgs: [], @@ -185,8 +180,6 @@ test('calls "kbn:bootstrap" scripts and links executables after installing deps' rootPath: '', }); - logMock.mockRestore(); - expect(mockLinkProjectExecutables.mock.calls).toMatchSnapshot('link bins'); expect(mockRunScriptInPackageStreaming.mock.calls).toMatchSnapshot('script'); }); diff --git a/packages/kbn-pm/src/utils/link_project_executables.test.ts b/packages/kbn-pm/src/utils/link_project_executables.test.ts index b618a28e642d36..ac0b69d01f798e 100644 --- a/packages/kbn-pm/src/utils/link_project_executables.test.ts +++ b/packages/kbn-pm/src/utils/link_project_executables.test.ts @@ -81,6 +81,7 @@ expect.addSnapshotSerializer(stripAnsiSnapshotSerializer); afterEach(() => { jest.resetAllMocks(); + jest.restoreAllMocks(); }); describe('bin script points nowhere', () => { @@ -102,7 +103,6 @@ describe('bin script points to a file', () => { // noop }); await linkProjectExecutables(projectsByName, projectGraph); - logMock.mockRestore(); expect(getFsMockCalls()).toMatchSnapshot('fs module calls'); expect(logMock.mock.calls).toMatchSnapshot('logs'); diff --git a/packages/kbn-pm/src/utils/scripts.ts b/packages/kbn-pm/src/utils/scripts.ts index 4131142a75344c..d7076b1d28b39e 100644 --- a/packages/kbn-pm/src/utils/scripts.ts +++ b/packages/kbn-pm/src/utils/scripts.ts @@ -24,7 +24,7 @@ import { Project } from './project'; * Install all dependencies in the given directory */ export async function installInDir(directory: string, extraArgs: string[] = []) { - const options = ['install', '--check-files', '--non-interactive', '--mutex file', ...extraArgs]; + const options = ['install', '--check-files', '--non-interactive', '--mutex=file', ...extraArgs]; // We pass the mutex flag to ensure only one instance of yarn runs at any // given time (e.g. to avoid conflicts). diff --git a/packages/kbn-pm/yarn.lock b/packages/kbn-pm/yarn.lock index c16d37de830c8e..abbc780efcc1f8 100644 --- a/packages/kbn-pm/yarn.lock +++ b/packages/kbn-pm/yarn.lock @@ -70,9 +70,9 @@ version "3.0.0" resolved "https://registry.yarnpkg.com/@types/indent-string/-/indent-string-3.0.0.tgz#9ebb391ceda548926f5819ad16405349641b999f" -"@types/jest@^22.1.3": - version "22.1.3" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.1.3.tgz#25da391935e6fac537551456f077ce03144ec168" +"@types/jest@^23.3.1": + version "23.3.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.1.tgz#a4319aedb071d478e6f407d1c4578ec8156829cf" "@types/lodash.clonedeepwith@^4.5.3": version "4.5.3" @@ -3592,9 +3592,9 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typescript@^2.9.2: - version "2.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c" +typescript@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" uglify-js@^2.8.29: version "2.8.29" diff --git a/packages/kbn-system-loader/package.json b/packages/kbn-system-loader/package.json index 5a169283edb771..55c0a860c08c0d 100644 --- a/packages/kbn-system-loader/package.json +++ b/packages/kbn-system-loader/package.json @@ -10,7 +10,7 @@ "kbn:bootstrap": "yarn build" }, "devDependencies": { - "@types/jest": "^22.2.2", - "typescript": "^2.9.2" + "@types/jest": "^23.3.1", + "typescript": "^3.0.3" } } diff --git a/packages/kbn-system-loader/yarn.lock b/packages/kbn-system-loader/yarn.lock index 5e55ff3b0318ab..c20eb45a4d0a90 100644 --- a/packages/kbn-system-loader/yarn.lock +++ b/packages/kbn-system-loader/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@types/jest@^22.2.2": - version "22.2.2" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.2.2.tgz#afe5dacbd00d65325f52da0ed3e76e259629ac9d" +"@types/jest@^23.3.1": + version "23.3.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.1.tgz#a4319aedb071d478e6f407d1c4578ec8156829cf" -typescript@^2.9.2: - version "2.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c" +typescript@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" diff --git a/packages/kbn-test/src/es/es_test_config.js b/packages/kbn-test/src/es/es_test_config.js index 2e9e91e50b4b2b..e273172ef614b5 100644 --- a/packages/kbn-test/src/es/es_test_config.js +++ b/packages/kbn-test/src/es/es_test_config.js @@ -53,8 +53,9 @@ export const esTestConfig = new class EsTestConfig { }; } - const username = process.env.TEST_KIBANA_USERNAME || adminTestUser.username; - const password = process.env.TEST_KIBANA_PASSWORD || adminTestUser.password; + const username = process.env.TEST_ES_USERNAME || adminTestUser.username; + const password = process.env.TEST_ES_PASSWORD || adminTestUser.password; + return { // Allow setting any individual component(s) of the URL, // or use default values (username and password from ../kbn/users.js) diff --git a/scripts/extract_default_translations.js b/scripts/i18n_check.js similarity index 93% rename from scripts/extract_default_translations.js rename to scripts/i18n_check.js index 4de2184cb1be29..f461e1514e69f5 100644 --- a/scripts/extract_default_translations.js +++ b/scripts/i18n_check.js @@ -18,4 +18,4 @@ */ require('../src/setup_node_env'); -require('../src/dev/run_extract_default_translations'); +require('../src/dev/run_i18n_check'); diff --git a/src/cli/cluster/_mock_cluster_fork.js b/src/cli/cluster/__mocks__/cluster.js similarity index 75% rename from src/cli/cluster/_mock_cluster_fork.js rename to src/cli/cluster/__mocks__/cluster.js index 4312f6a85c53ad..14efc4b6f01504 100644 --- a/src/cli/cluster/_mock_cluster_fork.js +++ b/src/cli/cluster/__mocks__/cluster.js @@ -16,15 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +/* eslint-env jest */ import EventEmitter from 'events'; import { assign, random } from 'lodash'; -import sinon from 'sinon'; -import cluster from 'cluster'; import { delay } from 'bluebird'; -export default class MockClusterFork extends EventEmitter { - constructor() { +class MockClusterFork extends EventEmitter { + constructor(cluster) { super(); let dead = true; @@ -35,7 +34,7 @@ export default class MockClusterFork extends EventEmitter { assign(this, { process: { - kill: sinon.spy(() => { + kill: jest.fn(() => { (async () => { await wait(); this.emit('disconnect'); @@ -46,13 +45,13 @@ export default class MockClusterFork extends EventEmitter { })(); }), }, - isDead: sinon.spy(() => dead), - send: sinon.stub() + isDead: jest.fn(() => dead), + send: jest.fn() }); - sinon.spy(this, 'on'); - sinon.spy(this, 'removeListener'); - sinon.spy(this, 'emit'); + jest.spyOn(this, 'on'); + jest.spyOn(this, 'removeListener'); + jest.spyOn(this, 'emit'); (async () => { await wait(); @@ -61,3 +60,12 @@ export default class MockClusterFork extends EventEmitter { })(); } } + +class MockCluster extends EventEmitter { + fork = jest.fn(() => new MockClusterFork(this)); + setupMaster = jest.fn(); +} + +export function mockCluster() { + return new MockCluster(); +} diff --git a/src/cli/cluster/cluster_manager.js b/src/cli/cluster/cluster_manager.js index 0a514138b09f2d..1ea8a91eb21ef6 100644 --- a/src/cli/cluster/cluster_manager.js +++ b/src/cli/cluster/cluster_manager.js @@ -19,31 +19,30 @@ import { resolve } from 'path'; import { debounce, invoke, bindAll, once, uniq } from 'lodash'; +import { fromEvent, race } from 'rxjs'; +import { first } from 'rxjs/operators'; import Log from '../log'; import Worker from './worker'; import { Config } from '../../server/config/config'; import { transformDeprecations } from '../../server/config/transform_deprecations'; -import { configureBasePathProxy } from './configure_base_path_proxy'; process.env.kbnWorkerType = 'managr'; export default class ClusterManager { - static async create(opts = {}, settings = {}) { - const transformedSettings = transformDeprecations(settings); - const config = Config.withDefaultSchema(transformedSettings); - - const basePathProxy = opts.basePath - ? await configureBasePathProxy(config) - : undefined; - - return new ClusterManager(opts, config, basePathProxy); + static create(opts, settings = {}, basePathProxy) { + return new ClusterManager( + opts, + Config.withDefaultSchema(transformDeprecations(settings)), + basePathProxy + ); } constructor(opts, config, basePathProxy) { this.log = new Log(opts.quiet, opts.silent); this.addedCount = 0; this.inReplMode = !!opts.repl; + this.basePathProxy = basePathProxy; const serverArgv = []; const optimizerArgv = [ @@ -51,17 +50,15 @@ export default class ClusterManager { '--server.autoListen=false', ]; - if (basePathProxy) { - this.basePathProxy = basePathProxy; - + if (this.basePathProxy) { optimizerArgv.push( - `--server.basePath=${this.basePathProxy.getBasePath()}`, + `--server.basePath=${this.basePathProxy.basePath}`, '--server.rewriteBasePath=true', ); serverArgv.push( - `--server.port=${this.basePathProxy.getTargetPort()}`, - `--server.basePath=${this.basePathProxy.getBasePath()}`, + `--server.port=${this.basePathProxy.targetPort}`, + `--server.basePath=${this.basePathProxy.basePath}`, '--server.rewriteBasePath=true', ); } @@ -82,12 +79,6 @@ export default class ClusterManager { }) ]; - if (basePathProxy) { - // Pass server worker to the basepath proxy so that it can hold off the - // proxying until server worker is ready. - this.basePathProxy.serverWorker = this.server; - } - // broker messages between workers this.workers.forEach((worker) => { worker.on('broadcast', (msg) => { @@ -130,7 +121,10 @@ export default class ClusterManager { this.setupManualRestart(); invoke(this.workers, 'start'); if (this.basePathProxy) { - this.basePathProxy.start(); + this.basePathProxy.start({ + blockUntil: this.blockUntil.bind(this), + shouldRedirectFromOldBasePath: this.shouldRedirectFromOldBasePath.bind(this), + }); } } @@ -222,4 +216,23 @@ export default class ClusterManager { this.log.bad('failed to watch files!\n', err.stack); process.exit(1); // eslint-disable-line no-process-exit } + + shouldRedirectFromOldBasePath(path) { + const isApp = path.startsWith('app/'); + const isKnownShortPath = ['login', 'logout', 'status'].includes(path); + + return isApp || isKnownShortPath; + } + + blockUntil() { + // Wait until `server` worker either crashes or starts to listen. + if (this.server.listening || this.server.crashed) { + return Promise.resolve(); + } + + return race( + fromEvent(this.server, 'listening'), + fromEvent(this.server, 'crashed') + ).pipe(first()).toPromise(); + } } diff --git a/src/cli/cluster/cluster_manager.test.js b/src/cli/cluster/cluster_manager.test.js index b80ee62da29c31..ab42c4a369bb85 100644 --- a/src/cli/cluster/cluster_manager.test.js +++ b/src/cli/cluster/cluster_manager.test.js @@ -17,36 +17,43 @@ * under the License. */ -import sinon from 'sinon'; +import { mockCluster } from './__mocks__/cluster'; +jest.mock('cluster', () => mockCluster()); +jest.mock('readline', () => ({ + createInterface: jest.fn(() => ({ + on: jest.fn(), + prompt: jest.fn(), + setPrompt: jest.fn(), + })), +})); + import cluster from 'cluster'; import { sample } from 'lodash'; import ClusterManager from './cluster_manager'; import Worker from './worker'; -describe('CLI cluster manager', function () { - const sandbox = sinon.createSandbox(); - - beforeEach(function () { - sandbox.stub(cluster, 'fork').callsFake(() => { +describe('CLI cluster manager', () => { + beforeEach(() => { + cluster.fork.mockImplementation(() => { return { process: { - kill: sinon.stub(), + kill: jest.fn(), }, - isDead: sinon.stub().returns(false), - removeListener: sinon.stub(), - on: sinon.stub(), - send: sinon.stub() + isDead: jest.fn().mockReturnValue(false), + removeListener: jest.fn(), + addListener: jest.fn(), + send: jest.fn() }; }); }); - afterEach(function () { - sandbox.restore(); + afterEach(() => { + cluster.fork.mockReset(); }); - it('has two workers', async function () { - const manager = await ClusterManager.create({}); + test('has two workers', () => { + const manager = ClusterManager.create({}); expect(manager.workers).toHaveLength(2); for (const worker of manager.workers) expect(worker).toBeInstanceOf(Worker); @@ -55,8 +62,8 @@ describe('CLI cluster manager', function () { expect(manager.server).toBeInstanceOf(Worker); }); - it('delivers broadcast messages to other workers', async function () { - const manager = await ClusterManager.create({}); + test('delivers broadcast messages to other workers', () => { + const manager = ClusterManager.create({}); for (const worker of manager.workers) { Worker.prototype.start.call(worker);// bypass the debounced start method @@ -69,10 +76,111 @@ describe('CLI cluster manager', function () { messenger.emit('broadcast', football); for (const worker of manager.workers) { if (worker === messenger) { - expect(worker.fork.send.callCount).toBe(0); + expect(worker.fork.send).not.toHaveBeenCalled(); } else { - expect(worker.fork.send.firstCall.args[0]).toBe(football); + expect(worker.fork.send).toHaveBeenCalledTimes(1); + expect(worker.fork.send).toHaveBeenCalledWith(football); } } }); + + describe('interaction with BasePathProxy', () => { + test('correctly configures `BasePathProxy`.', async () => { + const basePathProxyMock = { start: jest.fn() }; + + ClusterManager.create({}, {}, basePathProxyMock); + + expect(basePathProxyMock.start).toHaveBeenCalledWith({ + shouldRedirectFromOldBasePath: expect.any(Function), + blockUntil: expect.any(Function), + }); + }); + + describe('proxy is configured with the correct `shouldRedirectFromOldBasePath` and `blockUntil` functions.', () => { + let clusterManager; + let shouldRedirectFromOldBasePath; + let blockUntil; + beforeEach(async () => { + const basePathProxyMock = { start: jest.fn() }; + + clusterManager = ClusterManager.create({}, {}, basePathProxyMock); + + jest.spyOn(clusterManager.server, 'addListener'); + jest.spyOn(clusterManager.server, 'removeListener'); + + [[{ blockUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.start.mock.calls; + }); + + test('`shouldRedirectFromOldBasePath()` returns `false` for unknown paths.', () => { + expect(shouldRedirectFromOldBasePath('')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); + expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); + }); + + test('`shouldRedirectFromOldBasePath()` returns `true` for `app` and other known paths.', () => { + expect(shouldRedirectFromOldBasePath('app/')).toBe(true); + expect(shouldRedirectFromOldBasePath('login')).toBe(true); + expect(shouldRedirectFromOldBasePath('logout')).toBe(true); + expect(shouldRedirectFromOldBasePath('status')).toBe(true); + }); + + test('`blockUntil()` resolves immediately if worker has already crashed.', async () => { + clusterManager.server.crashed = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(clusterManager.server.addListener).not.toHaveBeenCalled(); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + }); + + test('`blockUntil()` resolves immediately if worker is already listening.', async () => { + clusterManager.server.listening = true; + + await expect(blockUntil()).resolves.not.toBeDefined(); + expect(clusterManager.server.addListener).not.toHaveBeenCalled(); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + }); + + test('`blockUntil()` resolves when worker crashes.', async () => { + const blockUntilPromise = blockUntil(); + + expect(clusterManager.server.addListener).toHaveBeenCalledTimes(2); + expect(clusterManager.server.addListener).toHaveBeenCalledWith( + 'crashed', + expect.any(Function) + ); + + const [, [eventName, onCrashed]] = clusterManager.server.addListener.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('crashed'); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + + onCrashed(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(clusterManager.server.removeListener).toHaveBeenCalledTimes(2); + }); + + test('`blockUntil()` resolves when worker starts listening.', async () => { + const blockUntilPromise = blockUntil(); + + expect(clusterManager.server.addListener).toHaveBeenCalledTimes(2); + expect(clusterManager.server.addListener).toHaveBeenCalledWith( + 'listening', + expect.any(Function) + ); + + const [[eventName, onListening]] = clusterManager.server.addListener.mock.calls; + // Check event name to make sure we call the right callback, + // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. + expect(eventName).toBe('listening'); + expect(clusterManager.server.removeListener).not.toHaveBeenCalled(); + + onListening(); + await expect(blockUntilPromise).resolves.not.toBeDefined(); + + expect(clusterManager.server.removeListener).toHaveBeenCalledTimes(2); + }); + }); + }); }); diff --git a/src/cli/cluster/configure_base_path_proxy.js b/src/cli/cluster/configure_base_path_proxy.js deleted file mode 100644 index 477b10053d1e66..00000000000000 --- a/src/cli/cluster/configure_base_path_proxy.js +++ /dev/null @@ -1,64 +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 { Server } from 'hapi'; -import { createBasePathProxy } from '../../core'; -import { setupLogging } from '../../server/logging'; - -export async function configureBasePathProxy(config) { - // New platform forwards all logs to the legacy platform so we need HapiJS server - // here just for logging purposes and nothing else. - const server = new Server(); - setupLogging(server, config); - - const basePathProxy = createBasePathProxy({ server, config }); - - await basePathProxy.configure({ - shouldRedirectFromOldBasePath: path => { - const isApp = path.startsWith('app/'); - const isKnownShortPath = ['login', 'logout', 'status'].includes(path); - - return isApp || isKnownShortPath; - }, - - blockUntil: () => { - // Wait until `serverWorker either crashes or starts to listen. - // The `serverWorker` property should be set by the ClusterManager - // once it creates the worker. - const serverWorker = basePathProxy.serverWorker; - if (serverWorker.listening || serverWorker.crashed) { - return Promise.resolve(); - } - - return new Promise(resolve => { - const done = () => { - serverWorker.removeListener('listening', done); - serverWorker.removeListener('crashed', done); - - resolve(); - }; - - serverWorker.on('listening', done); - serverWorker.on('crashed', done); - }); - }, - }); - - return basePathProxy; -} diff --git a/src/cli/cluster/configure_base_path_proxy.test.js b/src/cli/cluster/configure_base_path_proxy.test.js deleted file mode 100644 index 01cbaf0bcc9008..00000000000000 --- a/src/cli/cluster/configure_base_path_proxy.test.js +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('../../core', () => ({ - createBasePathProxy: jest.fn(), -})); - -jest.mock('../../server/logging', () => ({ - setupLogging: jest.fn(), -})); - -import { Server } from 'hapi'; -import { createBasePathProxy as createBasePathProxyMock } from '../../core'; -import { setupLogging as setupLoggingMock } from '../../server/logging'; -import { configureBasePathProxy } from './configure_base_path_proxy'; - -describe('configureBasePathProxy()', () => { - it('returns `BasePathProxy` instance.', async () => { - const basePathProxyMock = { configure: jest.fn() }; - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - const basePathProxy = await configureBasePathProxy({}); - - expect(basePathProxy).toBe(basePathProxyMock); - }); - - it('correctly configures `BasePathProxy`.', async () => { - const configMock = {}; - const basePathProxyMock = { configure: jest.fn() }; - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - await configureBasePathProxy(configMock); - - // Check that logging is configured with the right parameters. - expect(setupLoggingMock).toHaveBeenCalledWith( - expect.any(Server), - configMock - ); - - const [[server]] = setupLoggingMock.mock.calls; - expect(createBasePathProxyMock).toHaveBeenCalledWith({ - config: configMock, - server, - }); - - expect(basePathProxyMock.configure).toHaveBeenCalledWith({ - shouldRedirectFromOldBasePath: expect.any(Function), - blockUntil: expect.any(Function), - }); - }); - - describe('configured with the correct `shouldRedirectFromOldBasePath` and `blockUntil` functions.', async () => { - let serverWorkerMock; - let shouldRedirectFromOldBasePath; - let blockUntil; - beforeEach(async () => { - serverWorkerMock = { - listening: false, - crashed: false, - on: jest.fn(), - removeListener: jest.fn(), - }; - - const basePathProxyMock = { - configure: jest.fn(), - serverWorker: serverWorkerMock, - }; - - createBasePathProxyMock.mockReturnValue(basePathProxyMock); - - await configureBasePathProxy({}); - - [[{ blockUntil, shouldRedirectFromOldBasePath }]] = basePathProxyMock.configure.mock.calls; - }); - - it('`shouldRedirectFromOldBasePath()` returns `false` for unknown paths.', async () => { - expect(shouldRedirectFromOldBasePath('')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-path/')).toBe(false); - expect(shouldRedirectFromOldBasePath('some-other-path')).toBe(false); - }); - - it('`shouldRedirectFromOldBasePath()` returns `true` for `app` and other known paths.', async () => { - expect(shouldRedirectFromOldBasePath('app/')).toBe(true); - expect(shouldRedirectFromOldBasePath('login')).toBe(true); - expect(shouldRedirectFromOldBasePath('logout')).toBe(true); - expect(shouldRedirectFromOldBasePath('status')).toBe(true); - }); - - it('`blockUntil()` resolves immediately if worker has already crashed.', async () => { - serverWorkerMock.crashed = true; - - await expect(blockUntil()).resolves.not.toBeDefined(); - expect(serverWorkerMock.on).not.toHaveBeenCalled(); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - }); - - it('`blockUntil()` resolves immediately if worker is already listening.', async () => { - serverWorkerMock.listening = true; - - await expect(blockUntil()).resolves.not.toBeDefined(); - expect(serverWorkerMock.on).not.toHaveBeenCalled(); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - }); - - it('`blockUntil()` resolves when worker crashes.', async () => { - const blockUntilPromise = blockUntil(); - - expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); - expect(serverWorkerMock.on).toHaveBeenCalledWith( - 'crashed', - expect.any(Function) - ); - - const [, [eventName, onCrashed]] = serverWorkerMock.on.mock.calls; - // Check event name to make sure we call the right callback, - // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. - expect(eventName).toBe('crashed'); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - - onCrashed(); - await expect(blockUntilPromise).resolves.not.toBeDefined(); - - expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); - }); - - it('`blockUntil()` resolves when worker starts listening.', async () => { - const blockUntilPromise = blockUntil(); - - expect(serverWorkerMock.on).toHaveBeenCalledTimes(2); - expect(serverWorkerMock.on).toHaveBeenCalledWith( - 'listening', - expect.any(Function) - ); - - const [[eventName, onListening]] = serverWorkerMock.on.mock.calls; - // Check event name to make sure we call the right callback, - // in Jest 23 we could use `toHaveBeenNthCalledWith` instead. - expect(eventName).toBe('listening'); - expect(serverWorkerMock.removeListener).not.toHaveBeenCalled(); - - onListening(); - await expect(blockUntilPromise).resolves.not.toBeDefined(); - - expect(serverWorkerMock.removeListener).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/cli/cluster/worker.test.js b/src/cli/cluster/worker.test.js index c166956bcbf348..24687d640438a4 100644 --- a/src/cli/cluster/worker.test.js +++ b/src/cli/cluster/worker.test.js @@ -17,26 +17,25 @@ * under the License. */ -import sinon from 'sinon'; +import { mockCluster } from './__mocks__/cluster'; +jest.mock('cluster', () => mockCluster()); + import cluster from 'cluster'; -import { findIndex } from 'lodash'; -import MockClusterFork from './_mock_cluster_fork'; import Worker from './worker'; import Log from '../log'; const workersToShutdown = []; function assertListenerAdded(emitter, event) { - sinon.assert.calledWith(emitter.on, event); + expect(emitter.on).toHaveBeenCalledWith(event, expect.any(Function)); } function assertListenerRemoved(emitter, event) { - sinon.assert.calledWith( - emitter.removeListener, - event, - emitter.on.args[findIndex(emitter.on.args, { 0: event })][1] - ); + const [, onEventListener] = emitter.on.mock.calls.find(([eventName]) => { + return eventName === event; + }); + expect(emitter.removeListener).toHaveBeenCalledWith(event, onEventListener); } function setup(opts = {}) { @@ -50,81 +49,82 @@ function setup(opts = {}) { return worker; } -describe('CLI cluster manager', function () { - const sandbox = sinon.createSandbox(); - - beforeEach(function () { - sandbox.stub(cluster, 'fork').callsFake(() => new MockClusterFork()); - }); - - afterEach(async function () { - sandbox.restore(); +describe('CLI cluster manager', () => { + afterEach(async () => { + while(workersToShutdown.length > 0) { + const worker = workersToShutdown.pop(); + // If `fork` exists we should set `exitCode` to the non-zero value to + // prevent worker from auto restart. + if (worker.fork) { + worker.fork.exitCode = 1; + } - for (const worker of workersToShutdown) { await worker.shutdown(); } + + cluster.fork.mockClear(); }); - describe('#onChange', function () { - describe('opts.watch = true', function () { - it('restarts the fork', function () { + describe('#onChange', () => { + describe('opts.watch = true', () => { + test('restarts the fork', () => { const worker = setup({ watch: true }); - sinon.stub(worker, 'start'); + jest.spyOn(worker, 'start').mockImplementation(() => {}); worker.onChange('/some/path'); expect(worker.changes).toEqual(['/some/path']); - sinon.assert.calledOnce(worker.start); + expect(worker.start).toHaveBeenCalledTimes(1); }); }); - describe('opts.watch = false', function () { - it('does not restart the fork', function () { + describe('opts.watch = false', () => { + test('does not restart the fork', () => { const worker = setup({ watch: false }); - sinon.stub(worker, 'start'); + jest.spyOn(worker, 'start').mockImplementation(() => {}); worker.onChange('/some/path'); expect(worker.changes).toEqual([]); - sinon.assert.notCalled(worker.start); + expect(worker.start).not.toHaveBeenCalled(); }); }); }); - describe('#shutdown', function () { - describe('after starting()', function () { - it('kills the worker and unbinds from message, online, and disconnect events', async function () { + describe('#shutdown', () => { + describe('after starting()', () => { + test('kills the worker and unbinds from message, online, and disconnect events', async () => { const worker = setup(); await worker.start(); expect(worker).toHaveProperty('online', true); const fork = worker.fork; - sinon.assert.notCalled(fork.process.kill); + expect(fork.process.kill).not.toHaveBeenCalled(); assertListenerAdded(fork, 'message'); assertListenerAdded(fork, 'online'); assertListenerAdded(fork, 'disconnect'); worker.shutdown(); - sinon.assert.calledOnce(fork.process.kill); + expect(fork.process.kill).toHaveBeenCalledTimes(1); assertListenerRemoved(fork, 'message'); assertListenerRemoved(fork, 'online'); assertListenerRemoved(fork, 'disconnect'); }); }); - describe('before being started', function () { - it('does nothing', function () { + describe('before being started', () => { + test('does nothing', () => { const worker = setup(); worker.shutdown(); }); }); }); - describe('#parseIncomingMessage()', function () { - describe('on a started worker', function () { - it(`is bound to fork's message event`, async function () { + describe('#parseIncomingMessage()', () => { + describe('on a started worker', () => { + test(`is bound to fork's message event`, async () => { const worker = setup(); await worker.start(); - sinon.assert.calledWith(worker.fork.on, 'message'); + expect(worker.fork.on).toHaveBeenCalledWith('message', expect.any(Function)); }); }); - describe('do after', function () { - it('ignores non-array messages', function () { + describe('do after', () => { + test('ignores non-array messages', () => { const worker = setup(); worker.parseIncomingMessage('some string thing'); worker.parseIncomingMessage(0); @@ -134,39 +134,39 @@ describe('CLI cluster manager', function () { worker.parseIncomingMessage(/weird/); }); - it('calls #onMessage with message parts', function () { + test('calls #onMessage with message parts', () => { const worker = setup(); - const stub = sinon.stub(worker, 'onMessage'); + jest.spyOn(worker, 'onMessage').mockImplementation(() => {}); worker.parseIncomingMessage([10, 100, 1000, 10000]); - sinon.assert.calledWith(stub, 10, 100, 1000, 10000); + expect(worker.onMessage).toHaveBeenCalledWith(10, 100, 1000, 10000); }); }); }); - describe('#onMessage', function () { - describe('when sent WORKER_BROADCAST message', function () { - it('emits the data to be broadcasted', function () { + describe('#onMessage', () => { + describe('when sent WORKER_BROADCAST message', () => { + test('emits the data to be broadcasted', () => { const worker = setup(); const data = {}; - const stub = sinon.stub(worker, 'emit'); + jest.spyOn(worker, 'emit').mockImplementation(() => {}); worker.onMessage('WORKER_BROADCAST', data); - sinon.assert.calledWithExactly(stub, 'broadcast', data); + expect(worker.emit).toHaveBeenCalledWith('broadcast', data); }); }); - describe('when sent WORKER_LISTENING message', function () { - it('sets the listening flag and emits the listening event', function () { + describe('when sent WORKER_LISTENING message', () => { + test('sets the listening flag and emits the listening event', () => { const worker = setup(); - const stub = sinon.stub(worker, 'emit'); + jest.spyOn(worker, 'emit').mockImplementation(() => {}); expect(worker).toHaveProperty('listening', false); worker.onMessage('WORKER_LISTENING'); expect(worker).toHaveProperty('listening', true); - sinon.assert.calledWithExactly(stub, 'listening'); + expect(worker.emit).toHaveBeenCalledWith('listening'); }); }); - describe('when passed an unknown message', function () { - it('does nothing', function () { + describe('when passed an unknown message', () => { + test('does nothing', () => { const worker = setup(); worker.onMessage('asdlfkajsdfahsdfiohuasdofihsdoif'); worker.onMessage({}); @@ -175,46 +175,46 @@ describe('CLI cluster manager', function () { }); }); - describe('#start', function () { - describe('when not started', function () { - // TODO This test is flaky, see https://github.com/elastic/kibana/issues/15888 - it.skip('creates a fork and waits for it to come online', async function () { + describe('#start', () => { + describe('when not started', () => { + test('creates a fork and waits for it to come online', async () => { const worker = setup(); - sinon.spy(worker, 'on'); + jest.spyOn(worker, 'on'); await worker.start(); - sinon.assert.calledOnce(cluster.fork); - sinon.assert.calledWith(worker.on, 'fork:online'); + expect(cluster.fork).toHaveBeenCalledTimes(1); + expect(worker.on).toHaveBeenCalledWith('fork:online', expect.any(Function)); }); - // TODO This test is flaky, see https://github.com/elastic/kibana/issues/15888 - it.skip('listens for cluster and process "exit" events', async function () { + test('listens for cluster and process "exit" events', async () => { const worker = setup(); - sinon.spy(process, 'on'); - sinon.spy(cluster, 'on'); + jest.spyOn(process, 'on'); + jest.spyOn(cluster, 'on'); await worker.start(); - sinon.assert.calledOnce(cluster.on); - sinon.assert.calledWith(cluster.on, 'exit'); - sinon.assert.calledOnce(process.on); - sinon.assert.calledWith(process.on, 'exit'); + expect(cluster.on).toHaveBeenCalledTimes(1); + expect(cluster.on).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(process.on).toHaveBeenCalledTimes(1); + expect(process.on).toHaveBeenCalledWith('exit', expect.any(Function)); }); }); - describe('when already started', function () { - it('calls shutdown and waits for the graceful shutdown to cause a restart', async function () { + describe('when already started', () => { + test('calls shutdown and waits for the graceful shutdown to cause a restart', async () => { const worker = setup(); await worker.start(); - sinon.spy(worker, 'shutdown'); - sinon.spy(worker, 'on'); + + jest.spyOn(worker, 'shutdown'); + jest.spyOn(worker, 'on'); worker.start(); - sinon.assert.calledOnce(worker.shutdown); - sinon.assert.calledWith(worker.on, 'online'); + + expect(worker.shutdown).toHaveBeenCalledTimes(1); + expect(worker.on).toHaveBeenCalledWith('online', expect.any(Function)); }); }); }); diff --git a/src/cli/color.js b/src/cli/color.js index b678376ef7c247..a02fb551c41818 100644 --- a/src/cli/color.js +++ b/src/cli/color.js @@ -17,9 +17,8 @@ * under the License. */ -import _ from 'lodash'; import chalk from 'chalk'; -export const green = _.flow(chalk.black, chalk.bgGreen); -export const red = _.flow(chalk.white, chalk.bgRed); -export const yellow = _.flow(chalk.black, chalk.bgYellow); +export const green = chalk.black.bgGreen; +export const red = chalk.white.bgRed; +export const yellow = chalk.black.bgYellow; diff --git a/src/cli/serve/__fixtures__/invalid_en_var_ref_config.yml b/src/cli/serve/__fixtures__/invalid_en_var_ref_config.yml deleted file mode 100644 index 23458124e5f0e3..00000000000000 --- a/src/cli/serve/__fixtures__/invalid_en_var_ref_config.yml +++ /dev/null @@ -1 +0,0 @@ -foo: "${KBN_NON_EXISTENT_ENV_VAR}" diff --git a/src/cli/serve/__fixtures__/one.yml b/src/cli/serve/__fixtures__/one.yml deleted file mode 100644 index e577d50638d5f9..00000000000000 --- a/src/cli/serve/__fixtures__/one.yml +++ /dev/null @@ -1,2 +0,0 @@ -foo: 1 -bar: true diff --git a/src/cli/serve/__fixtures__/two.yml b/src/cli/serve/__fixtures__/two.yml deleted file mode 100644 index aef807fcaebe97..00000000000000 --- a/src/cli/serve/__fixtures__/two.yml +++ /dev/null @@ -1,2 +0,0 @@ -foo: 2 -baz: bonkers diff --git a/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap b/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap index 0e702ed6123bd1..47b98f740af588 100644 --- a/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap +++ b/src/cli/serve/integration_tests/__snapshots__/invalid_config.test.js.snap @@ -4,12 +4,15 @@ exports[`cli invalid config support exits with statusCode 64 and logs a single l Array [ Object { "@timestamp": "## @timestamp ##", + "error": "## Error with stack trace ##", + "level": "fatal", "message": "\\"unknown.key\\", \\"other.unknown.key\\", \\"other.third\\", \\"some.flat.key\\", and \\"some.array\\" settings were not applied. Check for spelling errors and ensure that expected plugins are installed.", "pid": "## PID ##", "tags": Array [ "fatal", + "root", ], - "type": "log", + "type": "error", }, ] `; diff --git a/src/cli/serve/integration_tests/invalid_config.test.js b/src/cli/serve/integration_tests/invalid_config.test.js index 335fb1dbcaf9f6..495bfbeaa939e3 100644 --- a/src/cli/serve/integration_tests/invalid_config.test.js +++ b/src/cli/serve/integration_tests/invalid_config.test.js @@ -39,7 +39,8 @@ describe('cli invalid config support', function () { .map(obj => ({ ...obj, pid: '## PID ##', - '@timestamp': '## @timestamp ##' + '@timestamp': '## @timestamp ##', + error: '## Error with stack trace ##', })); expect(error).toBe(undefined); diff --git a/src/cli/serve/integration_tests/reload_logging_config.test.js b/src/cli/serve/integration_tests/reload_logging_config.test.js index 100fccceadbac7..61e590f2b51d51 100644 --- a/src/cli/serve/integration_tests/reload_logging_config.test.js +++ b/src/cli/serve/integration_tests/reload_logging_config.test.js @@ -23,7 +23,7 @@ import { relative, resolve } from 'path'; import { safeDump } from 'js-yaml'; import es from 'event-stream'; import stripAnsi from 'strip-ansi'; -import { readYamlConfig } from '../read_yaml_config'; +import { getConfigFromFiles } from '../../../core/server/config'; const testConfigFile = follow('__fixtures__/reload_logging_config/kibana.test.yml'); const kibanaPath = follow('../../../../scripts/kibana.js'); @@ -33,7 +33,7 @@ function follow(file) { } function setLoggingJson(enabled) { - const conf = readYamlConfig(testConfigFile); + const conf = getConfigFromFiles([testConfigFile]); conf.logging = conf.logging || {}; conf.logging.json = enabled; diff --git a/src/cli/serve/read_yaml_config.js b/src/cli/serve/read_yaml_config.js deleted file mode 100644 index f2ad397abf7c39..00000000000000 --- a/src/cli/serve/read_yaml_config.js +++ /dev/null @@ -1,63 +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 { isArray, isPlainObject, forOwn, set, transform, isString } from 'lodash'; -import { readFileSync as read } from 'fs'; -import { safeLoad } from 'js-yaml'; - -function replaceEnvVarRefs(val) { - return val.replace(/\$\{(\w+)\}/g, (match, envVarName) => { - if (process.env[envVarName] !== undefined) { - return process.env[envVarName]; - } else { - throw new Error(`Unknown environment variable referenced in config : ${envVarName}`); - } - }); -} - -export function merge(sources) { - return transform(sources, (merged, source) => { - forOwn(source, function apply(val, key) { - if (isPlainObject(val)) { - forOwn(val, function (subVal, subKey) { - apply(subVal, key + '.' + subKey); - }); - return; - } - - if (isArray(val)) { - set(merged, key, []); - val.forEach((subVal, i) => apply(subVal, key + '.' + i)); - return; - } - - if (isString(val)) { - val = replaceEnvVarRefs(val); - } - - set(merged, key, val); - }); - }, {}); -} - -export function readYamlConfig(paths) { - const files = [].concat(paths || []); - const yamls = files.map(path => safeLoad(read(path, 'utf8'))); - return merge(yamls); -} diff --git a/src/cli/serve/read_yaml_config.test.js b/src/cli/serve/read_yaml_config.test.js deleted file mode 100644 index 09898a25c45b00..00000000000000 --- a/src/cli/serve/read_yaml_config.test.js +++ /dev/null @@ -1,98 +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 { relative, resolve } from 'path'; -import { readYamlConfig } from './read_yaml_config'; - -function fixture(name) { - return resolve(__dirname, '__fixtures__', name); -} - -describe('cli/serve/read_yaml_config', function () { - it('reads a single config file', function () { - const config = readYamlConfig(fixture('one.yml')); - - expect(config).toEqual({ - foo: 1, - bar: true, - }); - }); - - it('reads and merged multiple config file', function () { - const config = readYamlConfig([ - fixture('one.yml'), - fixture('two.yml') - ]); - - expect(config).toEqual({ - foo: 2, - bar: true, - baz: 'bonkers' - }); - }); - - it('should inject an environment variable value when setting a value with ${ENV_VAR}', function () { - process.env.KBN_ENV_VAR1 = 'val1'; - process.env.KBN_ENV_VAR2 = 'val2'; - const config = readYamlConfig([ fixture('en_var_ref_config.yml') ]); - - expect(config).toEqual({ - foo: 1, - bar: 'pre-val1-mid-val2-post', - elasticsearch: { - requestHeadersWhitelist: ['val1', 'val2'] - } - }); - }); - - it('should thow an exception when referenced environment variable in a config value does not exist', function () { - expect(function () { - readYamlConfig([ fixture('invalid_en_var_ref_config.yml') ]); - }).toThrow(); - }); - - describe('different cwd()', function () { - const originalCwd = process.cwd(); - const tempCwd = resolve(__dirname); - - beforeAll(() => process.chdir(tempCwd)); - afterAll(() => process.chdir(originalCwd)); - - it('resolves relative files based on the cwd', function () { - const relativePath = relative(tempCwd, fixture('one.yml')); - const config = readYamlConfig(relativePath); - expect(config).toEqual({ - foo: 1, - bar: true, - }); - }); - - it('fails to load relative paths, not found because of the cwd', function () { - expect(function () { - const relativePath = relative( - resolve(__dirname, '../../'), - fixture('one.yml') - ); - - readYamlConfig(relativePath); - }).toThrowError(/ENOENT/); - }); - }); - -}); diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index ecb0ae3a53dc55..2820ac6a64ea41 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -19,20 +19,15 @@ import _ from 'lodash'; import { statSync, lstatSync, realpathSync } from 'fs'; -import { isWorker } from 'cluster'; import { resolve } from 'path'; import { fromRoot } from '../../utils'; import { getConfig } from '../../server/path'; -import { Config } from '../../server/config/config'; -import { readYamlConfig } from './read_yaml_config'; +import { bootstrap } from '../../core/server'; import { readKeystore } from './read_keystore'; -import { transformDeprecations } from '../../server/config/transform_deprecations'; import { DEV_SSL_CERT_PATH, DEV_SSL_KEY_PATH } from '../dev_ssl'; -const { startRepl } = canRequire('../repl') ? require('../repl') : { }; - function canRequire(path) { try { require.resolve(path); @@ -60,6 +55,9 @@ function isSymlinkTo(link, dest) { const CLUSTER_MANAGER_PATH = resolve(__dirname, '../cluster/cluster_manager'); const CAN_CLUSTER = canRequire(CLUSTER_MANAGER_PATH); +const REPL_PATH = resolve(__dirname, '../repl'); +const CAN_REPL = canRequire(REPL_PATH); + // xpack is installed in both dev and the distributable, it's optional if // install is a link to the source, not an actual install const XPACK_INSTALLED_DIR = resolve(__dirname, '../../../node_modules/x-pack'); @@ -79,12 +77,11 @@ const configPathCollector = pathCollector(); const pluginDirCollector = pathCollector(); const pluginPathCollector = pathCollector(); -function readServerSettings(opts, extraCliOptions) { - const settings = readYamlConfig(opts.config); - const set = _.partial(_.set, settings); - const get = _.partial(_.get, settings); - const has = _.partial(_.has, settings); - const merge = _.partial(_.merge, settings); +function applyConfigOverrides(rawConfig, opts, extraCliOptions) { + const set = _.partial(_.set, rawConfig); + const get = _.partial(_.get, rawConfig); + const has = _.partial(_.has, rawConfig); + const merge = _.partial(_.merge, rawConfig); if (opts.dev) { set('env', 'development'); @@ -133,7 +130,7 @@ function readServerSettings(opts, extraCliOptions) { merge(extraCliOptions); merge(readKeystore(get('path.data'))); - return settings; + return rawConfig; } export default function (program) { @@ -175,7 +172,7 @@ export default function (program) { ) .option('--plugins ', 'an alias for --plugin-dir', pluginDirCollector); - if (!!startRepl) { + if (CAN_REPL) { command.option('--repl', 'Run the server with a REPL prompt and access to the server object'); } @@ -205,81 +202,25 @@ export default function (program) { } } - const getCurrentSettings = () => readServerSettings(opts, this.getUnknownOptions()); - const settings = getCurrentSettings(); - - if (CAN_CLUSTER && opts.dev && !isWorker) { - // stop processing the action and handoff to cluster manager - const ClusterManager = require(CLUSTER_MANAGER_PATH); - await ClusterManager.create(opts, settings); - return; - } - - let kbnServer = {}; - const KbnServer = require('../../server/kbn_server'); - try { - kbnServer = new KbnServer(settings); - if (shouldStartRepl(opts)) { - startRepl(kbnServer); - } - await kbnServer.ready(); - } catch (error) { - const { server } = kbnServer; - - switch (error.code) { - case 'EADDRINUSE': - logFatal(`Port ${error.port} is already in use. Another instance of Kibana may be running!`, server); - break; - - case 'InvalidConfig': - logFatal(error.message, server); - break; - - default: - logFatal(error, server); - break; - } - - kbnServer.close(); - const exitCode = error.processExitCode == null ? 1 : error.processExitCode; - // eslint-disable-next-line no-process-exit - process.exit(exitCode); - } - - process.on('SIGHUP', async function reloadConfig() { - const settings = transformDeprecations(getCurrentSettings()); - const config = new Config(kbnServer.config.getSchema(), settings); - - kbnServer.server.log(['info', 'config'], 'Reloading logging configuration due to SIGHUP.'); - await kbnServer.applyLoggingConfiguration(config); - kbnServer.server.log(['info', 'config'], 'Reloaded logging configuration due to SIGHUP.'); - - // If new platform config subscription is active, let's notify it with the updated config. - if (kbnServer.newPlatform) { - kbnServer.newPlatform.updateConfig(config); - } + const unknownOptions = this.getUnknownOptions(); + await bootstrap({ + configs: [].concat(opts.config || []), + cliArgs: { + dev: !!opts.dev, + envName: unknownOptions.env ? unknownOptions.env.name : undefined, + quiet: !!opts.quiet, + silent: !!opts.silent, + watch: !!opts.watch, + repl: !!opts.repl, + basePath: !!opts.basePath, + }, + features: { + isClusterModeSupported: CAN_CLUSTER, + isOssModeSupported: XPACK_OPTIONAL, + isXPackInstalled: XPACK_INSTALLED, + isReplModeSupported: CAN_REPL, + }, + applyConfigOverrides: rawConfig => applyConfigOverrides(rawConfig, opts, unknownOptions), }); - - return kbnServer; }); } - -function shouldStartRepl(opts) { - if (opts.repl && !startRepl) { - throw new Error('Kibana REPL mode can only be run in development mode.'); - } - - // The kbnWorkerType check is necessary to prevent the repl - // from being started multiple times in different processes. - // We only want one REPL. - return opts.repl && process.env.kbnWorkerType === 'server'; -} - -function logFatal(message, server) { - if (server) { - server.log(['fatal'], message); - } - - // It's possible for the Hapi logger to not be setup - console.error('FATAL', message); -} diff --git a/src/core/README.md b/src/core/README.md index c3b056f9817261..196946ed9e4a31 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -5,26 +5,17 @@ Core is a set of systems (frontend, backend etc.) that Kibana and its plugins ar ## Integration with the "legacy" Kibana Most of the existing core functionality is still spread over "legacy" Kibana and it will take some time to upgrade it. -Kibana is still started using existing "legacy" CLI and bootstraps `core` only when needed. At the moment `core` manages -HTTP connections, handles TLS configuration and base path proxy. All requests to Kibana server will hit HTTP server -exposed by the `core` first and it will decide whether request can be solely handled by the new platform or request should -be proxied to the "legacy" Kibana. This setup allows `core` to gradually introduce any "pre-route" processing -logic, expose new routes or replace old ones handled by the "legacy" Kibana currently. +Kibana is started using existing "legacy" CLI that bootstraps `core` which in turn creates the "legacy" Kibana server. +At the moment `core` manages HTTP connections, handles TLS configuration and base path proxy. All requests to Kibana server +will hit HTTP server exposed by the `core` first and it will decide whether request can be solely handled by the new +platform or request should be proxied to the "legacy" Kibana. This setup allows `core` to gradually introduce any "pre-route" +processing logic, expose new routes or replace old ones handled by the "legacy" Kibana currently. -Once config has been loaded and validated by the "legacy" Kibana it's passed to the `core` where some of its parts will -be additionally validated so that we can make config validation stricter with the new config validation system. Even though -the new validation system provided by the `core` is also based on Joi internally it is complemented with custom rules -tailored to our needs (e.g. `byteSize`, `duration` etc.). That means that config values that are accepted by the "legacy" -Kibana may be rejected by the `core`. - -One can also define new configuration keys under `__newPlatform` if these keys are supposed to be used by the `core` only -and should not be validated by the "legacy" Kibana, e.g. - -```yaml -__newPlatform: - plugins: - scanDirs: ['./example_plugins'] -``` +Once config has been loaded and some of its parts were validated by the `core` it's passed to the "legacy" Kibana where +it will be additionally validated so that we can make config validation stricter with the new config validation system. +Even though the new validation system provided by the `core` is also based on Joi internally it is complemented with custom +rules tailored to our needs (e.g. `byteSize`, `duration` etc.). That means that config values that were previously accepted +by the "legacy" Kibana may be rejected by the `core` now. Even though `core` has its own logging system it doesn't output log records directly (e.g. to file or terminal), but instead forward them to the "legacy" Kibana so that they look the same as the rest of the log records throughout Kibana. diff --git a/src/core/public/base_path/base_path_service.test.ts b/src/core/public/base_path/base_path_service.test.ts new file mode 100644 index 00000000000000..ed44c322f158c5 --- /dev/null +++ b/src/core/public/base_path/base_path_service.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BasePathService } from './base_path_service'; + +function setup(options: any = {}) { + const injectedBasePath: string = + options.injectedBasePath === undefined ? '/foo/bar' : options.injectedBasePath; + + const service = new BasePathService(); + + const injectedMetadata = { + getBasePath: jest.fn().mockReturnValue(injectedBasePath), + } as any; + + const startContract = service.start({ + injectedMetadata, + }); + + return { + service, + startContract, + injectedBasePath, + }; +} + +describe('startContract.get()', () => { + it('returns an empty string if no basePath is injected', () => { + const { startContract } = setup({ injectedBasePath: null }); + expect(startContract.get()).toBe(''); + }); + + it('returns the injected basePath', () => { + const { startContract } = setup(); + expect(startContract.get()).toBe('/foo/bar'); + }); +}); + +describe('startContract.addToPath()', () => { + it('adds the base path to the path if it is relative and starts with a slash', () => { + const { startContract } = setup(); + expect(startContract.addToPath('/a/b')).toBe('/foo/bar/a/b'); + }); + + it('leaves the query string and hash of path unchanged', () => { + const { startContract } = setup(); + expect(startContract.addToPath('/a/b?x=y#c/d/e')).toBe('/foo/bar/a/b?x=y#c/d/e'); + }); + + it('returns the path unchanged if it does not start with a slash', () => { + const { startContract } = setup(); + expect(startContract.addToPath('a/b')).toBe('a/b'); + }); + + it('returns the path unchanged it it has a hostname', () => { + const { startContract } = setup(); + expect(startContract.addToPath('http://localhost:5601/a/b')).toBe('http://localhost:5601/a/b'); + }); +}); + +describe('startContract.removeFromPath()', () => { + it('removes the basePath if relative path starts with it', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar/a/b')).toBe('/a/b'); + }); + + it('leaves query string and hash intact', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar/a/b?c=y#1234')).toBe('/a/b?c=y#1234'); + }); + + it('ignores urls with hostnames', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('http://localhost:5601/foo/bar/a/b')).toBe( + 'http://localhost:5601/foo/bar/a/b' + ); + }); + + it('returns slash if path is just basePath', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/bar')).toBe('/'); + }); + + it('returns full path if basePath is not its own segment', () => { + const { startContract } = setup(); + expect(startContract.removeFromPath('/foo/barhop')).toBe('/foo/barhop'); + }); +}); diff --git a/src/core/public/base_path/base_path_service.ts b/src/core/public/base_path/base_path_service.ts new file mode 100644 index 00000000000000..bd6f665abdf9e6 --- /dev/null +++ b/src/core/public/base_path/base_path_service.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 { InjectedMetadataStartContract } from '../injected_metadata'; +import { modifyUrl } from '../utils'; + +interface Deps { + injectedMetadata: InjectedMetadataStartContract; +} + +export class BasePathService { + public start({ injectedMetadata }: Deps) { + const basePath = injectedMetadata.getBasePath() || ''; + + return { + /** + * Get the current basePath as defined by the server + */ + get() { + return basePath; + }, + + /** + * Add the current basePath to a path string. + * @param path A relative url including the leading `/`, otherwise it will be returned without modification + */ + addToPath(path: string) { + return modifyUrl(path, parts => { + if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { + parts.pathname = `${basePath}${parts.pathname}`; + } + }); + }, + + /** + * Remove the basePath from a path that starts with it + * @param path A relative url that starts with the basePath, which will be stripped + */ + removeFromPath(path: string) { + if (!basePath) { + return path; + } + + if (path === basePath) { + return '/'; + } + + if (path.startsWith(basePath + '/')) { + return path.slice(basePath.length); + } + + return path; + }, + }; + } +} + +export type BasePathStartContract = ReturnType; diff --git a/src/core/index.ts b/src/core/public/base_path/index.ts similarity index 90% rename from src/core/index.ts rename to src/core/public/base_path/index.ts index 326d08e0ec43f0..13ff2350cab846 100644 --- a/src/core/index.ts +++ b/src/core/public/base_path/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { injectIntoKbnServer, createBasePathProxy } from './server/legacy_compat'; +export { BasePathService, BasePathStartContract } from './base_path_service'; diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index d867c8f49b6a09..e5c5319fb85ed4 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -17,11 +17,13 @@ * under the License. */ +import { BasePathService } from './base_path'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformService } from './legacy_platform'; import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; +import { UiSettingsService } from './ui_settings'; const MockLegacyPlatformService = jest.fn( function _MockLegacyPlatformService(this: any) { @@ -71,11 +73,30 @@ const MockLoadingCountService = jest.fn(function _MockNotif this: any ) { this.start = jest.fn().mockReturnValue(mockLoadingCountContract); + this.stop = jest.fn(); }); jest.mock('./loading_count', () => ({ LoadingCountService: MockLoadingCountService, })); +const mockBasePathStartContract = {}; +const MockBasePathService = jest.fn(function _MockNotificationsService(this: any) { + this.start = jest.fn().mockReturnValue(mockBasePathStartContract); +}); +jest.mock('./base_path', () => ({ + BasePathService: MockBasePathService, +})); + +const mockUiSettingsContract = {}; +const MockUiSettingsService = jest.fn(function _MockNotificationsService( + this: any +) { + this.start = jest.fn().mockReturnValue(mockUiSettingsContract); +}); +jest.mock('./ui_settings', () => ({ + UiSettingsService: MockUiSettingsService, +})); + import { CoreSystem } from './core_system'; jest.spyOn(CoreSystem.prototype, 'stop'); @@ -101,6 +122,8 @@ describe('constructor', () => { expect(MockFatalErrorsService).toHaveBeenCalledTimes(1); expect(MockNotificationsService).toHaveBeenCalledTimes(1); expect(MockLoadingCountService).toHaveBeenCalledTimes(1); + expect(MockBasePathService).toHaveBeenCalledTimes(1); + expect(MockUiSettingsService).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -175,17 +198,51 @@ describe('constructor', () => { }); describe('#stop', () => { - it('call legacyPlatform.stop()', () => { + it('calls legacyPlatform.stop()', () => { const coreSystem = new CoreSystem({ ...defaultCoreSystemParams, }); - const legacyPlatformService = MockLegacyPlatformService.mock.instances[0]; - + const [legacyPlatformService] = MockLegacyPlatformService.mock.instances; expect(legacyPlatformService.stop).not.toHaveBeenCalled(); coreSystem.stop(); expect(legacyPlatformService.stop).toHaveBeenCalled(); }); + + it('calls notifications.stop()', () => { + const coreSystem = new CoreSystem({ + ...defaultCoreSystemParams, + }); + + const [notificationsService] = MockNotificationsService.mock.instances; + expect(notificationsService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(notificationsService.stop).toHaveBeenCalled(); + }); + + it('calls loadingCount.stop()', () => { + const coreSystem = new CoreSystem({ + ...defaultCoreSystemParams, + }); + + const [loadingCountService] = MockLoadingCountService.mock.instances; + expect(loadingCountService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(loadingCountService.stop).toHaveBeenCalled(); + }); + + it('clears the rootDomElement', () => { + const rootDomElement = document.createElement('div'); + const coreSystem = new CoreSystem({ + ...defaultCoreSystemParams, + rootDomElement, + }); + + coreSystem.start(); + expect(rootDomElement.innerHTML).not.toBe(''); + coreSystem.stop(); + expect(rootDomElement.innerHTML).toBe(''); + }); }); describe('#start()', () => { @@ -221,6 +278,27 @@ describe('#start()', () => { }); }); + it('calls basePath#start()', () => { + startCore(); + const [mockInstance] = MockBasePathService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith({ + injectedMetadata: mockInjectedMetadataStartContract, + }); + }); + + it('calls uiSettings#start()', () => { + startCore(); + const [mockInstance] = MockUiSettingsService.mock.instances; + expect(mockInstance.start).toHaveBeenCalledTimes(1); + expect(mockInstance.start).toHaveBeenCalledWith({ + notifications: mockNotificationStartContract, + loadingCount: mockLoadingCountContract, + injectedMetadata: mockInjectedMetadataStartContract, + basePath: mockBasePathStartContract, + }); + }); + it('calls fatalErrors#start()', () => { startCore(); const [mockInstance] = MockFatalErrorsService.mock.instances; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index db5500cb2ffdbe..5d39a883e39f83 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -18,11 +18,14 @@ */ import './core.css'; + +import { BasePathService } from './base_path'; import { FatalErrorsService } from './fatal_errors'; import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata'; import { LegacyPlatformParams, LegacyPlatformService } from './legacy_platform'; import { LoadingCountService } from './loading_count'; import { NotificationsService } from './notifications'; +import { UiSettingsService } from './ui_settings'; interface Params { rootDomElement: HTMLElement; @@ -43,6 +46,8 @@ export class CoreSystem { private readonly legacyPlatform: LegacyPlatformService; private readonly notifications: NotificationsService; private readonly loadingCount: LoadingCountService; + private readonly uiSettings: UiSettingsService; + private readonly basePath: BasePathService; private readonly rootDomElement: HTMLElement; private readonly notificationsTargetDomElement: HTMLDivElement; @@ -71,6 +76,8 @@ export class CoreSystem { }); this.loadingCount = new LoadingCountService(); + this.basePath = new BasePathService(); + this.uiSettings = new UiSettingsService(); this.legacyPlatformTargetDomElement = document.createElement('div'); this.legacyPlatform = new LegacyPlatformService({ @@ -92,7 +99,21 @@ export class CoreSystem { const injectedMetadata = this.injectedMetadata.start(); const fatalErrors = this.fatalErrors.start(); const loadingCount = this.loadingCount.start({ fatalErrors }); - this.legacyPlatform.start({ injectedMetadata, fatalErrors, notifications, loadingCount }); + const basePath = this.basePath.start({ injectedMetadata }); + const uiSettings = this.uiSettings.start({ + notifications, + loadingCount, + injectedMetadata, + basePath, + }); + this.legacyPlatform.start({ + injectedMetadata, + fatalErrors, + notifications, + loadingCount, + basePath, + uiSettings, + }); } catch (error) { this.fatalErrors.add(error); } @@ -101,6 +122,7 @@ export class CoreSystem { public stop() { this.legacyPlatform.stop(); this.notifications.stop(); + this.loadingCount.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/functional_test_runner/__tests__/lib/kibana.js b/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/index.ts similarity index 71% rename from src/functional_test_runner/__tests__/lib/kibana.js rename to src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/index.ts index df046e34b26589..0be915a4bde1fb 100644 --- a/src/functional_test_runner/__tests__/lib/kibana.js +++ b/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/index.ts @@ -17,20 +17,34 @@ * under the License. */ -import { createServerWithCorePlugins } from '../../../test_utils/kbn_server'; +import { deepFreeze } from '../../deep_freeze'; -export async function startupKibana({ port, esUrl }) { - const server = createServerWithCorePlugins({ - server: { - port, - autoListen: true, +deepFreeze( + { + foo: { + bar: { + baz: 1, + }, }, + } +).foo.bar.baz = 2; - elasticsearch: { - url: esUrl - } - }); +deepFreeze( + { + foo: [ + { + bar: 1, + }, + ], + } +).foo[0].bar = 2; - await server.ready(); - return server; -} +deepFreeze( + { + foo: [1], + } +).foo[0] = 2; + +deepFreeze({ + foo: [1], +}).foo.push(2); diff --git a/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.tsconfig.json b/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/tsconfig.json similarity index 61% rename from src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.tsconfig.json rename to src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/tsconfig.json index aaedce798435d3..64fcbf1f748815 100644 --- a/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.tsconfig.json +++ b/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation/tsconfig.json @@ -6,8 +6,7 @@ "esnext" ] }, - "include": [ - "frozen_object_mutation.ts", - "../deep_freeze.ts" + "files": [ + "index.ts" ] } diff --git a/src/core/public/injected_metadata/deep_freeze.test.ts b/src/core/public/injected_metadata/deep_freeze.test.ts index 9086d697f9ce38..7657af7f1051de 100644 --- a/src/core/public/injected_metadata/deep_freeze.test.ts +++ b/src/core/public/injected_metadata/deep_freeze.test.ts @@ -75,28 +75,17 @@ it('prevents reassigning items in a frozen array', () => { }); it('types return values to prevent mutations in typescript', async () => { - const result = await execa.stdout( - 'tsc', - [ - '--noEmit', - '--project', - resolve(__dirname, '__fixtures__/frozen_object_mutation.tsconfig.json'), - ], - { - cwd: resolve(__dirname, '__fixtures__'), - reject: false, - } - ); + await expect( + execa.stdout('tsc', ['--noEmit'], { + cwd: resolve(__dirname, '__fixtures__/frozen_object_mutation'), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(` +"Command failed: tsc --noEmit - const errorCodeRe = /\serror\s(TS\d{4}):/g; - const errorCodes = []; - while (true) { - const match = errorCodeRe.exec(result); - if (!match) { - break; - } - errorCodes.push(match[1]); - } - - expect(errorCodes).toEqual(['TS2704', 'TS2540', 'TS2540', 'TS2339']); +index.ts(30,11): error TS2540: Cannot assign to 'baz' because it is a constant or a read-only property. +index.ts(40,10): error TS2540: Cannot assign to 'bar' because it is a constant or a read-only property. +index.ts(42,1): error TS2542: Index signature in type 'RecursiveReadonlyArray' only permits reading. +index.ts(50,8): error TS2339: Property 'push' does not exist on type 'RecursiveReadonlyArray'. +" +`); }); diff --git a/src/core/public/injected_metadata/deep_freeze.ts b/src/core/public/injected_metadata/deep_freeze.ts index 33948fccef9504..ba1308690b6832 100644 --- a/src/core/public/injected_metadata/deep_freeze.ts +++ b/src/core/public/injected_metadata/deep_freeze.ts @@ -19,9 +19,12 @@ type Freezable = { [k: string]: any } | any[]; -type RecursiveReadOnly = T extends Freezable - ? Readonly<{ [K in keyof T]: RecursiveReadOnly }> - : T; +// if we define this inside RecursiveReadonly TypeScript complains +interface RecursiveReadonlyArray extends ReadonlyArray> {} + +type RecursiveReadonly = T extends any[] + ? RecursiveReadonlyArray + : T extends object ? Readonly<{ [K in keyof T]: RecursiveReadonly }> : T; export function deepFreeze(object: T) { // for any properties that reference an object, makes sure that object is @@ -32,5 +35,5 @@ export function deepFreeze(object: T) { } } - return Object.freeze(object) as RecursiveReadOnly; + return Object.freeze(object) as RecursiveReadonly; } diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index e756d99b1f854f..85c3fce0ba9deb 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -23,6 +23,7 @@ export interface InjectedMetadataParams { injectedMetadata: { version: string; buildNumber: number; + basePath: string; legacyMetadata: { [key: string]: any; }; @@ -42,6 +43,14 @@ export class InjectedMetadataService { public start() { return { + getBasePath: () => { + return this.state.basePath; + }, + + getKibanaVersion: () => { + return this.getKibanaVersion(); + }, + getLegacyMetadata: () => { return this.state.legacyMetadata; }, diff --git a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap index e012b43d5977a6..8d318e8e57673c 100644 --- a/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap +++ b/src/core/public/legacy_platform/__snapshots__/legacy_platform_service.test.ts.snap @@ -1,5 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`#start() load order useLegacyTestHarness = false loads ui/modules before ui/chrome, and both before legacy files 1`] = ` +Array [ + "ui/metadata", + "ui/notify/fatal_error", + "ui/notify/toasts", + "ui/chrome/api/loading_count", + "ui/chrome/api/base_path", + "ui/chrome/api/ui_settings", + "ui/chrome", + "legacy files", +] +`; + +exports[`#start() load order useLegacyTestHarness = true loads ui/modules before ui/test_harness, and both before legacy files 1`] = ` +Array [ + "ui/metadata", + "ui/notify/fatal_error", + "ui/notify/toasts", + "ui/chrome/api/loading_count", + "ui/chrome/api/base_path", + "ui/chrome/api/ui_settings", + "ui/test_harness", + "legacy files", +] +`; + exports[`#stop() destroys the angular scope and empties the targetDomElement if angular is bootstraped to targetDomElement 1`] = `
{ }; }); +const mockBasePathInit = jest.fn(); +jest.mock('ui/chrome/api/base_path', () => { + mockLoadOrder.push('ui/chrome/api/base_path'); + return { + __newPlatformInit__: mockBasePathInit, + }; +}); + +const mockUiSettingsInit = jest.fn(); +jest.mock('ui/chrome/api/ui_settings', () => { + mockLoadOrder.push('ui/chrome/api/ui_settings'); + return { + __newPlatformInit__: mockUiSettingsInit, + }; +}); + import { LegacyPlatformService } from './legacy_platform_service'; const fatalErrorsStartContract = {} as any; @@ -77,7 +93,8 @@ const notificationsStartContract = { toasts: {}, } as any; -const injectedMetadataStartContract = { +const injectedMetadataStartContract: any = { + getBasePath: jest.fn(), getLegacyMetadata: jest.fn(), }; @@ -86,6 +103,14 @@ const loadingCountStartContract = { getCount$: jest.fn().mockImplementation(() => new Rx.Observable(observer => observer.next(0))), }; +const basePathStartContract = { + get: jest.fn(), + addToPath: jest.fn(), + removeFromPath: jest.fn(), +}; + +const uiSettingsStartContract: any = {}; + const defaultParams = { targetDomElement: document.createElement('div'), requireLegacyFiles: jest.fn(() => { @@ -98,6 +123,8 @@ const defaultStartDeps = { injectedMetadata: injectedMetadataStartContract, notifications: notificationsStartContract, loadingCount: loadingCountStartContract, + basePath: basePathStartContract, + uiSettings: uiSettingsStartContract, }; afterEach(() => { @@ -156,6 +183,28 @@ describe('#start()', () => { expect(mockLoadingCountInit).toHaveBeenCalledWith(loadingCountStartContract); }); + it('passes basePath service to ui/chrome/api/base_path', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockBasePathInit).toHaveBeenCalledTimes(1); + expect(mockBasePathInit).toHaveBeenCalledWith(basePathStartContract); + }); + + it('passes basePath service to ui/chrome/api/ui_settings', () => { + const legacyPlatform = new LegacyPlatformService({ + ...defaultParams, + }); + + legacyPlatform.start(defaultStartDeps); + + expect(mockUiSettingsInit).toHaveBeenCalledTimes(1); + expect(mockUiSettingsInit).toHaveBeenCalledWith(uiSettingsStartContract); + }); + describe('useLegacyTestHarness = false', () => { it('passes the targetDomElement to ui/chrome', () => { const legacyPlatform = new LegacyPlatformService({ @@ -169,6 +218,7 @@ describe('#start()', () => { expect(mockUiChromeBootstrap).toHaveBeenCalledWith(defaultParams.targetDomElement); }); }); + describe('useLegacyTestHarness = true', () => { it('passes the targetDomElement to ui/test_harness', () => { const legacyPlatform = new LegacyPlatformService({ @@ -196,14 +246,7 @@ describe('#start()', () => { legacyPlatform.start(defaultStartDeps); - expect(mockLoadOrder).toEqual([ - 'ui/metadata', - 'ui/notify/fatal_error', - 'ui/notify/toasts', - 'ui/chrome/api/loading_count', - 'ui/chrome', - 'legacy files', - ]); + expect(mockLoadOrder).toMatchSnapshot(); }); }); @@ -218,14 +261,7 @@ describe('#start()', () => { legacyPlatform.start(defaultStartDeps); - expect(mockLoadOrder).toEqual([ - 'ui/metadata', - 'ui/notify/fatal_error', - 'ui/notify/toasts', - 'ui/chrome/api/loading_count', - 'ui/test_harness', - 'legacy files', - ]); + expect(mockLoadOrder).toMatchSnapshot(); }); }); }); diff --git a/src/core/public/legacy_platform/legacy_platform_service.ts b/src/core/public/legacy_platform/legacy_platform_service.ts index 52d2534c8b8e6e..45c7cf76c3cb68 100644 --- a/src/core/public/legacy_platform/legacy_platform_service.ts +++ b/src/core/public/legacy_platform/legacy_platform_service.ts @@ -18,16 +18,20 @@ */ import angular from 'angular'; +import { BasePathStartContract } from '../base_path'; import { FatalErrorsStartContract } from '../fatal_errors'; import { InjectedMetadataStartContract } from '../injected_metadata'; import { LoadingCountStartContract } from '../loading_count'; import { NotificationsStartContract } from '../notifications'; +import { UiSettingsClient } from '../ui_settings'; interface Deps { injectedMetadata: InjectedMetadataStartContract; fatalErrors: FatalErrorsStartContract; notifications: NotificationsStartContract; loadingCount: LoadingCountStartContract; + basePath: BasePathStartContract; + uiSettings: UiSettingsClient; } export interface LegacyPlatformParams { @@ -46,13 +50,22 @@ export interface LegacyPlatformParams { export class LegacyPlatformService { constructor(private readonly params: LegacyPlatformParams) {} - public start({ injectedMetadata, fatalErrors, notifications, loadingCount }: Deps) { + public start({ + injectedMetadata, + fatalErrors, + notifications, + loadingCount, + basePath, + uiSettings, + }: Deps) { // Inject parts of the new platform into parts of the legacy platform // so that legacy APIs/modules can mimic their new platform counterparts require('ui/metadata').__newPlatformInit__(injectedMetadata.getLegacyMetadata()); require('ui/notify/fatal_error').__newPlatformInit__(fatalErrors); require('ui/notify/toasts').__newPlatformInit__(notifications.toasts); require('ui/chrome/api/loading_count').__newPlatformInit__(loadingCount); + require('ui/chrome/api/base_path').__newPlatformInit__(basePath); + require('ui/chrome/api/ui_settings').__newPlatformInit__(uiSettings); // Load the bootstrap module before loading the legacy platform files so that // the bootstrap module can modify the environment a bit first diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap new file mode 100644 index 00000000000000..1f69bc37b81cd5 --- /dev/null +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_api.test.ts.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#batchSet Buffers are always clear of previously buffered changes: two requests, second only sends bar, not foo 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"bar\\":\\"box\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet Overwrites previously buffered values with new values for the same key: two requests, foo=d in final 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"a\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"d\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: final, includes both requests 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"box\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet buffers changes while first request is in progress, sends buffered changes after first request completes: initial, only one request 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; + +exports[`#batchSet rejects all promises for batched requests that fail: promise rejections 1`] = ` +Array [ + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, + Object { + "error": [Error: Request failed with status code: 400], + "isRejected": true, + }, +] +`; + +exports[`#batchSet rejects on 301 1`] = `"Request failed with status code: 301"`; + +exports[`#batchSet rejects on 404 response 1`] = `"Request failed with status code: 404"`; + +exports[`#batchSet rejects on 500 1`] = `"Request failed with status code: 500"`; + +exports[`#batchSet sends a single change immediately: synchronous fetch 1`] = ` +Object { + "matched": Array [ + Array [ + "/foo/bar/api/kibana/settings", + Object { + "body": "{\\"changes\\":{\\"foo\\":\\"bar\\"}}", + "credentials": "same-origin", + "headers": Object { + "accept": "application/json", + "content-type": "application/json", + "kbn-version": "v9.9.9", + }, + "method": "POST", + }, + ], + ], + "unmatched": Array [], +} +`; diff --git a/src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap similarity index 87% rename from src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap rename to src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap index 8915553b36bf15..e49c546f3550ca 100644 --- a/src/ui/ui_settings/public/__snapshots__/ui_settings_client.test.js.snap +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_client.test.ts.snap @@ -20,7 +20,29 @@ You can use \`config.get(\\"throwableProperty\\", defaultValue)\`, which will ju \`defaultValue\` when the key is unrecognized." `; -exports[`#overrideLocalDefault #assertUpdateAllowed() throws error when keys is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; +exports[`#getUpdate$ sends { key, newValue, oldValue } notifications when config changes 1`] = ` +Array [ + Array [ + Object { + "key": "foo", + "newValue": "bar", + "oldValue": undefined, + }, + ], +] +`; + +exports[`#getUpdate$ sends { key, newValue, oldValue } notifications when config changes 2`] = ` +Array [ + Array [ + Object { + "key": "foo", + "newValue": "baz", + "oldValue": "bar", + }, + ], +] +`; exports[`#overrideLocalDefault key has no user value calls subscriber with new and previous value: single subscriber call 1`] = ` Array [ @@ -100,39 +122,3 @@ Object { exports[`#remove throws an error if key is overridden 1`] = `"Unable to update \\"bar\\" because its value is overridden by the Kibana server"`; exports[`#set throws an error if key is overridden 1`] = `"Unable to update \\"foo\\" because its value is overridden by the Kibana server"`; - -exports[`#subscribe calls handler with { key, newValue, oldValue } when config changes 1`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "bar", - "oldValue": undefined, - }, - ], -] -`; - -exports[`#subscribe calls handler with { key, newValue, oldValue } when config changes 2`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "baz", - "oldValue": "bar", - }, - ], -] -`; - -exports[`#subscribe returns a subscription object which unsubs when .unsubscribe() is called 1`] = ` -Array [ - Array [ - Object { - "key": "foo", - "newValue": "bar", - "oldValue": undefined, - }, - ], -] -`; diff --git a/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap new file mode 100644 index 00000000000000..e7e42c42c8b876 --- /dev/null +++ b/src/core/public/ui_settings/__snapshots__/ui_settings_service.test.ts.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#start constructs UiSettingsClient and UiSettingsApi: UiSettingsApi args 1`] = ` +[MockFunction MockUiSettingsApi] { + "calls": Array [ + Array [ + Object { + "basePathStartContract": true, + }, + "kibanaVersion", + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; + +exports[`#start constructs UiSettingsClient and UiSettingsApi: UiSettingsClient args 1`] = ` +[MockFunction MockUiSettingsClient] { + "calls": Array [ + Array [ + Object { + "api": MockUiSettingsApi { + "getLoadingCount$": [MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "isThrow": false, + "value": Object { + "loadingCountObservable": true, + }, + }, + ], + }, + "stop": [MockFunction], + }, + "defaults": Object { + "legacyInjectedUiSettingDefaults": true, + }, + "initialSettings": Object { + "legacyInjectedUiSettingUserValues": true, + }, + "onUpdateError": [Function], + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; + +exports[`#start passes the uiSettings loading count to the loading count api: loadingCount.add calls 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "loadingCountObservable": true, + }, + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], +} +`; diff --git a/src/core/public/ui_settings/index.ts b/src/core/public/ui_settings/index.ts new file mode 100644 index 00000000000000..36c3d864d81192 --- /dev/null +++ b/src/core/public/ui_settings/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { UiSettingsService, UiSettingsStartContract } from './ui_settings_service'; +export { UiSettingsClient } from './ui_settings_client'; diff --git a/src/core/server/legacy_compat/legacy_kbn_server.ts b/src/core/public/ui_settings/types.ts similarity index 60% rename from src/core/server/legacy_compat/legacy_kbn_server.ts rename to src/core/public/ui_settings/types.ts index 4f4cd53677d3e4..4fa4109c7bc262 100644 --- a/src/core/server/legacy_compat/legacy_kbn_server.ts +++ b/src/core/public/ui_settings/types.ts @@ -17,18 +17,23 @@ * under the License. */ -/** - * Represents a wrapper around legacy `kbnServer` instance that exposes only - * a subset of `kbnServer` APIs used by the new platform. - * @internal - */ -export class LegacyKbnServer { - constructor(private readonly rawKbnServer: any) {} +// properties that come from legacyInjectedMetadata.uiSettings.defaults +interface InjectedUiSettingsDefault { + name?: string; + value?: any; + description?: string; + category?: string[]; + type?: string; + readOnly?: boolean; + options?: string[] | { [key: string]: any }; +} + +// properties that come from legacyInjectedMetadata.uiSettings.user +interface InjectedUiSettingsUser { + userValue?: any; + isOverridden?: boolean; +} - /** - * Custom HTTP Listener used by HapiJS server in the legacy platform. - */ - get newPlatformProxyListener() { - return this.rawKbnServer.newPlatform.proxyListener; - } +export interface UiSettingsState { + [key: string]: InjectedUiSettingsDefault & InjectedUiSettingsUser; } diff --git a/src/core/public/ui_settings/ui_settings_api.test.ts b/src/core/public/ui_settings/ui_settings_api.test.ts new file mode 100644 index 00000000000000..75358297a56613 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_api.test.ts @@ -0,0 +1,242 @@ +/* + * 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 fetchMock from 'fetch-mock'; +import * as Rx from 'rxjs'; +import { takeUntil, toArray } from 'rxjs/operators'; + +import { UiSettingsApi } from './ui_settings_api'; + +function setup() { + const basePath: any = { + addToPath: jest.fn(path => `/foo/bar${path}`), + }; + + const uiSettingsApi = new UiSettingsApi(basePath, 'v9.9.9'); + + return { + basePath, + uiSettingsApi, + }; +} + +async function settlePromise(promise: Promise) { + try { + return { + isResolved: true, + result: await promise, + }; + } catch (error) { + return { + isRejected: true, + error, + }; + } +} + +afterEach(() => { + fetchMock.restore(); +}); + +describe('#batchSet', () => { + it('sends a single change immediately', () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + uiSettingsApi.batchSet('foo', 'bar'); + expect(fetchMock.calls()).toMatchSnapshot('synchronous fetch'); + }); + + it('buffers changes while first request is in progress, sends buffered changes after first request completes', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + + uiSettingsApi.batchSet('foo', 'bar'); + const finalPromise = uiSettingsApi.batchSet('box', 'bar'); + + expect(fetchMock.calls()).toMatchSnapshot('initial, only one request'); + await finalPromise; + expect(fetchMock.calls()).toMatchSnapshot('final, includes both requests'); + }); + + it('Overwrites previously buffered values with new values for the same key', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + + uiSettingsApi.batchSet('foo', 'a'); + uiSettingsApi.batchSet('foo', 'b'); + uiSettingsApi.batchSet('foo', 'c'); + await uiSettingsApi.batchSet('foo', 'd'); + + expect(fetchMock.calls()).toMatchSnapshot('two requests, foo=d in final'); + }); + + it('Buffers are always clear of previously buffered changes', async () => { + fetchMock.mock('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + uiSettingsApi.batchSet('foo', 'bar'); + uiSettingsApi.batchSet('bar', 'foo'); + await uiSettingsApi.batchSet('bar', 'box'); + + expect(fetchMock.calls()).toMatchSnapshot('two requests, second only sends bar, not foo'); + }); + + it('rejects on 404 response', async () => { + fetchMock.mock('*', { + status: 404, + body: 'not found', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects on 301', async () => { + fetchMock.mock('*', { + status: 301, + body: 'redirect', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects on 500', async () => { + fetchMock.mock('*', { + status: 500, + body: 'redirect', + }); + + const { uiSettingsApi } = setup(); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowErrorMatchingSnapshot(); + }); + + it('rejects all promises for batched requests that fail', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + fetchMock.once('*', { + status: 400, + body: 'invalid', + }); + + const { uiSettingsApi } = setup(); + // trigger the initial sync request, which enabled buffering + uiSettingsApi.batchSet('foo', 'bar'); + + // buffer some requests so they will be sent together + await expect( + Promise.all([ + settlePromise(uiSettingsApi.batchSet('foo', 'a')), + settlePromise(uiSettingsApi.batchSet('bar', 'b')), + settlePromise(uiSettingsApi.batchSet('baz', 'c')), + ]) + ).resolves.toMatchSnapshot('promise rejections'); + + // ensure only two requests were sent + expect(fetchMock.calls().matched).toHaveLength(2); + }); +}); + +describe('#getLoadingCount$()', () => { + it('emits the current number of active requests', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + const done$ = new Rx.Subject(); + const promise = uiSettingsApi + .getLoadingCount$() + .pipe( + takeUntil(done$), + toArray() + ) + .toPromise(); + + await uiSettingsApi.batchSet('foo', 'bar'); + done$.next(); + + await expect(promise).resolves.toEqual([0, 1, 0]); + }); + + it('decrements loading count when requests fail', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + fetchMock.once('*', { + status: 400, + body: 'invalid', + }); + + const { uiSettingsApi } = setup(); + const done$ = new Rx.Subject(); + const promise = uiSettingsApi + .getLoadingCount$() + .pipe( + takeUntil(done$), + toArray() + ) + .toPromise(); + + await uiSettingsApi.batchSet('foo', 'bar'); + await expect(uiSettingsApi.batchSet('foo', 'bar')).rejects.toThrowError(); + + done$.next(); + await expect(promise).resolves.toEqual([0, 1, 0, 1, 0]); + }); +}); + +describe('#stop', () => { + it('completes any loading count observables', async () => { + fetchMock.once('*', { + body: { settings: {} }, + }); + + const { uiSettingsApi } = setup(); + const promise = Promise.all([ + uiSettingsApi + .getLoadingCount$() + .pipe(toArray()) + .toPromise(), + uiSettingsApi + .getLoadingCount$() + .pipe(toArray()) + .toPromise(), + ]); + + const batchSetPromise = uiSettingsApi.batchSet('foo', 'bar'); + uiSettingsApi.stop(); + + // both observables should emit the same values, and complete before the request is done loading + await expect(promise).resolves.toEqual([[0, 1], [0, 1]]); + await batchSetPromise; + }); +}); diff --git a/src/core/public/ui_settings/ui_settings_api.ts b/src/core/public/ui_settings/ui_settings_api.ts new file mode 100644 index 00000000000000..6d43384fa6d025 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_api.ts @@ -0,0 +1,163 @@ +/* + * 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 { BasePathStartContract } from '../base_path'; +import { UiSettingsState } from './types'; + +export interface UiSettingsApiResponse { + settings: UiSettingsState; +} + +interface Changes { + values: { + [key: string]: any; + }; + + callback(error?: Error, response?: UiSettingsApiResponse): void; +} + +const NOOP_CHANGES = { + values: {}, + callback: () => { + // noop + }, +}; + +export class UiSettingsApi { + private pendingChanges?: Changes; + private sendInProgress = false; + + private readonly loadingCount$ = new BehaviorSubject(0); + + constructor( + private readonly basePath: BasePathStartContract, + private readonly kibanaVersion: string + ) {} + + /** + * Adds a key+value that will be sent to the server ASAP. If a request is + * already in progress it will wait until the previous request is complete + * before sending the next request + */ + public batchSet(key: string, value: any) { + return new Promise((resolve, reject) => { + const prev = this.pendingChanges || NOOP_CHANGES; + + this.pendingChanges = { + values: { + ...prev.values, + [key]: value, + }, + + callback(error, resp) { + prev.callback(error, resp); + + if (error) { + reject(error); + } else { + resolve(resp); + } + }, + }; + + this.flushPendingChanges(); + }); + } + + /** + * Gets an observable that notifies subscribers of the current number of active requests + */ + public getLoadingCount$() { + return this.loadingCount$.asObservable(); + } + + /** + * Prepares the uiSettings API to be discarded + */ + public stop() { + this.loadingCount$.complete(); + } + + /** + * If there are changes that need to be sent to the server and there is not already a + * request in progress, this method will start a request sending those changes. Once + * the request is complete `flushPendingChanges()` will be called again, and if the + * prerequisites are still true (because changes were queued while the request was in + * progress) then another request will be started until all pending changes have been + * sent to the server. + */ + private async flushPendingChanges() { + if (!this.pendingChanges) { + return; + } + + if (this.sendInProgress) { + return; + } + + const changes = this.pendingChanges; + this.pendingChanges = undefined; + + try { + this.sendInProgress = true; + changes.callback( + undefined, + await this.sendRequest('POST', '/api/kibana/settings', { + changes: changes.values, + }) + ); + } catch (error) { + changes.callback(error); + } finally { + this.sendInProgress = false; + this.flushPendingChanges(); + } + } + + /** + * Calls window.fetch() with the proper headers and error handling logic. + * + * TODO: migrate this to kfetch or whatever the new platform equivalent is once it exists + */ + private async sendRequest(method: string, path: string, body: any) { + try { + this.loadingCount$.next(this.loadingCount$.getValue() + 1); + const response = await fetch(this.basePath.addToPath(path), { + method, + body: JSON.stringify(body), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'kbn-version': this.kibanaVersion, + }, + credentials: 'same-origin', + }); + + if (response.status >= 300) { + throw new Error(`Request failed with status code: ${response.status}`); + } + + return await response.json(); + } finally { + this.loadingCount$.next(this.loadingCount$.getValue() - 1); + } + } +} diff --git a/src/ui/ui_settings/public/ui_settings_client.test.js b/src/core/public/ui_settings/ui_settings_client.test.ts similarity index 82% rename from src/ui/ui_settings/public/ui_settings_client.test.js rename to src/core/public/ui_settings/ui_settings_client.test.ts index f41c9bc0018ca7..53cf4b7347e1be 100644 --- a/src/ui/ui_settings/public/ui_settings_client.test.js +++ b/src/core/public/ui_settings/ui_settings_client.test.ts @@ -18,41 +18,26 @@ */ import { UiSettingsClient } from './ui_settings_client'; -import { sendRequest } from './send_request'; -jest.useFakeTimers(); -jest.mock('./send_request', () => ({ - sendRequest: jest.fn(() => ({})) -})); - -beforeEach(() => { - sendRequest.mockRestore(); - jest.clearAllMocks(); -}); - -function setup(options = {}) { - const { - defaults = { dateFormat: { value: 'Browser' } }, - initialSettings = {} - } = options; +function setup(options: { defaults?: any; initialSettings?: any } = {}) { + const { defaults = { dateFormat: { value: 'Browser' } }, initialSettings = {} } = options; const batchSet = jest.fn(() => ({ - settings: {} + settings: {}, })); + const onUpdateError = jest.fn(); + const config = new UiSettingsClient({ defaults, initialSettings, api: { - batchSet - }, - notify: { - log: jest.fn(), - error: jest.fn(), - } + batchSet, + } as any, + onUpdateError, }); - return { config, batchSet }; + return { config, batchSet, onUpdateError }; } describe('#get', () => { @@ -88,7 +73,7 @@ describe('#get', () => { expect(config.get('dataFormat', defaultDateFormat)).toBe(defaultDateFormat); }); - it('throws on unknown properties that don\'t have a value yet.', () => { + it("throws on unknown properties that don't have a value yet.", () => { const { config } = setup(); expect(() => config.get('throwableProperty')).toThrowErrorMatchingSnapshot(); }); @@ -129,9 +114,9 @@ describe('#set', () => { initialSettings: { foo: { isOverridden: true, - value: 'bar' - } - } + value: 'bar', + }, + }, }); await expect(config.set('foo', true)).rejects.toThrowErrorMatchingSnapshot(); }); @@ -158,9 +143,9 @@ describe('#remove', () => { initialSettings: { bar: { isOverridden: true, - userValue: true - } - } + userValue: true, + }, + }, }); await expect(config.remove('bar')).rejects.toThrowErrorMatchingSnapshot(); }); @@ -209,12 +194,12 @@ describe('#isCustom', () => { }); }); -describe('#subscribe', () => { - it('calls handler with { key, newValue, oldValue } when config changes', () => { +describe('#getUpdate$', () => { + it('sends { key, newValue, oldValue } notifications when config changes', () => { const handler = jest.fn(); const { config } = setup(); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); expect(handler).not.toHaveBeenCalled(); config.set('foo', 'bar'); @@ -227,21 +212,17 @@ describe('#subscribe', () => { expect(handler.mock.calls).toMatchSnapshot(); }); - it('returns a subscription object which unsubs when .unsubscribe() is called', () => { - const handler = jest.fn(); + it('observables complete when client is stopped', () => { + const onComplete = jest.fn(); const { config } = setup(); - const subscription = config.subscribe(handler); - expect(handler).not.toHaveBeenCalled(); - - config.set('foo', 'bar'); - expect(handler).toHaveBeenCalledTimes(1); - expect(handler.mock.calls).toMatchSnapshot(); - handler.mockClear(); + config.getUpdate$().subscribe({ + complete: onComplete, + }); - subscription.unsubscribe(); - config.set('foo', 'baz'); - expect(handler).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + config.stop(); + expect(onComplete).toHaveBeenCalled(); }); }); @@ -267,7 +248,7 @@ describe('#overrideLocalDefault', () => { const handler = jest.fn(); const { config } = setup(); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); config.overrideLocalDefault('dateFormat', 'bar'); expect(handler.mock.calls).toMatchSnapshot('single subscriber call'); }); @@ -297,7 +278,7 @@ describe('#overrideLocalDefault', () => { const { config } = setup(); config.set('dateFormat', 'foo'); - config.subscribe(handler); + config.getUpdate$().subscribe(handler); config.overrideLocalDefault('dateFormat', 'bar'); expect(handler).not.toHaveBeenCalled(); }); @@ -323,55 +304,40 @@ describe('#overrideLocalDefault', () => { const { config } = setup(); expect(config.isOverridden('foo')).toBe(false); }); + it('returns false if key is no overridden', () => { const { config } = setup({ initialSettings: { foo: { - userValue: 1 + userValue: 1, }, bar: { isOverridden: true, - userValue: 2 - } - } + userValue: 2, + }, + }, }); expect(config.isOverridden('foo')).toBe(false); }); + it('returns true when key is overridden', () => { const { config } = setup({ initialSettings: { foo: { - userValue: 1 + userValue: 1, }, bar: { isOverridden: true, - userValue: 2 + userValue: 2, }, - } + }, }); expect(config.isOverridden('bar')).toBe(true); }); + it('returns false for object prototype properties', () => { const { config } = setup(); expect(config.isOverridden('hasOwnProperty')).toBe(false); }); }); - - describe('#assertUpdateAllowed()', () => { - it('returns false if no settings defined', () => { - const { config } = setup(); - expect(config.assertUpdateAllowed('foo')).toBe(undefined); - }); - it('throws error when keys is overridden', () => { - const { config } = setup({ - initialSettings: { - foo: { - isOverridden: true, - userValue: 'bar' - } - } - }); - expect(() => config.assertUpdateAllowed('foo')).toThrowErrorMatchingSnapshot(); - }); - }); }); diff --git a/src/core/public/ui_settings/ui_settings_client.ts b/src/core/public/ui_settings/ui_settings_client.ts new file mode 100644 index 00000000000000..3eb818ee453aaf --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_client.ts @@ -0,0 +1,251 @@ +/* + * 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 { cloneDeep, defaultsDeep } from 'lodash'; +import { Subject } from 'rxjs'; + +import { UiSettingsState } from './types'; +import { UiSettingsApi } from './ui_settings_api'; + +interface Params { + api: UiSettingsApi; + onUpdateError: UiSettingsClient['onUpdateError']; + defaults: UiSettingsState; + initialSettings: UiSettingsState; +} + +export class UiSettingsClient { + private readonly update$ = new Subject<{ key: string; newValue: any; oldValue: any }>(); + + private readonly api: UiSettingsApi; + private readonly onUpdateError: (error: Error) => void; + private readonly defaults: UiSettingsState; + private cache: UiSettingsState; + + constructor(readonly params: Params) { + this.api = params.api; + this.onUpdateError = params.onUpdateError; + this.defaults = cloneDeep(params.defaults); + this.cache = defaultsDeep({}, this.defaults, cloneDeep(params.initialSettings)); + } + + /** + * Gets the metadata about all uiSettings, including the type, default value, and user value + * for each key. + */ + public getAll() { + return cloneDeep(this.cache); + } + + /** + * Gets the value for a specific uiSetting. If this setting has no user-defined value + * then the `defaultOverride` parameter is returned (and parsed if setting is of type + * "json" or "number). If the parameter is not defined and the key is not defined by a + * uiSettingDefaults then an error is thrown, otherwise the default is read + * from the uiSettingDefaults. + */ + public get(key: string, defaultOverride?: any) { + const declared = this.isDeclared(key); + + if (!declared && defaultOverride !== undefined) { + return defaultOverride; + } + + if (!declared) { + throw new Error( + `Unexpected \`config.get("${key}")\` call on unrecognized configuration setting "${key}". +Setting an initial value via \`config.set("${key}", value)\` before attempting to retrieve +any custom setting value for "${key}" may fix this issue. +You can use \`config.get("${key}", defaultValue)\`, which will just return +\`defaultValue\` when the key is unrecognized.` + ); + } + + const type = this.cache[key].type; + const userValue = this.cache[key].userValue; + const defaultValue = defaultOverride !== undefined ? defaultOverride : this.cache[key].value; + const value = userValue == null ? defaultValue : userValue; + + if (type === 'json') { + return JSON.parse(value); + } + + if (type === 'number') { + return parseFloat(value); + } + + return value; + } + + /** + * Sets the value for a uiSetting. If the setting is not defined in the uiSettingDefaults + * it will be stored as a custom setting. The new value will be synchronously available via + * the `get()` method and sent to the server in the background. If the request to the + * server fails then a toast notification will be displayed and the setting will be + * reverted it its value before `set()` was called. + */ + public async set(key: string, val: any) { + return await this.update(key, val); + } + + /** + * Removes the user-defined value for a setting, causing it to revert to the default. This + * method behaves the same as calling `set(key, null)`, including the synchronization, custom + * setting, and error behavior of that method. + */ + public async remove(key: string) { + return await this.update(key, null); + } + + /** + * Returns true if the key is a "known" uiSetting, meaning it is either defined in the + * uiSettingDefaults or was previously added as a custom setting via the `set()` method. + */ + public isDeclared(key: string) { + return key in this.cache; + } + + /** + * Returns true if the setting has no user-defined value or is unknown + */ + public isDefault(key: string) { + return !this.isDeclared(key) || this.cache[key].userValue == null; + } + + /** + * Returns true if the setting is not a part of the uiSettingDefaults, but was either + * added directly via `set()`, or is an unknown setting found in the uiSettings saved + * object + */ + public isCustom(key: string) { + return this.isDeclared(key) && !('value' in this.cache[key]); + } + + /** + * Returns true if a settings value is overridden by the server. When a setting is overridden + * its value can not be changed via `set()` or `remove()`. + */ + public isOverridden(key: string) { + return this.isDeclared(key) && Boolean(this.cache[key].isOverridden); + } + + /** + * Overrides the default value for a setting in this specific browser tab. If the page + * is reloaded the default override is lost. + */ + public overrideLocalDefault(key: string, newDefault: any) { + // capture the previous value + const prevDefault = this.defaults[key] ? this.defaults[key].value : undefined; + + // update defaults map + this.defaults[key] = { + ...(this.defaults[key] || {}), + value: newDefault, + }; + + // update cached default value + this.cache[key] = { + ...(this.cache[key] || {}), + value: newDefault, + }; + + // don't broadcast change if userValue was already overriding the default + if (this.cache[key].userValue == null) { + this.update$.next({ + key, + newValue: newDefault, + oldValue: prevDefault, + }); + } + } + + /** + * Returns an Observable that notifies subscribers of each update to the uiSettings, + * including the key, newValue, and oldValue of the setting that changed. + */ + public getUpdate$() { + return this.update$.asObservable(); + } + + /** + * Prepares the uiSettingsClient to be discarded, completing any update$ observables + * that have been created. + */ + public stop() { + this.update$.complete(); + } + + private assertUpdateAllowed(key: string) { + if (this.isOverridden(key)) { + throw new Error( + `Unable to update "${key}" because its value is overridden by the Kibana server` + ); + } + } + + private async update(key: string, newVal: any) { + this.assertUpdateAllowed(key); + + const declared = this.isDeclared(key); + const defaults = this.defaults; + + const oldVal = declared ? this.cache[key].userValue : undefined; + + const unchanged = oldVal === newVal; + if (unchanged) { + return true; + } + + const initialVal = declared ? this.get(key) : undefined; + this.setLocally(key, newVal); + + try { + const { settings } = await this.api.batchSet(key, newVal); + this.cache = defaultsDeep({}, defaults, settings); + return true; + } catch (error) { + this.setLocally(key, initialVal); + this.onUpdateError(error); + return false; + } + } + + private setLocally(key: string, newValue: any) { + this.assertUpdateAllowed(key); + + if (!this.isDeclared(key)) { + this.cache[key] = {}; + } + + const oldValue = this.get(key); + + if (newValue === null) { + delete this.cache[key].userValue; + } else { + const { type } = this.cache[key]; + if (type === 'json' && typeof newValue !== 'string') { + this.cache[key].userValue = JSON.stringify(newValue); + } else { + this.cache[key].userValue = newValue; + } + } + + this.update$.next({ key, newValue, oldValue }); + } +} diff --git a/src/core/public/ui_settings/ui_settings_service.test.ts b/src/core/public/ui_settings/ui_settings_service.test.ts new file mode 100644 index 00000000000000..2b31cedd070948 --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_service.test.ts @@ -0,0 +1,127 @@ +/* + * 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. + */ + +function mockClass( + module: string, + Class: { new (...args: any[]): T }, + setup: (instance: any, args: any[]) => void +) { + const MockClass = jest.fn(function(this: any, ...args: any[]) { + setup(this, args); + }); + + // define the mock name which is used in some snapshots + MockClass.mockName(`Mock${Class.name}`); + + // define the class name for the MockClass which is used in other snapshots + Object.defineProperty(MockClass, 'name', { + value: `Mock${Class.name}`, + }); + + jest.mock(module, () => ({ + [Class.name]: MockClass, + })); + + return MockClass; +} + +// Mock the UiSettingsApi class +import { UiSettingsApi } from './ui_settings_api'; +const MockUiSettingsApi = mockClass('./ui_settings_api', UiSettingsApi, inst => { + inst.stop = jest.fn(); + inst.getLoadingCount$ = jest.fn().mockReturnValue({ + loadingCountObservable: true, + }); +}); + +// Mock the UiSettingsClient class +import { UiSettingsClient } from './ui_settings_client'; +const MockUiSettingsClient = mockClass('./ui_settings_client', UiSettingsClient, inst => { + inst.stop = jest.fn(); +}); + +// Load the service +import { UiSettingsService } from './ui_settings_service'; + +const loadingCountStartContract = { + loadingCountStartContract: true, + add: jest.fn(), +}; + +const defaultDeps: any = { + notifications: { + notificationsStartContract: true, + }, + loadingCount: loadingCountStartContract, + injectedMetadata: { + injectedMetadataStartContract: true, + getKibanaVersion: jest.fn().mockReturnValue('kibanaVersion'), + getLegacyMetadata: jest.fn().mockReturnValue({ + uiSettings: { + defaults: { legacyInjectedUiSettingDefaults: true }, + user: { legacyInjectedUiSettingUserValues: true }, + }, + }), + }, + basePath: { + basePathStartContract: true, + }, +}; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('#start', () => { + it('returns an instance of UiSettingsClient', () => { + const start = new UiSettingsService().start(defaultDeps); + expect(start).toBeInstanceOf(MockUiSettingsClient); + }); + + it('constructs UiSettingsClient and UiSettingsApi', () => { + new UiSettingsService().start(defaultDeps); + + expect(MockUiSettingsApi).toMatchSnapshot('UiSettingsApi args'); + expect(MockUiSettingsClient).toMatchSnapshot('UiSettingsClient args'); + }); + + it('passes the uiSettings loading count to the loading count api', () => { + new UiSettingsService().start(defaultDeps); + + expect(loadingCountStartContract.add).toMatchSnapshot('loadingCount.add calls'); + }); +}); + +describe('#stop', () => { + it('runs fine if service never started', () => { + const service = new UiSettingsService(); + expect(() => service.stop()).not.toThrowError(); + }); + + it('stops the uiSettingsClient and uiSettingsApi', () => { + const service = new UiSettingsService(); + const client = service.start(defaultDeps); + const [[{ api }]] = MockUiSettingsClient.mock.calls; + jest.spyOn(client, 'stop'); + jest.spyOn(api, 'stop'); + service.stop(); + expect(api.stop).toHaveBeenCalledTimes(1); + expect(client.stop).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/public/ui_settings/ui_settings_service.ts b/src/core/public/ui_settings/ui_settings_service.ts new file mode 100644 index 00000000000000..e11f903507dc4c --- /dev/null +++ b/src/core/public/ui_settings/ui_settings_service.ts @@ -0,0 +1,72 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BasePathStartContract } from '../base_path'; +import { InjectedMetadataStartContract } from '../injected_metadata'; +import { LoadingCountStartContract } from '../loading_count'; +import { NotificationsStartContract } from '../notifications'; + +import { UiSettingsApi } from './ui_settings_api'; +import { UiSettingsClient } from './ui_settings_client'; + +interface Deps { + notifications: NotificationsStartContract; + loadingCount: LoadingCountStartContract; + injectedMetadata: InjectedMetadataStartContract; + basePath: BasePathStartContract; +} + +export class UiSettingsService { + private uiSettingsApi?: UiSettingsApi; + private uiSettingsClient?: UiSettingsClient; + + public start({ notifications, loadingCount, injectedMetadata, basePath }: Deps) { + this.uiSettingsApi = new UiSettingsApi(basePath, injectedMetadata.getKibanaVersion()); + loadingCount.add(this.uiSettingsApi.getLoadingCount$()); + + // TODO: when we have time to refactor the UiSettingsClient and all consumers + // we should stop using the legacy format and pick a better one + const legacyMetadata = injectedMetadata.getLegacyMetadata(); + this.uiSettingsClient = new UiSettingsClient({ + api: this.uiSettingsApi, + onUpdateError: error => { + notifications.toasts.addDanger({ + title: 'Unable to update UI setting', + text: error.message, + }); + }, + defaults: legacyMetadata.uiSettings.defaults, + initialSettings: legacyMetadata.uiSettings.user, + }); + + return this.uiSettingsClient; + } + + public stop() { + if (this.uiSettingsClient) { + this.uiSettingsClient.stop(); + } + + if (this.uiSettingsApi) { + this.uiSettingsApi.stop(); + } + } +} + +export type UiSettingsStartContract = UiSettingsClient; diff --git a/src/test_utils/base_auth.js b/src/core/public/utils/index.ts similarity index 85% rename from src/test_utils/base_auth.js rename to src/core/public/utils/index.ts index 270ed7563e7c10..17de85bbfecce1 100644 --- a/src/test_utils/base_auth.js +++ b/src/core/public/utils/index.ts @@ -17,7 +17,4 @@ * under the License. */ -export function header(user, pass) { - const encoded = new Buffer(`${user}:${pass}`).toString('base64'); - return `Basic ${encoded}`; -} +export { modifyUrl } from './modify_url'; diff --git a/src/core/public/utils/modify_url.test.ts b/src/core/public/utils/modify_url.test.ts new file mode 100644 index 00000000000000..3b8091b7c3074c --- /dev/null +++ b/src/core/public/utils/modify_url.test.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { modifyUrl } from './modify_url'; + +it('supports returning a new url spec', () => { + expect(modifyUrl('http://localhost', () => ({}))).toBe(''); +}); + +it('supports modifying the passed object', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.port = '9999'; + parsed.auth = 'foo:bar'; + }) + ).toBe('http://foo:bar@localhost:9999/'); +}); + +it('supports changing pathname', () => { + expect( + modifyUrl('http://localhost/some/path', parsed => { + parsed.pathname += '/subpath'; + }) + ).toBe('http://localhost/some/path/subpath'); +}); + +it('supports changing port', () => { + expect( + modifyUrl('http://localhost:5601', parsed => { + parsed.port = String(Number(parsed.port) + 1); + }) + ).toBe('http://localhost:5602/'); +}); + +it('supports changing protocol', () => { + expect( + modifyUrl('http://localhost', parsed => { + parsed.protocol = 'mail'; + parsed.slashes = false; + parsed.pathname = undefined; + }) + ).toBe('mail:localhost'); +}); diff --git a/src/utils/modify_url.js b/src/core/public/utils/modify_url.ts similarity index 84% rename from src/utils/modify_url.js rename to src/core/public/utils/modify_url.ts index f988d5218ebf37..a441c7eed6a38e 100644 --- a/src/utils/modify_url.js +++ b/src/core/public/utils/modify_url.ts @@ -17,7 +17,18 @@ * under the License. */ -import { parse as parseUrl, format as formatUrl } from 'url'; +import { format as formatUrl, parse as parseUrl } from 'url'; + +interface UrlParts { + protocol?: string; + slashes?: boolean; + auth?: string; + hostname?: string; + port?: string; + pathname?: string; + query: { [key: string]: string | string[] | undefined }; + hash?: string; +} /** * Takes a URL and a function that takes the meaningful parts @@ -42,17 +53,12 @@ import { parse as parseUrl, format as formatUrl } from 'url'; * lead to the modifications being ignored (depending on which * property was modified) * - It's not always clear wither to use path/pathname, host/hostname, - * so this trys to add helpful constraints + * so this tries to add helpful constraints * - * @param {String} url - the url to parse - * @param {Function} block - a function that will modify the parsed url, or return a new one - * @return {String} the modified and reformatted url + * @param url the url to parse + * @param block a function that will modify the parsed url, or return a new one */ -export function modifyUrl(url, block) { - if (typeof block !== 'function') { - throw new TypeError('You must pass a block to define the modifications desired'); - } - +export function modifyUrl(url: string, block: (parts: UrlParts) => Partial | void) { const parsed = parseUrl(url, true); // copy over the most specific version of each diff --git a/src/core/server/__snapshots__/index.test.ts.snap b/src/core/server/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000000000..8c3022a07d074b --- /dev/null +++ b/src/core/server/__snapshots__/index.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`does not fail on "start" if there are unused paths detected: unused paths logs 1`] = ` +Object { + "debug": Array [ + Array [ + "starting server", + ], + ], + "error": Array [], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [ + Array [ + "some config paths are not handled by the core: [\\"some.path\\",\\"another.path\\"]", + ], + ], + "warn": Array [], +} +`; diff --git a/src/core/server/bootstrap.ts b/src/core/server/bootstrap.ts new file mode 100644 index 00000000000000..69b1d751010c91 --- /dev/null +++ b/src/core/server/bootstrap.ts @@ -0,0 +1,113 @@ +/* + * 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 chalk from 'chalk'; +import { isMaster } from 'cluster'; +import { CliArgs, Env, RawConfigService } from './config'; +import { LegacyObjectToConfigAdapter } from './legacy_compat'; +import { Root } from './root'; + +interface KibanaFeatures { + // Indicates whether we can run Kibana in a so called cluster mode in which + // Kibana is run as a "worker" process together with optimizer "worker" process + // that are orchestrated by the "master" process (dev mode only feature). + isClusterModeSupported: boolean; + + // Indicates whether we can run Kibana without X-Pack plugin pack even if it's + // installed (dev mode only feature). + isOssModeSupported: boolean; + + // Indicates whether we can run Kibana in REPL mode (dev mode only feature). + isReplModeSupported: boolean; + + // Indicates whether X-Pack plugin pack is installed and available. + isXPackInstalled: boolean; +} + +interface BootstrapArgs { + configs: string[]; + cliArgs: CliArgs; + applyConfigOverrides: (config: Record) => Record; + features: KibanaFeatures; +} + +export async function bootstrap({ + configs, + cliArgs, + applyConfigOverrides, + features, +}: BootstrapArgs) { + if (cliArgs.repl && !features.isReplModeSupported) { + onRootShutdown('Kibana REPL mode can only be run in development mode.'); + } + + const env = Env.createDefault({ + configs, + cliArgs, + isDevClusterMaster: isMaster && cliArgs.dev && features.isClusterModeSupported, + }); + + const rawConfigService = new RawConfigService( + env.configs, + rawConfig => new LegacyObjectToConfigAdapter(applyConfigOverrides(rawConfig)) + ); + + rawConfigService.loadConfig(); + + const root = new Root(rawConfigService.getConfig$(), env, onRootShutdown); + + function shutdown(reason?: Error) { + rawConfigService.stop(); + return root.shutdown(reason); + } + + try { + await root.start(); + } catch (err) { + await shutdown(err); + } + + process.on('SIGHUP', () => { + const cliLogger = root.logger.get('cli'); + cliLogger.info('Reloading logging configuration due to SIGHUP.', { tags: ['config'] }); + + try { + rawConfigService.reloadConfig(); + } catch (err) { + return shutdown(err); + } + + cliLogger.info('Reloaded logging configuration due to SIGHUP.', { tags: ['config'] }); + }); + + process.on('SIGINT', () => shutdown()); + process.on('SIGTERM', () => shutdown()); +} + +function onRootShutdown(reason?: any) { + if (reason !== undefined) { + // There is a chance that logger wasn't configured properly and error that + // that forced root to shut down could go unnoticed. To prevent this we always + // mirror such fatal errors in standard output with `console.error`. + // tslint:disable no-console + console.error(`\n${chalk.white.bgRed(' FATAL ')} ${reason}\n`); + } + + process.exit(reason === undefined ? 0 : (reason as any).processExitCode || 1); +} diff --git a/src/core/server/config/__tests__/__fixtures__/config.yml b/src/core/server/config/__fixtures__/config.yml similarity index 100% rename from src/core/server/config/__tests__/__fixtures__/config.yml rename to src/core/server/config/__fixtures__/config.yml diff --git a/src/core/server/config/__tests__/__fixtures__/config_flat.yml b/src/core/server/config/__fixtures__/config_flat.yml similarity index 100% rename from src/core/server/config/__tests__/__fixtures__/config_flat.yml rename to src/core/server/config/__fixtures__/config_flat.yml diff --git a/src/cli/serve/__fixtures__/en_var_ref_config.yml b/src/core/server/config/__fixtures__/en_var_ref_config.yml similarity index 100% rename from src/cli/serve/__fixtures__/en_var_ref_config.yml rename to src/core/server/config/__fixtures__/en_var_ref_config.yml diff --git a/src/core/server/config/__fixtures__/one.yml b/src/core/server/config/__fixtures__/one.yml new file mode 100644 index 00000000000000..1dbe11095b19ae --- /dev/null +++ b/src/core/server/config/__fixtures__/one.yml @@ -0,0 +1,7 @@ +foo: 1 +bar: true +xyz: ['1', '2'] +abc: + def: test + qwe: 1 +pom.bom: 3 diff --git a/src/core/server/config/__fixtures__/two.yml b/src/core/server/config/__fixtures__/two.yml new file mode 100644 index 00000000000000..2fd2963e5668cb --- /dev/null +++ b/src/core/server/config/__fixtures__/two.yml @@ -0,0 +1,7 @@ +foo: 2 +baz: bonkers +xyz: ['3', '4'] +abc: + ghi: test2 + qwe: 2 +pom.mob: 4 diff --git a/src/core/server/config/__tests__/__mocks__/env.ts b/src/core/server/config/__mocks__/env.ts similarity index 58% rename from src/core/server/config/__tests__/__mocks__/env.ts rename to src/core/server/config/__mocks__/env.ts index e86cd9aab77cda..dec62978a292fe 100644 --- a/src/core/server/config/__tests__/__mocks__/env.ts +++ b/src/core/server/config/__mocks__/env.ts @@ -19,35 +19,25 @@ // Test helpers to simplify mocking environment options. -import { EnvOptions } from '../../env'; +import { EnvOptions } from '../env'; -interface MockEnvOptions { - config?: string; - kbnServer?: any; - mode?: EnvOptions['mode']['name']; - packageInfo?: Partial; -} +type DeepPartial = { + [P in keyof T]?: T[P] extends Array ? Array> : DeepPartial +}; -export function getEnvOptions({ - config, - kbnServer, - mode = 'development', - packageInfo = {}, -}: MockEnvOptions = {}): EnvOptions { +export function getEnvOptions(options: DeepPartial = {}): EnvOptions { return { - config, - kbnServer, - mode: { - dev: mode === 'development', - name: mode, - prod: mode === 'production', - }, - packageInfo: { - branch: 'some-branch', - buildNum: 1, - buildSha: 'some-sha-256', - version: 'some-version', - ...packageInfo, + configs: options.configs || [], + cliArgs: { + dev: true, + quiet: false, + silent: false, + watch: false, + repl: false, + basePath: false, + ...(options.cliArgs || {}), }, + isDevClusterMaster: + options.isDevClusterMaster !== undefined ? options.isDevClusterMaster : false, }; } diff --git a/src/core/server/config/__tests__/__snapshots__/config_service.test.ts.snap b/src/core/server/config/__snapshots__/config_service.test.ts.snap similarity index 100% rename from src/core/server/config/__tests__/__snapshots__/config_service.test.ts.snap rename to src/core/server/config/__snapshots__/config_service.test.ts.snap diff --git a/src/core/server/config/__snapshots__/env.test.ts.snap b/src/core/server/config/__snapshots__/env.test.ts.snap new file mode 100644 index 00000000000000..5931b0697d79c5 --- /dev/null +++ b/src/core/server/config/__snapshots__/env.test.ts.snap @@ -0,0 +1,207 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`correctly creates default environment if \`--env.name\` is supplied.: dev env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "basePath": false, + "dev": true, + "envName": "development", + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/test/cwd/config", + "configs": Array [ + "/some/other/path/some-kibana.yml", + ], + "corePluginsDir": "/test/cwd/core_plugins", + "homeDir": "/test/cwd", + "isDevClusterMaster": false, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 9007199254740991, + "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "version": "v1", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates default environment if \`--env.name\` is supplied.: prod env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "basePath": false, + "dev": false, + "envName": "production", + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/test/cwd/config", + "configs": Array [ + "/some/other/path/some-kibana.yml", + ], + "corePluginsDir": "/test/cwd/core_plugins", + "homeDir": "/test/cwd", + "isDevClusterMaster": false, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": false, + "name": "production", + "prod": true, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 9007199254740991, + "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "version": "v1", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates default environment in dev mode.: env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "basePath": false, + "dev": true, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/test/cwd/config", + "configs": Array [ + "/test/cwd/config/kibana.yml", + ], + "corePluginsDir": "/test/cwd/core_plugins", + "homeDir": "/test/cwd", + "isDevClusterMaster": true, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": true, + "name": "development", + "prod": false, + }, + "packageInfo": Object { + "branch": "some-branch", + "buildNum": 9007199254740991, + "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "version": "some-version", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates default environment in prod distributable mode.: env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "basePath": false, + "dev": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/test/cwd/config", + "configs": Array [ + "/some/other/path/some-kibana.yml", + ], + "corePluginsDir": "/test/cwd/core_plugins", + "homeDir": "/test/cwd", + "isDevClusterMaster": false, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": false, + "name": "production", + "prod": true, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 100, + "buildSha": "feature-v1-build-sha", + "version": "v1", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates default environment in prod non-distributable mode.: env properties 1`] = ` +Env { + "binDir": "/test/cwd/bin", + "cliArgs": Object { + "basePath": false, + "dev": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/test/cwd/config", + "configs": Array [ + "/some/other/path/some-kibana.yml", + ], + "corePluginsDir": "/test/cwd/core_plugins", + "homeDir": "/test/cwd", + "isDevClusterMaster": false, + "logDir": "/test/cwd/log", + "mode": Object { + "dev": false, + "name": "production", + "prod": true, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 9007199254740991, + "buildSha": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "version": "v1", + }, + "staticFilesDir": "/test/cwd/ui", +} +`; + +exports[`correctly creates environment with constructor.: env properties 1`] = ` +Env { + "binDir": "/some/home/dir/bin", + "cliArgs": Object { + "basePath": false, + "dev": false, + "quiet": false, + "repl": false, + "silent": false, + "watch": false, + }, + "configDir": "/some/home/dir/config", + "configs": Array [ + "/some/other/path/some-kibana.yml", + ], + "corePluginsDir": "/some/home/dir/core_plugins", + "homeDir": "/some/home/dir", + "isDevClusterMaster": false, + "logDir": "/some/home/dir/log", + "mode": Object { + "dev": false, + "name": "production", + "prod": true, + }, + "packageInfo": Object { + "branch": "feature-v1", + "buildNum": 100, + "buildSha": "feature-v1-build-sha", + "version": "v1", + }, + "staticFilesDir": "/some/home/dir/ui", +} +`; diff --git a/src/core/server/config/__snapshots__/read_config.test.ts.snap b/src/core/server/config/__snapshots__/read_config.test.ts.snap new file mode 100644 index 00000000000000..c83e902d8d098f --- /dev/null +++ b/src/core/server/config/__snapshots__/read_config.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`different cwd() resolves relative files based on the cwd 1`] = ` +Object { + "abc": Object { + "def": "test", + "qwe": 1, + }, + "bar": true, + "foo": 1, + "pom": Object { + "bom": 3, + }, + "xyz": Array [ + "1", + "2", + ], +} +`; + +exports[`reads and merges multiple yaml files from file system and parses to json 1`] = ` +Object { + "abc": Object { + "def": "test", + "ghi": "test2", + "qwe": 2, + }, + "bar": true, + "baz": "bonkers", + "foo": 2, + "pom": Object { + "bom": 3, + "mob": 4, + }, + "xyz": Array [ + "3", + "4", + ], +} +`; + +exports[`reads single yaml from file system and parses to json 1`] = ` +Object { + "pid": Object { + "enabled": true, + "file": "/var/run/kibana.pid", + }, +} +`; + +exports[`returns a deep object 1`] = ` +Object { + "pid": Object { + "enabled": true, + "file": "/var/run/kibana.pid", + }, +} +`; + +exports[`should inject an environment variable value when setting a value with \${ENV_VAR} 1`] = ` +Object { + "bar": "pre-val1-mid-val2-post", + "elasticsearch": Object { + "requestHeadersWhitelist": Array [ + "val1", + "val2", + ], + }, + "foo": 1, +} +`; + +exports[`should throw an exception when referenced environment variable in a config value does not exist 1`] = `"Unknown environment variable referenced in config : KBN_ENV_VAR1"`; diff --git a/src/core/server/config/__tests__/env.test.ts b/src/core/server/config/__tests__/env.test.ts deleted file mode 100644 index 8707bb2f2a2f71..00000000000000 --- a/src/core/server/config/__tests__/env.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -jest.mock('process', () => ({ - cwd() { - return '/test/cwd'; - }, -})); - -jest.mock('path', () => ({ - resolve(...pathSegments: string[]) { - return pathSegments.join('/'); - }, -})); - -import { Env } from '../env'; -import { getEnvOptions } from './__mocks__/env'; - -test('correctly creates default environment with empty options.', () => { - const envOptions = getEnvOptions(); - const defaultEnv = Env.createDefault(envOptions); - - expect(defaultEnv.homeDir).toEqual('/test/cwd'); - expect(defaultEnv.configDir).toEqual('/test/cwd/config'); - expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins'); - expect(defaultEnv.binDir).toEqual('/test/cwd/bin'); - expect(defaultEnv.logDir).toEqual('/test/cwd/log'); - expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui'); - - expect(defaultEnv.getConfigFile()).toEqual('/test/cwd/config/kibana.yml'); - expect(defaultEnv.getLegacyKbnServer()).toBeUndefined(); - expect(defaultEnv.getMode()).toEqual(envOptions.mode); - expect(defaultEnv.getPackageInfo()).toEqual(envOptions.packageInfo); -}); - -test('correctly creates default environment with options overrides.', () => { - const mockEnvOptions = getEnvOptions({ - config: '/some/other/path/some-kibana.yml', - kbnServer: {}, - mode: 'production', - packageInfo: { - branch: 'feature-v1', - buildNum: 100, - buildSha: 'feature-v1-build-sha', - version: 'v1', - }, - }); - const defaultEnv = Env.createDefault(mockEnvOptions); - - expect(defaultEnv.homeDir).toEqual('/test/cwd'); - expect(defaultEnv.configDir).toEqual('/test/cwd/config'); - expect(defaultEnv.corePluginsDir).toEqual('/test/cwd/core_plugins'); - expect(defaultEnv.binDir).toEqual('/test/cwd/bin'); - expect(defaultEnv.logDir).toEqual('/test/cwd/log'); - expect(defaultEnv.staticFilesDir).toEqual('/test/cwd/ui'); - - expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config); - expect(defaultEnv.getLegacyKbnServer()).toBe(mockEnvOptions.kbnServer); - expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode); - expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo); -}); - -test('correctly creates environment with constructor.', () => { - const mockEnvOptions = getEnvOptions({ - config: '/some/other/path/some-kibana.yml', - mode: 'production', - packageInfo: { - branch: 'feature-v1', - buildNum: 100, - buildSha: 'feature-v1-build-sha', - version: 'v1', - }, - }); - - const defaultEnv = new Env('/some/home/dir', mockEnvOptions); - - expect(defaultEnv.homeDir).toEqual('/some/home/dir'); - expect(defaultEnv.configDir).toEqual('/some/home/dir/config'); - expect(defaultEnv.corePluginsDir).toEqual('/some/home/dir/core_plugins'); - expect(defaultEnv.binDir).toEqual('/some/home/dir/bin'); - expect(defaultEnv.logDir).toEqual('/some/home/dir/log'); - expect(defaultEnv.staticFilesDir).toEqual('/some/home/dir/ui'); - - expect(defaultEnv.getConfigFile()).toEqual(mockEnvOptions.config); - expect(defaultEnv.getLegacyKbnServer()).toBeUndefined(); - expect(defaultEnv.getMode()).toEqual(mockEnvOptions.mode); - expect(defaultEnv.getPackageInfo()).toEqual(mockEnvOptions.packageInfo); -}); diff --git a/src/core/server/config/__tests__/apply_argv.test.ts b/src/core/server/config/apply_argv.test.ts similarity index 82% rename from src/core/server/config/__tests__/apply_argv.test.ts rename to src/core/server/config/apply_argv.test.ts index 9fd36b308f049e..80aa3d9f74a401 100644 --- a/src/core/server/config/__tests__/apply_argv.test.ts +++ b/src/core/server/config/apply_argv.test.ts @@ -17,15 +17,15 @@ * under the License. */ -import { ObjectToRawConfigAdapter, RawConfig } from '..'; +import { Config, ObjectToConfigAdapter } from '.'; /** * Overrides some config values with ones from argv. * - * @param config `RawConfig` instance to update config values for. + * @param config `Config` instance to update config values for. * @param argv Argv object with key/value pairs. */ -export function overrideConfigWithArgv(config: RawConfig, argv: { [key: string]: any }) { +export function overrideConfigWithArgv(config: Config, argv: { [key: string]: any }) { if (argv.port != null) { config.set(['server', 'port'], argv.port); } @@ -42,7 +42,7 @@ test('port', () => { port: 123, }; - const config = new ObjectToRawConfigAdapter({ + const config = new ObjectToConfigAdapter({ server: { port: 456 }, }); @@ -56,7 +56,7 @@ test('host', () => { host: 'example.org', }; - const config = new ObjectToRawConfigAdapter({ + const config = new ObjectToConfigAdapter({ server: { host: 'org.example' }, }); @@ -70,7 +70,7 @@ test('ignores unknown', () => { unknown: 'some value', }; - const config = new ObjectToRawConfigAdapter({}); + const config = new ObjectToConfigAdapter({}); jest.spyOn(config, 'set'); overrideConfigWithArgv(config, argv); diff --git a/src/core/server/config/raw_config.ts b/src/core/server/config/config.ts similarity index 80% rename from src/core/server/config/raw_config.ts rename to src/core/server/config/config.ts index c87a6fe5768a5a..07a3a7be81dd91 100644 --- a/src/core/server/config/raw_config.ts +++ b/src/core/server/config/config.ts @@ -17,12 +17,12 @@ * under the License. */ -import { ConfigPath } from './config_service'; +export type ConfigPath = string | string[]; /** - * Represents raw config store. + * Represents config store. */ -export interface RawConfig { +export interface Config { /** * Returns whether or not there is a config value located at the specified path. * @param configPath Path to locate value at. @@ -49,4 +49,11 @@ export interface RawConfig { * @returns List of the string config paths. */ getFlattenedPaths(): string[]; + + /** + * Returns a full copy of the underlying raw config object. Should be used ONLY + * in extreme cases when there is no other better way, e.g. bridging with the + * "legacy" systems that consume and process config in a different way. + */ + toRaw(): Record; } diff --git a/src/core/server/config/__tests__/config_service.test.ts b/src/core/server/config/config_service.test.ts similarity index 80% rename from src/core/server/config/__tests__/config_service.test.ts rename to src/core/server/config/config_service.test.ts index 1cac0d4ef8d0e3..22598b2a971d01 100644 --- a/src/core/server/config/__tests__/config_service.test.ts +++ b/src/core/server/config/config_service.test.ts @@ -18,20 +18,24 @@ */ /* tslint:disable max-classes-per-file */ + import { BehaviorSubject } from 'rxjs'; import { first } from 'rxjs/operators'; -import { schema, Type, TypeOf } from '../schema'; -import { ConfigService, ObjectToRawConfigAdapter } from '..'; -import { logger } from '../../logging/__mocks__'; -import { Env } from '../env'; +const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] }); +jest.mock('../../../utils/package_json', () => ({ pkg: mockPackage })); + +import { schema, Type, TypeOf } from './schema'; + +import { ConfigService, Env, ObjectToConfigAdapter } from '.'; +import { logger } from '../logging/__mocks__'; import { getEnvOptions } from './__mocks__/env'; const emptyArgv = getEnvOptions(); const defaultEnv = new Env('/kibana', emptyArgv); test('returns config at path as observable', async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'foo' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ key: 'foo' })); const configService = new ConfigService(config$, defaultEnv, logger); const configs = configService.atPath('key', ExampleClassWithStringSchema); @@ -43,7 +47,7 @@ test('returns config at path as observable', async () => { test('throws if config at path does not match schema', async () => { expect.assertions(1); - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 123 })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ key: 123 })); const configService = new ConfigService(config$, defaultEnv, logger); const configs = configService.atPath('key', ExampleClassWithStringSchema); @@ -56,7 +60,7 @@ test('throws if config at path does not match schema', async () => { }); test("returns undefined if fetching optional config at a path that doesn't exist", async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ foo: 'bar' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ foo: 'bar' })); const configService = new ConfigService(config$, defaultEnv, logger); const configs = configService.optionalAtPath('unique-name', ExampleClassWithStringSchema); @@ -66,7 +70,7 @@ test("returns undefined if fetching optional config at a path that doesn't exist }); test('returns observable config at optional path if it exists', async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ value: 'bar' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ value: 'bar' })); const configService = new ConfigService(config$, defaultEnv, logger); const configs = configService.optionalAtPath('value', ExampleClassWithStringSchema); @@ -77,7 +81,7 @@ test('returns observable config at optional path if it exists', async () => { }); test("does not push new configs when reloading if config at path hasn't changed", async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ key: 'value' })); const configService = new ConfigService(config$, defaultEnv, logger); const valuesReceived: any[] = []; @@ -85,13 +89,13 @@ test("does not push new configs when reloading if config at path hasn't changed" valuesReceived.push(config.value); }); - config$.next(new ObjectToRawConfigAdapter({ key: 'value' })); + config$.next(new ObjectToConfigAdapter({ key: 'value' })); expect(valuesReceived).toEqual(['value']); }); test('pushes new config when reloading and config at path has changed', async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ key: 'value' })); const configService = new ConfigService(config$, defaultEnv, logger); const valuesReceived: any[] = []; @@ -99,7 +103,7 @@ test('pushes new config when reloading and config at path has changed', async () valuesReceived.push(config.value); }); - config$.next(new ObjectToRawConfigAdapter({ key: 'new value' })); + config$.next(new ObjectToConfigAdapter({ key: 'new value' })); expect(valuesReceived).toEqual(['value', 'new value']); }); @@ -109,7 +113,7 @@ test("throws error if config class does not implement 'schema'", async () => { class ExampleClass {} - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ key: 'value' })); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ key: 'value' })); const configService = new ConfigService(config$, defaultEnv, logger); const configs = configService.atPath('key', ExampleClass as any); @@ -142,7 +146,7 @@ test('tracks unhandled paths', async () => { }, }; - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter(initialConfig)); const configService = new ConfigService(config$, defaultEnv, logger); configService.atPath('foo', createClassWithSchema(schema.string())); @@ -161,21 +165,19 @@ test('tracks unhandled paths', async () => { }); test('correctly passes context', async () => { - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter({ foo: {} })); - - const env = new Env( - '/kibana', - getEnvOptions({ - mode: 'development', - packageInfo: { - branch: 'feature-v1', - buildNum: 100, - buildSha: 'feature-v1-build-sha', - version: 'v1', - }, - }) - ); + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: true, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const env = new Env('/kibana', getEnvOptions()); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter({ foo: {} })); const configService = new ConfigService(config$, env, logger); const configs = configService.atPath( 'foo', @@ -210,7 +212,7 @@ test('handles enabled path, but only marks the enabled path as used', async () = }, }; - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter(initialConfig)); const configService = new ConfigService(config$, defaultEnv, logger); const isEnabled = await configService.isEnabledAtPath('pid'); @@ -228,7 +230,7 @@ test('handles enabled path when path is array', async () => { }, }; - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter(initialConfig)); const configService = new ConfigService(config$, defaultEnv, logger); const isEnabled = await configService.isEnabledAtPath(['pid']); @@ -246,7 +248,7 @@ test('handles disabled path and marks config as used', async () => { }, }; - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter(initialConfig)); const configService = new ConfigService(config$, defaultEnv, logger); const isEnabled = await configService.isEnabledAtPath('pid'); @@ -259,7 +261,7 @@ test('handles disabled path and marks config as used', async () => { test('treats config as enabled if config path is not present in config', async () => { const initialConfig = {}; - const config$ = new BehaviorSubject(new ObjectToRawConfigAdapter(initialConfig)); + const config$ = new BehaviorSubject(new ObjectToConfigAdapter(initialConfig)); const configService = new ConfigService(config$, defaultEnv, logger); const isEnabled = await configService.isEnabledAtPath('pid'); diff --git a/src/core/server/config/config_service.ts b/src/core/server/config/config_service.ts index a918bb588e94c4..a3314d5657141e 100644 --- a/src/core/server/config/config_service.ts +++ b/src/core/server/config/config_service.ts @@ -21,14 +21,10 @@ import { isEqual } from 'lodash'; import { Observable } from 'rxjs'; import { distinctUntilChanged, first, map } from 'rxjs/operators'; +import { Config, ConfigPath, ConfigWithSchema, Env } from '.'; import { Logger, LoggerFactory } from '../logging'; -import { ConfigWithSchema } from './config_with_schema'; -import { Env } from './env'; -import { RawConfig } from './raw_config'; import { Type } from './schema'; -export type ConfigPath = string | string[]; - export class ConfigService { private readonly log: Logger; @@ -39,7 +35,7 @@ export class ConfigService { private readonly handledPaths: ConfigPath[] = []; constructor( - private readonly config$: Observable, + private readonly config$: Observable, readonly env: Env, logger: LoggerFactory ) { @@ -62,12 +58,12 @@ export class ConfigService { * @param ConfigClass A class (not an instance of a class) that contains a * static `schema` that we validate the config at the given `path` against. */ - public atPath, Config>( + public atPath, TConfig>( path: ConfigPath, - ConfigClass: ConfigWithSchema + ConfigClass: ConfigWithSchema ) { - return this.getDistinctRawConfig(path).pipe( - map(rawConfig => this.createConfig(path, rawConfig, ConfigClass)) + return this.getDistinctConfig(path).pipe( + map(config => this.createConfig(path, config, ConfigClass)) ); } @@ -77,14 +73,13 @@ export class ConfigService { * * @see atPath */ - public optionalAtPath, Config>( + public optionalAtPath, TConfig>( path: ConfigPath, - ConfigClass: ConfigWithSchema + ConfigClass: ConfigWithSchema ) { - return this.getDistinctRawConfig(path).pipe( + return this.getDistinctConfig(path).pipe( map( - rawConfig => - rawConfig === undefined ? undefined : this.createConfig(path, rawConfig, ConfigClass) + config => (config === undefined ? undefined : this.createConfig(path, config, ConfigClass)) ) ); } @@ -93,13 +88,11 @@ export class ConfigService { const enabledPath = createPluginEnabledPath(path); const config = await this.config$.pipe(first()).toPromise(); - if (!config.has(enabledPath)) { return true; } const isEnabled = config.get(enabledPath); - if (isEnabled === false) { // If the plugin is _not_ enabled, we mark the entire plugin path as // handled, as it's expected that it won't be used. @@ -121,10 +114,10 @@ export class ConfigService { return config.getFlattenedPaths().filter(path => !isPathHandled(path, handledPaths)); } - private createConfig, Config>( + private createConfig, TConfig>( path: ConfigPath, - rawConfig: {}, - ConfigClass: ConfigWithSchema + config: Record, + ConfigClass: ConfigWithSchema ) { const namespace = Array.isArray(path) ? path.join('.') : path; @@ -138,20 +131,19 @@ export class ConfigService { ); } - const environmentMode = this.env.getMode(); - const config = ConfigClass.schema.validate( - rawConfig, + const validatedConfig = ConfigClass.schema.validate( + config, { - dev: environmentMode.dev, - prod: environmentMode.prod, - ...this.env.getPackageInfo(), + dev: this.env.mode.dev, + prod: this.env.mode.prod, + ...this.env.packageInfo, }, namespace ); - return new ConfigClass(config, this.env); + return new ConfigClass(validatedConfig, this.env); } - private getDistinctRawConfig(path: ConfigPath) { + private getDistinctConfig(path: ConfigPath) { this.markAsHandled(path); return this.config$.pipe(map(config => config.get(path)), distinctUntilChanged(isEqual)); diff --git a/src/core/server/config/__tests__/ensure_deep_object.test.ts b/src/core/server/config/ensure_deep_object.test.ts similarity index 98% rename from src/core/server/config/__tests__/ensure_deep_object.test.ts rename to src/core/server/config/ensure_deep_object.test.ts index 40c07322660737..5a520fbeef3169 100644 --- a/src/core/server/config/__tests__/ensure_deep_object.test.ts +++ b/src/core/server/config/ensure_deep_object.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ensureDeepObject } from '../ensure_deep_object'; +import { ensureDeepObject } from './ensure_deep_object'; test('flat object', () => { const obj = { diff --git a/src/core/server/config/env.test.ts b/src/core/server/config/env.test.ts new file mode 100644 index 00000000000000..56ff576fd8f31d --- /dev/null +++ b/src/core/server/config/env.test.ts @@ -0,0 +1,145 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('process', () => ({ + cwd() { + return '/test/cwd'; + }, +})); + +jest.mock('path', () => ({ + resolve(...pathSegments: string[]) { + return pathSegments.join('/'); + }, +})); + +const mockPackage = new Proxy({ raw: {} as any }, { get: (obj, prop) => obj.raw[prop] }); +jest.mock('../../../utils/package_json', () => ({ pkg: mockPackage })); + +import { Env } from '.'; +import { getEnvOptions } from './__mocks__/env'; + +test('correctly creates default environment in dev mode.', () => { + mockPackage.raw = { + branch: 'some-branch', + version: 'some-version', + }; + + const defaultEnv = Env.createDefault( + getEnvOptions({ + configs: ['/test/cwd/config/kibana.yml'], + isDevClusterMaster: true, + }) + ); + + expect(defaultEnv).toMatchSnapshot('env properties'); +}); + +test('correctly creates default environment in prod distributable mode.', () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: true, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const defaultEnv = Env.createDefault( + getEnvOptions({ + cliArgs: { dev: false }, + configs: ['/some/other/path/some-kibana.yml'], + }) + ); + + expect(defaultEnv).toMatchSnapshot('env properties'); +}); + +test('correctly creates default environment in prod non-distributable mode.', () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: false, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const defaultEnv = Env.createDefault( + getEnvOptions({ + cliArgs: { dev: false }, + configs: ['/some/other/path/some-kibana.yml'], + }) + ); + + expect(defaultEnv).toMatchSnapshot('env properties'); +}); + +test('correctly creates default environment if `--env.name` is supplied.', () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: false, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const defaultDevEnv = Env.createDefault( + getEnvOptions({ + cliArgs: { envName: 'development' }, + configs: ['/some/other/path/some-kibana.yml'], + }) + ); + + const defaultProdEnv = Env.createDefault( + getEnvOptions({ + cliArgs: { dev: false, envName: 'production' }, + configs: ['/some/other/path/some-kibana.yml'], + }) + ); + + expect(defaultDevEnv).toMatchSnapshot('dev env properties'); + expect(defaultProdEnv).toMatchSnapshot('prod env properties'); +}); + +test('correctly creates environment with constructor.', () => { + mockPackage.raw = { + branch: 'feature-v1', + version: 'v1', + build: { + distributable: true, + number: 100, + sha: 'feature-v1-build-sha', + }, + }; + + const env = new Env( + '/some/home/dir', + getEnvOptions({ + cliArgs: { dev: false }, + configs: ['/some/other/path/some-kibana.yml'], + }) + ); + + expect(env).toMatchSnapshot('env properties'); +}); diff --git a/src/core/server/config/env.ts b/src/core/server/config/env.ts index 87e4b6567120b0..f7b497403a28ac 100644 --- a/src/core/server/config/env.ts +++ b/src/core/server/config/env.ts @@ -20,7 +20,7 @@ import { resolve } from 'path'; import process from 'process'; -import { LegacyKbnServer } from '../legacy_compat'; +import { pkg } from '../../../utils/package_json'; interface PackageInfo { version: string; @@ -36,11 +36,19 @@ interface EnvironmentMode { } export interface EnvOptions { - config?: string; - kbnServer?: any; - packageInfo: PackageInfo; - mode: EnvironmentMode; - [key: string]: any; + configs: string[]; + cliArgs: CliArgs; + isDevClusterMaster: boolean; +} + +export interface CliArgs { + dev: boolean; + envName?: string; + quiet: boolean; + silent: boolean; + watch: boolean; + repl: boolean; + basePath: boolean; } export class Env { @@ -58,43 +66,57 @@ export class Env { public readonly staticFilesDir: string; /** - * @internal + * Information about Kibana package (version, build number etc.). */ - constructor(readonly homeDir: string, private readonly options: EnvOptions) { - this.configDir = resolve(this.homeDir, 'config'); - this.corePluginsDir = resolve(this.homeDir, 'core_plugins'); - this.binDir = resolve(this.homeDir, 'bin'); - this.logDir = resolve(this.homeDir, 'log'); - this.staticFilesDir = resolve(this.homeDir, 'ui'); - } + public readonly packageInfo: Readonly; - public getConfigFile() { - const defaultConfigFile = this.getDefaultConfigFile(); - return this.options.config === undefined ? defaultConfigFile : this.options.config; - } + /** + * Mode Kibana currently run in (development or production). + */ + public readonly mode: Readonly; /** - * @internal + * Arguments provided through command line. */ - public getLegacyKbnServer(): LegacyKbnServer | undefined { - return this.options.kbnServer; - } + public readonly cliArgs: Readonly; /** - * Gets information about Kibana package (version, build number etc.). + * Paths to the configuration files. */ - public getPackageInfo() { - return this.options.packageInfo; - } + public readonly configs: ReadonlyArray; /** - * Gets mode Kibana currently run in (development or production). + * Indicates that this Kibana instance is run as development Node Cluster master. */ - public getMode() { - return this.options.mode; - } + public readonly isDevClusterMaster: boolean; + + /** + * @internal + */ + constructor(readonly homeDir: string, options: EnvOptions) { + this.configDir = resolve(this.homeDir, 'config'); + this.corePluginsDir = resolve(this.homeDir, 'core_plugins'); + this.binDir = resolve(this.homeDir, 'bin'); + this.logDir = resolve(this.homeDir, 'log'); + this.staticFilesDir = resolve(this.homeDir, 'ui'); + + this.cliArgs = Object.freeze(options.cliArgs); + this.configs = Object.freeze(options.configs); + this.isDevClusterMaster = options.isDevClusterMaster; + + const isDevMode = this.cliArgs.dev || this.cliArgs.envName === 'development'; + this.mode = Object.freeze({ + dev: isDevMode, + name: isDevMode ? 'development' : 'production', + prod: !isDevMode, + }); - private getDefaultConfigFile() { - return resolve(this.configDir, 'kibana.yml'); + const isKibanaDistributable = pkg.build && pkg.build.distributable === true; + this.packageInfo = Object.freeze({ + branch: pkg.branch, + buildNum: isKibanaDistributable ? pkg.build.number : Number.MAX_SAFE_INTEGER, + buildSha: isKibanaDistributable ? pkg.build.sha : 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + version: pkg.version, + }); } } diff --git a/src/core/server/config/index.ts b/src/core/server/config/index.ts index 030fdca252d33d..bbddec03a0f41f 100644 --- a/src/core/server/config/index.ts +++ b/src/core/server/config/index.ts @@ -17,18 +17,10 @@ * under the License. */ -/** - * This is a name of configuration node that is specifically dedicated to - * the configuration values used by the new platform only. Eventually all - * its nested values will be migrated to the stable config and this node - * will be deprecated. - */ -export const NEW_PLATFORM_CONFIG_ROOT = '__newPlatform'; - export { ConfigService } from './config_service'; export { RawConfigService } from './raw_config_service'; -export { RawConfig } from './raw_config'; +export { Config, ConfigPath } from './config'; /** @internal */ -export { ObjectToRawConfigAdapter } from './object_to_raw_config_adapter'; -export { Env } from './env'; +export { ObjectToConfigAdapter } from './object_to_config_adapter'; +export { Env, CliArgs } from './env'; export { ConfigWithSchema } from './config_with_schema'; diff --git a/src/core/server/config/object_to_raw_config_adapter.ts b/src/core/server/config/object_to_config_adapter.ts similarity index 75% rename from src/core/server/config/object_to_raw_config_adapter.ts rename to src/core/server/config/object_to_config_adapter.ts index 3abd73f01fcb9a..b6ec7726035656 100644 --- a/src/core/server/config/object_to_raw_config_adapter.ts +++ b/src/core/server/config/object_to_config_adapter.ts @@ -17,32 +17,35 @@ * under the License. */ -import { get, has, set } from 'lodash'; +import { cloneDeep, get, has, set } from 'lodash'; -import { ConfigPath } from './config_service'; -import { RawConfig } from './raw_config'; +import { Config, ConfigPath } from './'; /** * Allows plain javascript object to behave like `RawConfig` instance. * @internal */ -export class ObjectToRawConfigAdapter implements RawConfig { - constructor(private readonly rawValue: { [key: string]: any }) {} +export class ObjectToConfigAdapter implements Config { + constructor(private readonly rawConfig: Record) {} public has(configPath: ConfigPath) { - return has(this.rawValue, configPath); + return has(this.rawConfig, configPath); } public get(configPath: ConfigPath) { - return get(this.rawValue, configPath); + return get(this.rawConfig, configPath); } public set(configPath: ConfigPath, value: any) { - set(this.rawValue, configPath, value); + set(this.rawConfig, configPath, value); } public getFlattenedPaths() { - return [...flattenObjectKeys(this.rawValue)]; + return [...flattenObjectKeys(this.rawConfig)]; + } + + public toRaw() { + return cloneDeep(this.rawConfig); } } diff --git a/src/core/server/config/__tests__/raw_config_service.test.ts b/src/core/server/config/raw_config_service.test.ts similarity index 51% rename from src/core/server/config/__tests__/raw_config_service.test.ts rename to src/core/server/config/raw_config_service.test.ts index baa4d2f8fe92e2..eb5c212a31eb9f 100644 --- a/src/core/server/config/__tests__/raw_config_service.test.ts +++ b/src/core/server/config/raw_config_service.test.ts @@ -17,49 +17,73 @@ * under the License. */ -const mockGetConfigFromFile = jest.fn(); +const mockGetConfigFromFiles = jest.fn(); -jest.mock('../read_config', () => ({ - getConfigFromFile: mockGetConfigFromFile, +jest.mock('./read_config', () => ({ + getConfigFromFiles: mockGetConfigFromFiles, })); import { first } from 'rxjs/operators'; -import { RawConfigService } from '../raw_config_service'; +import { RawConfigService } from '.'; const configFile = '/config/kibana.yml'; +const anotherConfigFile = '/config/kibana.dev.yml'; beforeEach(() => { - mockGetConfigFromFile.mockReset(); - mockGetConfigFromFile.mockImplementation(() => ({})); + mockGetConfigFromFiles.mockReset(); + mockGetConfigFromFiles.mockImplementation(() => ({})); }); -test('loads raw config when started', () => { - const configService = new RawConfigService(configFile); +test('loads single raw config when started', () => { + const configService = new RawConfigService([configFile]); configService.loadConfig(); - expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); - expect(mockGetConfigFromFile).toHaveBeenLastCalledWith(configFile); + expect(mockGetConfigFromFiles).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFiles).toHaveBeenLastCalledWith([configFile]); }); -test('re-reads the config when reloading', () => { - const configService = new RawConfigService(configFile); +test('loads multiple raw configs when started', () => { + const configService = new RawConfigService([configFile, anotherConfigFile]); configService.loadConfig(); - mockGetConfigFromFile.mockClear(); - mockGetConfigFromFile.mockImplementation(() => ({ foo: 'bar' })); + expect(mockGetConfigFromFiles).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFiles).toHaveBeenLastCalledWith([configFile, anotherConfigFile]); +}); + +test('re-reads single config when reloading', () => { + const configService = new RawConfigService([configFile]); + + configService.loadConfig(); + + mockGetConfigFromFiles.mockClear(); + mockGetConfigFromFiles.mockImplementation(() => ({ foo: 'bar' })); + + configService.reloadConfig(); + + expect(mockGetConfigFromFiles).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFiles).toHaveBeenLastCalledWith([configFile]); +}); + +test('re-reads multiple configs when reloading', () => { + const configService = new RawConfigService([configFile, anotherConfigFile]); + + configService.loadConfig(); + + mockGetConfigFromFiles.mockClear(); + mockGetConfigFromFiles.mockImplementation(() => ({ foo: 'bar' })); configService.reloadConfig(); - expect(mockGetConfigFromFile).toHaveBeenCalledTimes(1); - expect(mockGetConfigFromFile).toHaveBeenLastCalledWith(configFile); + expect(mockGetConfigFromFiles).toHaveBeenCalledTimes(1); + expect(mockGetConfigFromFiles).toHaveBeenLastCalledWith([configFile, anotherConfigFile]); }); test('returns config at path as observable', async () => { - mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'value' })); - const configService = new RawConfigService(configFile); + const configService = new RawConfigService([configFile]); configService.loadConfig(); @@ -73,9 +97,9 @@ test('returns config at path as observable', async () => { }); test("does not push new configs when reloading if config at path hasn't changed", async () => { - mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'value' })); - const configService = new RawConfigService(configFile); + const configService = new RawConfigService([configFile]); configService.loadConfig(); @@ -84,8 +108,8 @@ test("does not push new configs when reloading if config at path hasn't changed" valuesReceived.push(config); }); - mockGetConfigFromFile.mockClear(); - mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + mockGetConfigFromFiles.mockClear(); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'value' })); configService.reloadConfig(); @@ -95,9 +119,9 @@ test("does not push new configs when reloading if config at path hasn't changed" }); test('pushes new config when reloading and config at path has changed', async () => { - mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'value' })); - const configService = new RawConfigService(configFile); + const configService = new RawConfigService([configFile]); configService.loadConfig(); @@ -106,8 +130,8 @@ test('pushes new config when reloading and config at path has changed', async () valuesReceived.push(config); }); - mockGetConfigFromFile.mockClear(); - mockGetConfigFromFile.mockImplementation(() => ({ key: 'new value' })); + mockGetConfigFromFiles.mockClear(); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'new value' })); configService.reloadConfig(); @@ -121,9 +145,9 @@ test('pushes new config when reloading and config at path has changed', async () test('completes config observables when stopped', done => { expect.assertions(0); - mockGetConfigFromFile.mockImplementation(() => ({ key: 'value' })); + mockGetConfigFromFiles.mockImplementation(() => ({ key: 'value' })); - const configService = new RawConfigService(configFile); + const configService = new RawConfigService([configFile]); configService.loadConfig(); diff --git a/src/core/server/config/raw_config_service.ts b/src/core/server/config/raw_config_service.ts index ae1b99fd42650b..870defdb5bc03b 100644 --- a/src/core/server/config/raw_config_service.ts +++ b/src/core/server/config/raw_config_service.ts @@ -17,52 +17,41 @@ * under the License. */ -import { isEqual, isPlainObject } from 'lodash'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { cloneDeep, isEqual, isPlainObject } from 'lodash'; +import { Observable, ReplaySubject } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; import typeDetect from 'type-detect'; -import { ObjectToRawConfigAdapter } from './object_to_raw_config_adapter'; -import { RawConfig } from './raw_config'; -import { getConfigFromFile } from './read_config'; - -// Used to indicate that no config has been received yet -const notRead = Symbol('config not yet read'); +import { Config } from './config'; +import { ObjectToConfigAdapter } from './object_to_config_adapter'; +import { getConfigFromFiles } from './read_config'; export class RawConfigService { /** - * The stream of configs read from the config file. Will be the symbol - * `notRead` before the config is initially read, and after that it can - * potentially be `null` for an empty yaml file. + * The stream of configs read from the config file. * * This is the _raw_ config before any overrides are applied. - * - * As we have a notion of a _current_ config we rely on a BehaviorSubject so - * every new subscription will immediately receive the current config. */ - private readonly rawConfigFromFile$ = new BehaviorSubject(notRead); + private readonly rawConfigFromFile$: ReplaySubject> = new ReplaySubject(1); - private readonly config$: Observable; + private readonly config$: Observable; - constructor(readonly configFile: string) { + constructor( + readonly configFiles: ReadonlyArray, + configAdapter: (rawConfig: Record) => Config = rawConfig => + new ObjectToConfigAdapter(rawConfig) + ) { this.config$ = this.rawConfigFromFile$.pipe( - filter(rawConfig => rawConfig !== notRead), + // We only want to update the config if there are changes to it. + distinctUntilChanged(isEqual), map(rawConfig => { - // If the raw config is null, e.g. if empty config file, we default to - // an empty config - if (rawConfig == null) { - return new ObjectToRawConfigAdapter({}); - } - if (isPlainObject(rawConfig)) { // TODO Make config consistent, e.g. handle dots in keys - return new ObjectToRawConfigAdapter(rawConfig); + return configAdapter(cloneDeep(rawConfig)); } throw new Error(`the raw config must be an object, got [${typeDetect(rawConfig)}]`); - }), - // We only want to update the config if there are changes to it - distinctUntilChanged(isEqual) + }) ); } @@ -70,8 +59,7 @@ export class RawConfigService { * Read the initial Kibana config. */ public loadConfig() { - const config = getConfigFromFile(this.configFile); - this.rawConfigFromFile$.next(config); + this.rawConfigFromFile$.next(getConfigFromFiles(this.configFiles)); } public stop() { diff --git a/src/core/server/config/read_config.test.ts b/src/core/server/config/read_config.test.ts new file mode 100644 index 00000000000000..46b75f28eb987b --- /dev/null +++ b/src/core/server/config/read_config.test.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 { relative, resolve } from 'path'; +import { getConfigFromFiles } from './read_config'; + +const fixtureFile = (name: string) => `${__dirname}/__fixtures__/${name}`; + +test('reads single yaml from file system and parses to json', () => { + const config = getConfigFromFiles([fixtureFile('config.yml')]); + + expect(config).toMatchSnapshot(); +}); + +test('returns a deep object', () => { + const config = getConfigFromFiles([fixtureFile('/config_flat.yml')]); + + expect(config).toMatchSnapshot(); +}); + +test('reads and merges multiple yaml files from file system and parses to json', () => { + const config = getConfigFromFiles([fixtureFile('/one.yml'), fixtureFile('/two.yml')]); + + expect(config).toMatchSnapshot(); +}); + +test('should inject an environment variable value when setting a value with ${ENV_VAR}', () => { + process.env.KBN_ENV_VAR1 = 'val1'; + process.env.KBN_ENV_VAR2 = 'val2'; + + const config = getConfigFromFiles([fixtureFile('/en_var_ref_config.yml')]); + + delete process.env.KBN_ENV_VAR1; + delete process.env.KBN_ENV_VAR2; + + expect(config).toMatchSnapshot(); +}); + +test('should throw an exception when referenced environment variable in a config value does not exist', () => { + expect(() => + getConfigFromFiles([fixtureFile('/en_var_ref_config.yml')]) + ).toThrowErrorMatchingSnapshot(); +}); + +describe('different cwd()', () => { + const originalCwd = process.cwd(); + const tempCwd = resolve(__dirname); + + beforeAll(() => process.chdir(tempCwd)); + afterAll(() => process.chdir(originalCwd)); + + test('resolves relative files based on the cwd', () => { + const relativePath = relative(tempCwd, fixtureFile('/one.yml')); + const config = getConfigFromFiles([relativePath]); + + expect(config).toMatchSnapshot(); + }); + + test('fails to load relative paths, not found because of the cwd', () => { + const relativePath = relative(resolve(__dirname, '../../'), fixtureFile('/one.yml')); + expect(() => getConfigFromFiles([relativePath])).toThrowError(/ENOENT/); + }); +}); diff --git a/src/core/server/config/read_config.ts b/src/core/server/config/read_config.ts index c1b8ce930af2e7..f468c08ec559b9 100644 --- a/src/core/server/config/read_config.ts +++ b/src/core/server/config/read_config.ts @@ -20,11 +20,43 @@ import { readFileSync } from 'fs'; import { safeLoad } from 'js-yaml'; +import { isPlainObject, set } from 'lodash'; import { ensureDeepObject } from './ensure_deep_object'; const readYaml = (path: string) => safeLoad(readFileSync(path, 'utf8')); -export const getConfigFromFile = (configFile: string) => { - const yaml = readYaml(configFile); - return yaml == null ? yaml : ensureDeepObject(yaml); +function replaceEnvVarRefs(val: string) { + return val.replace(/\$\{(\w+)\}/g, (match, envVarName) => { + const envVarValue = process.env[envVarName]; + if (envVarValue !== undefined) { + return envVarValue; + } + + throw new Error(`Unknown environment variable referenced in config : ${envVarName}`); + }); +} + +function merge(target: Record, value: any, key?: string) { + if (isPlainObject(value) || Array.isArray(value)) { + for (const [subKey, subVal] of Object.entries(value)) { + merge(target, subVal, key ? `${key}.${subKey}` : subKey); + } + } else if (key !== undefined) { + set(target, key, typeof value === 'string' ? replaceEnvVarRefs(value) : value); + } + + return target; +} + +export const getConfigFromFiles = (configFiles: ReadonlyArray) => { + let mergedYaml = {}; + + for (const configFile of configFiles) { + const yaml = readYaml(configFile); + if (yaml !== null) { + mergedYaml = merge(mergedYaml, yaml); + } + } + + return ensureDeepObject(mergedYaml); }; diff --git a/src/core/server/config/schema/byte_size_value/__tests__/__snapshots__/index.test.ts.snap b/src/core/server/config/schema/byte_size_value/__snapshots__/index.test.ts.snap similarity index 100% rename from src/core/server/config/schema/byte_size_value/__tests__/__snapshots__/index.test.ts.snap rename to src/core/server/config/schema/byte_size_value/__snapshots__/index.test.ts.snap diff --git a/src/core/server/config/schema/byte_size_value/__tests__/index.test.ts b/src/core/server/config/schema/byte_size_value/index.test.ts similarity index 99% rename from src/core/server/config/schema/byte_size_value/__tests__/index.test.ts rename to src/core/server/config/schema/byte_size_value/index.test.ts index ece87692481527..46ed96c83dd1f7 100644 --- a/src/core/server/config/schema/byte_size_value/__tests__/index.test.ts +++ b/src/core/server/config/schema/byte_size_value/index.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ByteSizeValue } from '../'; +import { ByteSizeValue } from '.'; describe('parsing units', () => { test('bytes', () => { diff --git a/src/core/server/config/schema/byte_size_value/index.ts b/src/core/server/config/schema/byte_size_value/index.ts index 61ba879a5c9262..fb0105503a1494 100644 --- a/src/core/server/config/schema/byte_size_value/index.ts +++ b/src/core/server/config/schema/byte_size_value/index.ts @@ -36,8 +36,7 @@ export class ByteSizeValue { const match = /([1-9][0-9]*)(b|kb|mb|gb)/.exec(text); if (!match) { throw new Error( - `could not parse byte size value [${text}]. value must start with a ` + - `number and end with bytes size unit, e.g. 10kb, 23mb, 3gb, 239493b` + `could not parse byte size value [${text}]. Value must be a safe positive integer.` ); } diff --git a/src/core/server/config/schema/errors/__tests__/schema_error.test.ts b/src/core/server/config/schema/errors/schema_error.test.ts similarity index 98% rename from src/core/server/config/schema/errors/__tests__/schema_error.test.ts rename to src/core/server/config/schema/errors/schema_error.test.ts index 15ce626621b58a..0f632b781e9a60 100644 --- a/src/core/server/config/schema/errors/__tests__/schema_error.test.ts +++ b/src/core/server/config/schema/errors/schema_error.test.ts @@ -18,7 +18,7 @@ */ import { relative } from 'path'; -import { SchemaError } from '..'; +import { SchemaError } from '.'; /** * Make all paths in stacktrace relative. diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/any_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/any_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/any_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/any_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/array_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/array_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/array_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/array_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/boolean_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/boolean_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/boolean_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/boolean_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/byte_size_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/byte_size_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/byte_size_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/byte_size_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/conditional_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/conditional_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/conditional_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/conditional_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/duration_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/duration_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/duration_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/duration_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/literal_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/literal_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/literal_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/literal_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/map_of_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/map_of_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/map_of_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/map_of_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/maybe_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/maybe_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/maybe_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/maybe_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/number_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/number_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/number_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/number_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/object_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/object_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/object_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/object_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/one_of_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/one_of_type.test.ts.snap similarity index 100% rename from src/core/server/config/schema/types/__tests__/__snapshots__/one_of_type.test.ts.snap rename to src/core/server/config/schema/types/__snapshots__/one_of_type.test.ts.snap diff --git a/src/core/server/config/schema/types/__snapshots__/string_type.test.ts.snap b/src/core/server/config/schema/types/__snapshots__/string_type.test.ts.snap new file mode 100644 index 00000000000000..38da495520d2c2 --- /dev/null +++ b/src/core/server/config/schema/types/__snapshots__/string_type.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#hostname returns error when value is not a valid hostname 1`] = `"value is [host:name] but it must be a valid hostname (see RFC 1123)."`; + +exports[`#hostname returns error when value is not a valid hostname 2`] = `"value is [localhost:5601] but it must be a valid hostname (see RFC 1123)."`; + +exports[`#hostname returns error when value is not a valid hostname 3`] = `"value is [-] but it must be a valid hostname (see RFC 1123)."`; + +exports[`#hostname returns error when value is not a valid hostname 4`] = `"value is [0:?:0:0:0:0:0:1] but it must be a valid hostname (see RFC 1123)."`; + +exports[`#hostname returns error when value is not a valid hostname 5`] = `"value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must be a valid hostname (see RFC 1123)."`; + +exports[`#maxLength returns error when longer string 1`] = `"value is [foo] but it must have a maximum length of [2]."`; + +exports[`#minLength returns error when shorter string 1`] = `"value is [foo] but it must have a minimum length of [4]."`; + +exports[`#validate throws when returns string 1`] = `"validator failure"`; + +exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [undefined]"`; + +exports[`is required by default 1`] = `"expected value of type [string] but got [undefined]"`; + +exports[`returns error when not string 1`] = `"expected value of type [string] but got [number]"`; + +exports[`returns error when not string 2`] = `"expected value of type [string] but got [Array]"`; + +exports[`returns error when not string 3`] = `"expected value of type [string] but got [RegExp]"`; diff --git a/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap b/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap deleted file mode 100644 index 6427fa2752d40b..00000000000000 --- a/src/core/server/config/schema/types/__tests__/__snapshots__/string_type.test.ts.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#maxLength returns error when longer string 1`] = `"value is [foo] but it must have a maximum length of [2]."`; - -exports[`#minLength returns error when shorter string 1`] = `"value is [foo] but it must have a minimum length of [4]."`; - -exports[`#validate throws when returns string 1`] = `"validator failure"`; - -exports[`includes namespace in failure 1`] = `"[foo-namespace]: expected value of type [string] but got [undefined]"`; - -exports[`is required by default 1`] = `"expected value of type [string] but got [undefined]"`; - -exports[`returns error when not string 1`] = `"expected value of type [string] but got [number]"`; - -exports[`returns error when not string 2`] = `"expected value of type [string] but got [Array]"`; - -exports[`returns error when not string 3`] = `"expected value of type [string] but got [RegExp]"`; diff --git a/src/core/server/config/schema/types/__tests__/any_type.test.ts b/src/core/server/config/schema/types/any_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/any_type.test.ts rename to src/core/server/config/schema/types/any_type.test.ts index 6f39f3deab5fd2..4d68c860ba13d3 100644 --- a/src/core/server/config/schema/types/__tests__/any_type.test.ts +++ b/src/core/server/config/schema/types/any_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('works for any value', () => { expect(schema.any().validate(true)).toBe(true); diff --git a/src/core/server/config/schema/types/__tests__/array_type.test.ts b/src/core/server/config/schema/types/array_type.test.ts similarity index 99% rename from src/core/server/config/schema/types/__tests__/array_type.test.ts rename to src/core/server/config/schema/types/array_type.test.ts index f1fb124a95ede0..c6943e0d1b5f39 100644 --- a/src/core/server/config/schema/types/__tests__/array_type.test.ts +++ b/src/core/server/config/schema/types/array_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value if it matches the type', () => { const type = schema.arrayOf(schema.string()); diff --git a/src/core/server/config/schema/types/__tests__/boolean_type.test.ts b/src/core/server/config/schema/types/boolean_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/boolean_type.test.ts rename to src/core/server/config/schema/types/boolean_type.test.ts index bfd4259af387ea..d6e274f05e3ffa 100644 --- a/src/core/server/config/schema/types/__tests__/boolean_type.test.ts +++ b/src/core/server/config/schema/types/boolean_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value by default', () => { expect(schema.boolean().validate(true)).toBe(true); diff --git a/src/core/server/config/schema/types/__tests__/byte_size_type.test.ts b/src/core/server/config/schema/types/byte_size_type.test.ts similarity index 97% rename from src/core/server/config/schema/types/__tests__/byte_size_type.test.ts rename to src/core/server/config/schema/types/byte_size_type.test.ts index 786b996ae5687a..67eae1e7c382a6 100644 --- a/src/core/server/config/schema/types/__tests__/byte_size_type.test.ts +++ b/src/core/server/config/schema/types/byte_size_type.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { schema } from '../..'; -import { ByteSizeValue } from '../../byte_size_value'; +import { schema } from '..'; +import { ByteSizeValue } from '../byte_size_value'; const { byteSize } = schema; diff --git a/src/core/server/config/schema/types/__tests__/conditional_type.test.ts b/src/core/server/config/schema/types/conditional_type.test.ts similarity index 99% rename from src/core/server/config/schema/types/__tests__/conditional_type.test.ts rename to src/core/server/config/schema/types/conditional_type.test.ts index 112ee874afa7b9..a72c3463e00cb1 100644 --- a/src/core/server/config/schema/types/__tests__/conditional_type.test.ts +++ b/src/core/server/config/schema/types/conditional_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('required by default', () => { const type = schema.conditional( diff --git a/src/core/server/config/schema/types/__tests__/duration_type.test.ts b/src/core/server/config/schema/types/duration_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/duration_type.test.ts rename to src/core/server/config/schema/types/duration_type.test.ts index 0c1d7e4dd8e508..9a21afc6cf40ff 100644 --- a/src/core/server/config/schema/types/__tests__/duration_type.test.ts +++ b/src/core/server/config/schema/types/duration_type.test.ts @@ -18,7 +18,7 @@ */ import { duration as momentDuration } from 'moment'; -import { schema } from '../..'; +import { schema } from '..'; const { duration } = schema; diff --git a/src/core/server/config/schema/types/__tests__/literal_type.test.ts b/src/core/server/config/schema/types/literal_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/literal_type.test.ts rename to src/core/server/config/schema/types/literal_type.test.ts index 4d590200c1ccf7..5ee0ac4edff688 100644 --- a/src/core/server/config/schema/types/__tests__/literal_type.test.ts +++ b/src/core/server/config/schema/types/literal_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; const { literal } = schema; diff --git a/src/core/server/config/schema/types/__tests__/map_of_type.test.ts b/src/core/server/config/schema/types/map_of_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/map_of_type.test.ts rename to src/core/server/config/schema/types/map_of_type.test.ts index ed4e12f162c59f..1b72d39fcec263 100644 --- a/src/core/server/config/schema/types/__tests__/map_of_type.test.ts +++ b/src/core/server/config/schema/types/map_of_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('handles object as input', () => { const type = schema.mapOf(schema.string(), schema.string()); diff --git a/src/core/server/config/schema/types/__tests__/maybe_type.test.ts b/src/core/server/config/schema/types/maybe_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/maybe_type.test.ts rename to src/core/server/config/schema/types/maybe_type.test.ts index 950987763baf19..b29f504c03b322 100644 --- a/src/core/server/config/schema/types/__tests__/maybe_type.test.ts +++ b/src/core/server/config/schema/types/maybe_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value if specified', () => { const type = schema.maybe(schema.string()); diff --git a/src/core/server/config/schema/types/__tests__/number_type.test.ts b/src/core/server/config/schema/types/number_type.test.ts similarity index 98% rename from src/core/server/config/schema/types/__tests__/number_type.test.ts rename to src/core/server/config/schema/types/number_type.test.ts index dd6be2631d28cf..b85d5113563eb6 100644 --- a/src/core/server/config/schema/types/__tests__/number_type.test.ts +++ b/src/core/server/config/schema/types/number_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value by default', () => { expect(schema.number().validate(4)).toBe(4); diff --git a/src/core/server/config/schema/types/__tests__/object_type.test.ts b/src/core/server/config/schema/types/object_type.test.ts similarity index 99% rename from src/core/server/config/schema/types/__tests__/object_type.test.ts rename to src/core/server/config/schema/types/object_type.test.ts index ec54528c292a05..e0eaabadb8ef59 100644 --- a/src/core/server/config/schema/types/__tests__/object_type.test.ts +++ b/src/core/server/config/schema/types/object_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value by default', () => { const type = schema.object({ diff --git a/src/core/server/config/schema/types/__tests__/one_of_type.test.ts b/src/core/server/config/schema/types/one_of_type.test.ts similarity index 99% rename from src/core/server/config/schema/types/__tests__/one_of_type.test.ts rename to src/core/server/config/schema/types/one_of_type.test.ts index e2f0f9688544af..72119e761590b1 100644 --- a/src/core/server/config/schema/types/__tests__/one_of_type.test.ts +++ b/src/core/server/config/schema/types/one_of_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('handles string', () => { expect(schema.oneOf([schema.string()]).validate('test')).toBe('test'); diff --git a/src/core/server/config/schema/types/__tests__/string_type.test.ts b/src/core/server/config/schema/types/string_type.test.ts similarity index 68% rename from src/core/server/config/schema/types/__tests__/string_type.test.ts rename to src/core/server/config/schema/types/string_type.test.ts index 4ed472d10930ae..193d85d2907310 100644 --- a/src/core/server/config/schema/types/__tests__/string_type.test.ts +++ b/src/core/server/config/schema/types/string_type.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { schema } from '../..'; +import { schema } from '..'; test('returns value is string and defined', () => { expect(schema.string().validate('test')).toBe('test'); @@ -53,6 +53,39 @@ describe('#maxLength', () => { }); }); +describe('#hostname', () => { + test('returns value for valid hostname as per RFC1123', () => { + const hostNameSchema = schema.string({ hostname: true }); + + expect(hostNameSchema.validate('www.example.com')).toBe('www.example.com'); + expect(hostNameSchema.validate('3domain.local')).toBe('3domain.local'); + expect(hostNameSchema.validate('hostname')).toBe('hostname'); + expect(hostNameSchema.validate('2387628')).toBe('2387628'); + expect(hostNameSchema.validate('::1')).toBe('::1'); + expect(hostNameSchema.validate('0:0:0:0:0:0:0:1')).toBe('0:0:0:0:0:0:0:1'); + expect(hostNameSchema.validate('xn----ascii-7gg5ei7b1i.xn--90a3a')).toBe( + 'xn----ascii-7gg5ei7b1i.xn--90a3a' + ); + + const hostNameWithMaxAllowedLength = 'a'.repeat(255); + expect(hostNameSchema.validate(hostNameWithMaxAllowedLength)).toBe( + hostNameWithMaxAllowedLength + ); + }); + + test('returns error when value is not a valid hostname', () => { + const hostNameSchema = schema.string({ hostname: true }); + + expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingSnapshot(); + expect(() => hostNameSchema.validate('localhost:5601')).toThrowErrorMatchingSnapshot(); + expect(() => hostNameSchema.validate('-')).toThrowErrorMatchingSnapshot(); + expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingSnapshot(); + + const tooLongHostName = 'a'.repeat(256); + expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingSnapshot(); + }); +}); + describe('#defaultValue', () => { test('returns default when string is undefined', () => { expect(schema.string({ defaultValue: 'foo' }).validate(undefined)).toBe('foo'); diff --git a/src/core/server/config/schema/types/string_type.ts b/src/core/server/config/schema/types/string_type.ts index 4e7eb5cc229a5f..6a2cb948b0e769 100644 --- a/src/core/server/config/schema/types/string_type.ts +++ b/src/core/server/config/schema/types/string_type.ts @@ -24,6 +24,7 @@ import { Type, TypeOptions } from './type'; export type StringOptions = TypeOptions & { minLength?: number; maxLength?: number; + hostname?: boolean; }; export class StringType extends Type { @@ -38,6 +39,10 @@ export class StringType extends Type { schema = schema.max(options.maxLength); } + if (options.hostname === true) { + schema = schema.hostname(); + } + super(schema, options); } @@ -50,6 +55,8 @@ export class StringType extends Type { return `value is [${value}] but it must have a minimum length of [${limit}].`; case 'string.max': return `value is [${value}] but it must have a maximum length of [${limit}].`; + case 'string.hostname': + return `value is [${value}] but it must be a valid hostname (see RFC 1123).`; } } } diff --git a/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap similarity index 89% rename from src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap rename to src/core/server/http/__snapshots__/http_config.test.ts.snap index 4201e4f774892e..d7fe10b1c417ba 100644 --- a/src/core/server/http/__tests__/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -1,7 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`accepts valid hostnames: valid host names 1`] = ` +Object { + "host1": "www.example.com", + "host2": "8.8.8.8", + "host3": "::1", + "host4": "localhost", +} +`; + exports[`has defaults for config 1`] = ` Object { + "autoListen": true, "cors": false, "host": "localhost", "maxPayload": ByteSizeValue { @@ -44,7 +54,7 @@ exports[`throws if basepath is missing prepended slash 1`] = `"[basePath]: must exports[`throws if basepath is not specified, but rewriteBasePath is set 1`] = `"cannot use [rewriteBasePath] when [basePath] is not specified"`; -exports[`throws if invalid hostname 1`] = `"[host]: must be a valid hostname"`; +exports[`throws if invalid hostname 1`] = `"[host]: value is [asdf$%^] but it must be a valid hostname (see RFC 1123)."`; exports[`with TLS should accept known protocols\` 1`] = ` "[ssl.supportedProtocols.0]: types that failed validation: diff --git a/src/core/server/http/__snapshots__/http_server.test.ts.snap b/src/core/server/http/__snapshots__/http_server.test.ts.snap new file mode 100644 index 00000000000000..8e868e803602fa --- /dev/null +++ b/src/core/server/http/__snapshots__/http_server.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns server and connection options on start 1`] = ` +Object { + "host": "127.0.0.1", + "port": 12345, + "routes": Object { + "cors": undefined, + "payload": Object { + "maxBytes": 1024, + }, + "validate": Object { + "options": Object { + "abortEarly": false, + }, + }, + }, + "state": Object { + "strictHeader": false, + }, +} +`; diff --git a/src/core/server/http/__tests__/__snapshots__/http_service.test.ts.snap b/src/core/server/http/__snapshots__/http_service.test.ts.snap similarity index 100% rename from src/core/server/http/__tests__/__snapshots__/http_service.test.ts.snap rename to src/core/server/http/__snapshots__/http_service.test.ts.snap diff --git a/src/core/server/http/__tests__/__snapshots__/https_redirect_server.test.ts.snap b/src/core/server/http/__snapshots__/https_redirect_server.test.ts.snap similarity index 100% rename from src/core/server/http/__tests__/__snapshots__/https_redirect_server.test.ts.snap rename to src/core/server/http/__snapshots__/https_redirect_server.test.ts.snap diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index f4a9b59b77b10d..b0c2144d7189a6 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -29,8 +29,6 @@ import { createServer, getServerOptions } from './http_tools'; const alphabet = 'abcdefghijklmnopqrztuvwxyz'.split(''); export interface BasePathProxyServerOptions { - httpConfig: HttpConfig; - devConfig: DevConfig; shouldRedirectFromOldBasePath: (path: string) => boolean; blockUntil: () => Promise; } @@ -40,34 +38,38 @@ export class BasePathProxyServer { private httpsAgent?: HttpsAgent; get basePath() { - return this.options.httpConfig.basePath; + return this.httpConfig.basePath; } get targetPort() { - return this.options.devConfig.basePathProxyTargetPort; + return this.devConfig.basePathProxyTargetPort; } - constructor(private readonly log: Logger, private readonly options: BasePathProxyServerOptions) { + constructor( + private readonly log: Logger, + private readonly httpConfig: HttpConfig, + private readonly devConfig: DevConfig + ) { const ONE_GIGABYTE = 1024 * 1024 * 1024; - options.httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); + httpConfig.maxPayload = new ByteSizeValue(ONE_GIGABYTE); - if (!options.httpConfig.basePath) { - options.httpConfig.basePath = `/${sample(alphabet, 3).join('')}`; + if (!httpConfig.basePath) { + httpConfig.basePath = `/${sample(alphabet, 3).join('')}`; } } - public async start() { - const { httpConfig } = this.options; + public async start(options: Readonly) { + this.log.debug('starting basepath proxy server'); - const options = getServerOptions(httpConfig); - this.server = createServer(options); + const serverOptions = getServerOptions(this.httpConfig); + this.server = createServer(serverOptions); // Register hapi plugin that adds proxying functionality. It can be configured // through the route configuration object (see { handler: { proxy: ... } }). await this.server.register({ plugin: require('h2o2-latest') }); - if (httpConfig.ssl.enabled) { - const tlsOptions = options.tls as TlsOptions; + if (this.httpConfig.ssl.enabled) { + const tlsOptions = serverOptions.tls as TlsOptions; this.httpsAgent = new HttpsAgent({ ca: tlsOptions.ca, cert: tlsOptions.cert, @@ -77,40 +79,42 @@ export class BasePathProxyServer { }); } - this.setupRoutes(); + this.setupRoutes(options); + + await this.server.start(); this.log.info( - `starting basepath proxy server at ${this.server.info.uri}${httpConfig.basePath}` + `basepath proxy server running at ${this.server.info.uri}${this.httpConfig.basePath}` ); - - await this.server.start(); } public async stop() { - this.log.info('stopping basepath proxy server'); - - if (this.server !== undefined) { - await this.server.stop(); - this.server = undefined; + if (this.server === undefined) { + return; } + this.log.debug('stopping basepath proxy server'); + await this.server.stop(); + this.server = undefined; + if (this.httpsAgent !== undefined) { this.httpsAgent.destroy(); this.httpsAgent = undefined; } } - private setupRoutes() { + private setupRoutes({ + blockUntil, + shouldRedirectFromOldBasePath, + }: Readonly) { if (this.server === undefined) { throw new Error(`Routes cannot be set up since server is not initialized.`); } - const { httpConfig, devConfig, blockUntil, shouldRedirectFromOldBasePath } = this.options; - // Always redirect from root URL to the URL with basepath. this.server.route({ handler: (request, responseToolkit) => { - return responseToolkit.redirect(httpConfig.basePath); + return responseToolkit.redirect(this.httpConfig.basePath); }, method: 'GET', path: '/', @@ -122,7 +126,7 @@ export class BasePathProxyServer { agent: this.httpsAgent, host: this.server.info.host, passThrough: true, - port: devConfig.basePathProxyTargetPort, + port: this.devConfig.basePathProxyTargetPort, protocol: this.server.info.protocol, xforward: true, }, @@ -138,7 +142,7 @@ export class BasePathProxyServer { }, ], }, - path: `${httpConfig.basePath}/{kbnPath*}`, + path: `${this.httpConfig.basePath}/{kbnPath*}`, }); // It may happen that basepath has changed, but user still uses the old one, @@ -152,7 +156,7 @@ export class BasePathProxyServer { const isBasepathLike = oldBasePath.length === 3; return isGet && isBasepathLike && shouldRedirectFromOldBasePath(kbnPath) - ? responseToolkit.redirect(`${httpConfig.basePath}/${kbnPath}`) + ? responseToolkit.redirect(`${this.httpConfig.basePath}/${kbnPath}`) : responseToolkit.response('Not Found').code(404); }, method: '*', diff --git a/src/core/server/http/__tests__/http_config.test.ts b/src/core/server/http/http_config.test.ts similarity index 92% rename from src/core/server/http/__tests__/http_config.test.ts rename to src/core/server/http/http_config.test.ts index bc21205cf8708a..54d28ef921fcfd 100644 --- a/src/core/server/http/__tests__/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { HttpConfig } from '../http_config'; +import { HttpConfig } from '.'; test('has defaults for config', () => { const httpSchema = HttpConfig.schema; @@ -25,6 +25,15 @@ test('has defaults for config', () => { expect(httpSchema.validate(obj)).toMatchSnapshot(); }); +test('accepts valid hostnames', () => { + const { host: host1 } = HttpConfig.schema.validate({ host: 'www.example.com' }); + const { host: host2 } = HttpConfig.schema.validate({ host: '8.8.8.8' }); + const { host: host3 } = HttpConfig.schema.validate({ host: '::1' }); + const { host: host4 } = HttpConfig.schema.validate({ host: 'localhost' }); + + expect({ host1, host2, host3, host4 }).toMatchSnapshot('valid host names'); +}); + test('throws if invalid hostname', () => { const httpSchema = HttpConfig.schema; const obj = { diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index bef8baf7941476..67578ecc1559c2 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -21,7 +21,6 @@ import { Env } from '../config'; import { ByteSizeValue, schema, TypeOf } from '../config/schema'; import { SslConfig } from './ssl_config'; -const validHostnameRegex = /^(([A-Z0-9]|[A-Z0-9][A-Z0-9\-]*[A-Z0-9])\.)*([A-Z0-9]|[A-Z0-9][A-Z0-9\-]*[A-Z0-9])$/i; const validBasePathRegex = /(^$|^\/.*[^\/]$)/; const match = (regex: RegExp, errorMsg: string) => (str: string) => @@ -29,6 +28,7 @@ const match = (regex: RegExp, errorMsg: string) => (str: string) => const createHttpSchema = schema.object( { + autoListen: schema.boolean({ defaultValue: true }), basePath: schema.maybe( schema.string({ validate: match(validBasePathRegex, "must start with a slash, don't end with one"), @@ -51,7 +51,7 @@ const createHttpSchema = schema.object( ), host: schema.string({ defaultValue: 'localhost', - validate: match(validHostnameRegex, 'must be a valid hostname'), + hostname: true, }), maxPayload: schema.byteSize({ defaultValue: '1048576b', @@ -91,6 +91,7 @@ export class HttpConfig { */ public static schema = createHttpSchema; + public autoListen: boolean; public host: string; public port: number; public cors: boolean | { origin: string[] }; @@ -104,6 +105,7 @@ export class HttpConfig { * @internal */ constructor(config: HttpConfigType, env: Env) { + this.autoListen = config.autoListen; this.host = config.host; this.port = config.port; this.cors = config.cors; diff --git a/src/core/server/http/__tests__/http_server.test.ts b/src/core/server/http/http_server.test.ts similarity index 67% rename from src/core/server/http/__tests__/http_server.test.ts rename to src/core/server/http/http_server.test.ts index ed07d8220141bd..704a6ddf97abae 100644 --- a/src/core/server/http/__tests__/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -17,32 +17,25 @@ * under the License. */ -import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; +import { Server } from 'http'; jest.mock('fs', () => ({ readFileSync: jest.fn(), })); import Chance from 'chance'; -import http from 'http'; import supertest from 'supertest'; -import { Env } from '../../config'; -import { ByteSizeValue } from '../../config/schema'; -import { logger } from '../../logging/__mocks__'; -import { HttpConfig } from '../http_config'; -import { HttpServer } from '../http_server'; -import { Router } from '../router'; +import { HttpConfig, Router } from '.'; +import { ByteSizeValue } from '../config/schema'; +import { logger } from '../logging/__mocks__'; +import { HttpServer } from './http_server'; const chance = new Chance(); let server: HttpServer; let config: HttpConfig; -function getServerListener(httpServer: HttpServer) { - return (httpServer as any).server.listener; -} - beforeEach(() => { config = { host: '127.0.0.1', @@ -51,7 +44,7 @@ beforeEach(() => { ssl: {}, } as HttpConfig; - server = new HttpServer(logger.get(), new Env('/kibana', getEnvOptions())); + server = new HttpServer(logger.get()); }); afterEach(async () => { @@ -76,9 +69,9 @@ test('200 OK with body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(200) .then(res => { @@ -95,9 +88,9 @@ test('202 Accepted with body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(202) .then(res => { @@ -114,9 +107,9 @@ test('204 No content', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(204) .then(res => { @@ -135,9 +128,9 @@ test('400 Bad request with error', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/') .expect(400) .then(res => { @@ -164,9 +157,9 @@ test('valid params', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/some-string') .expect(200) .then(res => { @@ -193,9 +186,9 @@ test('invalid params', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/some-string') .expect(400) .then(res => { @@ -225,9 +218,9 @@ test('valid query', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=test&quux=123') .expect(200) .then(res => { @@ -254,9 +247,9 @@ test('invalid query', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=test') .expect(400) .then(res => { @@ -286,9 +279,9 @@ test('valid body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .post('/foo/') .send({ bar: 'test', @@ -319,9 +312,9 @@ test('invalid body', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .post('/foo/') .send({ bar: 'test' }) .expect(400) @@ -351,9 +344,9 @@ test('handles putting', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .put('/foo/') .send({ key: 'new value' }) .expect(200) @@ -381,9 +374,9 @@ test('handles deleting', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .delete('/foo/3') .expect(200) .then(res => { @@ -406,9 +399,9 @@ test('filtered headers', async () => { server.registerRouter(router); - await server.start(config); + const { server: innerServer } = await server.start(config); - await supertest(getServerListener(server)) + await supertest(innerServer.listener) .get('/foo/?bar=quux') .set('x-kibana-foo', 'bar') .set('x-kibana-bar', 'quux'); @@ -421,6 +414,7 @@ test('filtered headers', async () => { describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { let configWithBasePath: HttpConfig; + let innerServerListener: Server; beforeEach(async () => { configWithBasePath = { @@ -437,29 +431,30 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { server.registerRouter(router); - await server.start(configWithBasePath); + const { server: innerServer } = await server.start(configWithBasePath); + innerServerListener = innerServer.listener; }); test('/bar => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar') .expect(404); }); test('/bar/ => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/') .expect(404); }); test('/bar/foo => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/foo') .expect(404); }); test('/ => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/') .expect(200) .then(res => { @@ -468,7 +463,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { }); test('/foo => /foo', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/foo') .expect(200) .then(res => { @@ -479,6 +474,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: false`', () => { describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { let configWithBasePath: HttpConfig; + let innerServerListener: Server; beforeEach(async () => { configWithBasePath = { @@ -495,11 +491,12 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { server.registerRouter(router); - await server.start(configWithBasePath); + const { server: innerServer } = await server.start(configWithBasePath); + innerServerListener = innerServer.listener; }); test('/bar => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar') .expect(200) .then(res => { @@ -508,7 +505,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/bar/ => /', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/') .expect(200) .then(res => { @@ -517,7 +514,7 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/bar/foo => /foo', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/bar/foo') .expect(200) .then(res => { @@ -526,13 +523,13 @@ describe('with `basepath: /bar` and `rewriteBasePath: true`', () => { }); test('/ => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/') .expect(404); }); test('/foo => 404', async () => { - await supertest(getServerListener(server)) + await supertest(innerServerListener) .get('/foo') .expect(404); }); @@ -563,99 +560,13 @@ describe('with defined `redirectHttpFromPort`', () => { }); }); -describe('when run within legacy platform', () => { - let newPlatformProxyListenerMock: any; - beforeEach(() => { - newPlatformProxyListenerMock = { - bind: jest.fn(), - proxy: jest.fn(), - }; - - const kbnServerMock = { - newPlatformProxyListener: newPlatformProxyListenerMock, - }; - - server = new HttpServer( - logger.get(), - new Env('/kibana', getEnvOptions({ kbnServer: kbnServerMock })) - ); - - const router = new Router('/new'); - router.get({ path: '/', validate: false }, async (req, res) => { - return res.ok({ key: 'new-platform' }); - }); - - server.registerRouter(router); - - newPlatformProxyListenerMock.proxy.mockImplementation( - (req: http.IncomingMessage, res: http.ServerResponse) => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ key: `legacy-platform:${req.url}` })); - } - ); +test('returns server and connection options on start', async () => { + const { server: innerServer, options } = await server.start({ + ...config, + port: 12345, }); - test('binds proxy listener to server.', async () => { - expect(newPlatformProxyListenerMock.bind).not.toHaveBeenCalled(); - - await server.start(config); - - expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledTimes(1); - expect(newPlatformProxyListenerMock.bind).toHaveBeenCalledWith( - expect.any((http as any).Server) - ); - expect(newPlatformProxyListenerMock.bind.mock.calls[0][0]).toBe(getServerListener(server)); - }); - - test('forwards request to legacy platform if new one cannot handle it', async () => { - await server.start(config); - - await supertest(getServerListener(server)) - .get('/legacy') - .expect(200) - .then(res => { - expect(res.body).toEqual({ key: 'legacy-platform:/legacy' }); - expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1); - expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith( - expect.any((http as any).IncomingMessage), - expect.any((http as any).ServerResponse) - ); - }); - }); - - test('forwards request to legacy platform and rewrites base path if needed', async () => { - await server.start({ - ...config, - basePath: '/bar', - rewriteBasePath: true, - }); - - await supertest(getServerListener(server)) - .get('/legacy') - .expect(404); - - await supertest(getServerListener(server)) - .get('/bar/legacy') - .expect(200) - .then(res => { - expect(res.body).toEqual({ key: 'legacy-platform:/legacy' }); - expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledTimes(1); - expect(newPlatformProxyListenerMock.proxy).toHaveBeenCalledWith( - expect.any((http as any).IncomingMessage), - expect.any((http as any).ServerResponse) - ); - }); - }); - - test('do not forward request to legacy platform if new one can handle it', async () => { - await server.start(config); - - await supertest(getServerListener(server)) - .get('/new/') - .expect(200) - .then(res => { - expect(res.body).toEqual({ key: 'new-platform' }); - expect(newPlatformProxyListenerMock.proxy).not.toHaveBeenCalled(); - }); - }); + expect(innerServer).toBeDefined(); + expect(innerServer).toBe((server as any).server); + expect(options).toMatchSnapshot(); }); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index ae02018c435456..c828ff4df5408e 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -17,20 +17,24 @@ * under the License. */ -import { Server } from 'hapi-latest'; +import { Server, ServerOptions } from 'hapi-latest'; import { modifyUrl } from '../../utils'; -import { Env } from '../config'; import { Logger } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getServerOptions } from './http_tools'; import { Router } from './router'; +export interface HttpServerInfo { + server: Server; + options: ServerOptions; +} + export class HttpServer { private server?: Server; private registeredRouters: Set = new Set(); - constructor(private readonly log: Logger, private readonly env: Env) {} + constructor(private readonly log: Logger) {} public isListening() { return this.server !== undefined && this.server.listener.listening; @@ -45,7 +49,10 @@ export class HttpServer { } public async start(config: HttpConfig) { - this.server = createServer(getServerOptions(config)); + this.log.debug('starting http server'); + + const serverOptions = getServerOptions(config); + this.server = createServer(serverOptions); this.setupBasePathRewrite(this.server, config); @@ -59,49 +66,28 @@ export class HttpServer { } } - const legacyKbnServer = this.env.getLegacyKbnServer(); - if (legacyKbnServer !== undefined) { - legacyKbnServer.newPlatformProxyListener.bind(this.server.listener); - - // We register Kibana proxy middleware right before we start server to allow - // all new platform plugins register their routes, so that `legacyKbnServer` - // handles only requests that aren't handled by the new platform. - this.server.route({ - handler: ({ raw: { req, res } }, responseToolkit) => { - legacyKbnServer.newPlatformProxyListener.proxy(req, res); - return responseToolkit.abandon; - }, - method: '*', - options: { - payload: { - output: 'stream', - parse: false, - timeout: false, - // Having such a large value here will allow legacy routes to override - // maximum allowed payload size set in the core http server if needed. - maxBytes: Number.MAX_SAFE_INTEGER, - }, - }, - path: '/{p*}', - }); - } - await this.server.start(); - this.log.info( - `Server running at ${this.server.info.uri}${config.rewriteBasePath ? config.basePath : ''}`, - // The "legacy" Kibana will output log records with `listening` tag even if `quiet` logging mode is enabled. - { tags: ['listening'] } + this.log.debug( + `http server running at ${this.server.info.uri}${ + config.rewriteBasePath ? config.basePath : '' + }` ); + + // Return server instance with the connection options so that we can properly + // bridge core and the "legacy" Kibana internally. Once this bridge isn't + // needed anymore we shouldn't return anything from this method. + return { server: this.server, options: serverOptions }; } public async stop() { - this.log.info('stopping http server'); - - if (this.server !== undefined) { - await this.server.stop(); - this.server = undefined; + if (this.server === undefined) { + return; } + + this.log.debug('stopping http server'); + await this.server.stop(); + this.server = undefined; } private setupBasePathRewrite(server: Server, config: HttpConfig) { diff --git a/src/core/server/http/__tests__/http_service.test.ts b/src/core/server/http/http_service.test.ts similarity index 78% rename from src/core/server/http/__tests__/http_service.test.ts rename to src/core/server/http/http_service.test.ts index 0cacad88174686..a42ac26745c605 100644 --- a/src/core/server/http/__tests__/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -17,22 +17,16 @@ * under the License. */ -import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; - const mockHttpServer = jest.fn(); -jest.mock('../http_server', () => ({ +jest.mock('./http_server', () => ({ HttpServer: mockHttpServer, })); import { noop } from 'lodash'; import { BehaviorSubject } from 'rxjs'; - -import { Env } from '../../config'; -import { logger } from '../../logging/__mocks__'; -import { HttpConfig } from '../http_config'; -import { HttpService } from '../http_service'; -import { Router } from '../router'; +import { HttpConfig, HttpService, Router } from '.'; +import { logger } from '../logging/__mocks__'; beforeEach(() => { logger.mockClear(); @@ -55,11 +49,7 @@ test('creates and starts http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.start).not.toHaveBeenCalled(); @@ -81,11 +71,7 @@ test('logs error if already started', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); await service.start(); @@ -104,11 +90,7 @@ test('stops http server', async () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); await service.start(); @@ -132,11 +114,7 @@ test('register route handler', () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); const router = new Router('/foo'); service.registerRouter(router); @@ -159,11 +137,7 @@ test('throws if registering route handler after http server is started', () => { }; mockHttpServer.mockImplementation(() => httpServer); - const service = new HttpService( - config$.asObservable(), - logger, - new Env('/kibana', getEnvOptions()) - ); + const service = new HttpService(config$.asObservable(), logger); const router = new Router('/foo'); service.registerRouter(router); @@ -171,3 +145,20 @@ test('throws if registering route handler after http server is started', () => { expect(httpServer.registerRouter).toHaveBeenCalledTimes(0); expect(logger.mockCollect()).toMatchSnapshot(); }); + +test('returns http server contract on start', async () => { + const httpServerContract = { + server: {}, + options: { someOption: true }, + }; + + mockHttpServer.mockImplementation(() => ({ + isListening: () => false, + start: jest.fn().mockReturnValue(httpServerContract), + stop: noop, + })); + + const service = new HttpService(new BehaviorSubject({ ssl: {} } as HttpConfig), logger); + + expect(await service.start()).toBe(httpServerContract); +}); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index 3caae18e857b34..6972dfffbb1dd1 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -21,24 +21,23 @@ import { Observable, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import { CoreService } from '../../types/core_service'; -import { Env } from '../config'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; -import { HttpServer } from './http_server'; +import { HttpServer, HttpServerInfo } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; import { Router } from './router'; -export class HttpService implements CoreService { +export class HttpService implements CoreService { private readonly httpServer: HttpServer; private readonly httpsRedirectServer: HttpsRedirectServer; private configSubscription?: Subscription; private readonly log: Logger; - constructor(private readonly config$: Observable, logger: LoggerFactory, env: Env) { + constructor(private readonly config$: Observable, logger: LoggerFactory) { this.log = logger.get('http'); - this.httpServer = new HttpServer(logger.get('http', 'server'), env); + this.httpServer = new HttpServer(logger.get('http', 'server')); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -61,7 +60,7 @@ export class HttpService implements CoreService { await this.httpsRedirectServer.start(config); } - await this.httpServer.start(config); + return await this.httpServer.start(config); } public async stop() { diff --git a/src/core/server/http/__tests__/https_redirect_server.test.ts b/src/core/server/http/https_redirect_server.test.ts similarity index 92% rename from src/core/server/http/__tests__/https_redirect_server.test.ts rename to src/core/server/http/https_redirect_server.test.ts index c92691a679ef06..6d9443335a62b6 100644 --- a/src/core/server/http/__tests__/https_redirect_server.test.ts +++ b/src/core/server/http/https_redirect_server.test.ts @@ -25,10 +25,10 @@ import Chance from 'chance'; import { Server } from 'http'; import supertest from 'supertest'; -import { ByteSizeValue } from '../../config/schema'; -import { logger } from '../../logging/__mocks__'; -import { HttpConfig } from '../http_config'; -import { HttpsRedirectServer } from '../https_redirect_server'; +import { HttpConfig } from '.'; +import { ByteSizeValue } from '../config/schema'; +import { logger } from '../logging/__mocks__'; +import { HttpsRedirectServer } from './https_redirect_server'; const chance = new Chance(); diff --git a/src/core/server/http/https_redirect_server.ts b/src/core/server/http/https_redirect_server.ts index 9a77c63f1b85b1..b2664c5e24b550 100644 --- a/src/core/server/http/https_redirect_server.ts +++ b/src/core/server/http/https_redirect_server.ts @@ -30,6 +30,8 @@ export class HttpsRedirectServer { constructor(private readonly log: Logger) {} public async start(config: HttpConfig) { + this.log.debug('starting http --> https redirect server'); + if (!config.ssl.enabled || config.ssl.redirectHttpFromPort === undefined) { throw new Error( 'Redirect server cannot be started when [ssl.enabled] is set to `false`' + @@ -37,10 +39,6 @@ export class HttpsRedirectServer { ); } - this.log.info( - `starting HTTP --> HTTPS redirect server [${config.host}:${config.ssl.redirectHttpFromPort}]` - ); - // Redirect server is configured in the same way as any other HTTP server // within the platform with the only exception that it should always be a // plain HTTP server, so we just ignore `tls` part of options. @@ -65,6 +63,7 @@ export class HttpsRedirectServer { try { await this.server.start(); + this.log.debug(`http --> https redirect server running at ${this.server.info.uri}`); } catch (err) { if (err.code === 'EADDRINUSE') { throw new Error( @@ -79,11 +78,12 @@ export class HttpsRedirectServer { } public async stop() { - this.log.info('stopping HTTPS redirect server'); - - if (this.server !== undefined) { - await this.server.stop(); - this.server = undefined; + if (this.server === undefined) { + return; } + + this.log.debug('stopping http --> https redirect server'); + await this.server.stop(); + this.server = undefined; } } diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index e636fcd801eb53..3fd37150834169 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -19,20 +19,26 @@ import { Observable } from 'rxjs'; -import { Env } from '../config'; import { LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { HttpService } from './http_service'; +import { Router } from './router'; export { Router, KibanaRequest } from './router'; export { HttpService }; +export { HttpServerInfo } from './http_server'; +export { BasePathProxyServer } from './base_path_proxy_server'; export { HttpConfig }; export class HttpModule { public readonly service: HttpService; - constructor(readonly config$: Observable, logger: LoggerFactory, env: Env) { - this.service = new HttpService(this.config$, logger, env); + constructor(readonly config$: Observable, logger: LoggerFactory) { + this.service = new HttpService(this.config$, logger); + + const router = new Router('/core'); + router.get({ path: '/', validate: false }, async (req, res) => res.ok({ version: '0.0.1' })); + this.service.registerRouter(router); } } diff --git a/src/core/server/index.test.ts b/src/core/server/index.test.ts new file mode 100644 index 00000000000000..d6bd19d36ce3c0 --- /dev/null +++ b/src/core/server/index.test.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. + */ + +const mockHttpService = { start: jest.fn(), stop: jest.fn(), registerRouter: jest.fn() }; +jest.mock('./http/http_service', () => ({ + HttpService: jest.fn(() => mockHttpService), +})); + +const mockLegacyService = { start: jest.fn(), stop: jest.fn() }; +jest.mock('./legacy_compat/legacy_service', () => ({ + LegacyService: jest.fn(() => mockLegacyService), +})); + +import { BehaviorSubject } from 'rxjs'; +import { Server } from '.'; +import { Env } from './config'; +import { getEnvOptions } from './config/__mocks__/env'; +import { logger } from './logging/__mocks__'; + +const mockConfigService = { atPath: jest.fn(), getUnusedPaths: jest.fn().mockReturnValue([]) }; +const env = new Env('.', getEnvOptions()); + +beforeEach(() => { + mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); +}); + +afterEach(() => { + logger.mockClear(); + mockConfigService.atPath.mockReset(); + mockHttpService.start.mockReset(); + mockHttpService.stop.mockReset(); + mockLegacyService.start.mockReset(); + mockLegacyService.stop.mockReset(); +}); + +test('starts services on "start"', async () => { + const mockHttpServiceStartContract = { something: true }; + mockHttpService.start.mockReturnValue(Promise.resolve(mockHttpServiceStartContract)); + + const server = new Server(mockConfigService as any, logger, env); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(mockHttpServiceStartContract); +}); + +test('does not fail on "start" if there are unused paths detected', async () => { + mockConfigService.getUnusedPaths.mockReturnValue(['some.path', 'another.path']); + + const server = new Server(mockConfigService as any, logger, env); + await expect(server.start()).resolves.toBeUndefined(); + expect(logger.mockCollect()).toMatchSnapshot('unused paths logs'); +}); + +test('does not start http service is `autoListen:false`', async () => { + mockConfigService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: false })); + + const server = new Server(mockConfigService as any, logger, env); + + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(undefined); +}); + +test('does not start http service if process is dev cluster master', async () => { + const server = new Server( + mockConfigService as any, + logger, + new Env('.', getEnvOptions({ isDevClusterMaster: true })) + ); + + expect(mockLegacyService.start).not.toHaveBeenCalled(); + + await server.start(); + + expect(mockHttpService.start).not.toHaveBeenCalled(); + expect(mockLegacyService.start).toHaveBeenCalledTimes(1); + expect(mockLegacyService.start).toHaveBeenCalledWith(undefined); +}); + +test('stops services on "stop"', async () => { + const mockHttpServiceStartContract = { something: true }; + mockHttpService.start.mockReturnValue(Promise.resolve(mockHttpServiceStartContract)); + + const server = new Server(mockConfigService as any, logger, env); + + await server.start(); + + expect(mockHttpService.stop).not.toHaveBeenCalled(); + expect(mockLegacyService.stop).not.toHaveBeenCalled(); + + await server.stop(); + + expect(mockHttpService.stop).toHaveBeenCalledTimes(1); + expect(mockLegacyService.stop).toHaveBeenCalledTimes(1); +}); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 27d9a12081897b..ac645b22800417 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -17,39 +17,59 @@ * under the License. */ +export { bootstrap } from './bootstrap'; + +import { first } from 'rxjs/operators'; import { ConfigService, Env } from './config'; -import { HttpConfig, HttpModule, Router } from './http'; +import { HttpConfig, HttpModule, HttpServerInfo } from './http'; +import { LegacyCompatModule } from './legacy_compat'; import { Logger, LoggerFactory } from './logging'; export class Server { private readonly http: HttpModule; + private readonly legacy: LegacyCompatModule; private readonly log: Logger; - constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) { + constructor( + private readonly configService: ConfigService, + logger: LoggerFactory, + private readonly env: Env + ) { this.log = logger.get('server'); - const httpConfig$ = configService.atPath('server', HttpConfig); - this.http = new HttpModule(httpConfig$, logger, env); + this.http = new HttpModule(configService.atPath('server', HttpConfig), logger); + this.legacy = new LegacyCompatModule(configService, logger, env); } public async start() { - this.log.debug('starting server :tada:'); + this.log.debug('starting server'); - const router = new Router('/core'); - router.get({ path: '/', validate: false }, async (req, res) => res.ok({ version: '0.0.1' })); - this.http.service.registerRouter(router); + // We shouldn't start http service in two cases: + // 1. If `server.autoListen` is explicitly set to `false`. + // 2. When the process is run as dev cluster master in which case cluster manager + // will fork a dedicated process where http service will be started instead. + let httpServerInfo: HttpServerInfo | undefined; + const httpConfig = await this.http.config$.pipe(first()).toPromise(); + if (!this.env.isDevClusterMaster && httpConfig.autoListen) { + httpServerInfo = await this.http.service.start(); + } - await this.http.service.start(); + await this.legacy.service.start(httpServerInfo); const unhandledConfigPaths = await this.configService.getUnusedPaths(); if (unhandledConfigPaths.length > 0) { - throw new Error(`some config paths are not handled: ${JSON.stringify(unhandledConfigPaths)}`); + // We don't throw here since unhandled paths are verified by the "legacy" + // Kibana right now, but this will eventually change. + this.log.trace( + `some config paths are not handled by the core: ${JSON.stringify(unhandledConfigPaths)}` + ); } } public async stop() { this.log.debug('stopping server'); + await this.legacy.service.stop(); await this.http.service.stop(); } } diff --git a/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts b/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts deleted file mode 100644 index ed1b7c04625756..00000000000000 --- a/src/core/server/legacy_compat/__mocks__/legacy_config_mock.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * This is a partial mock of src/server/config/config.js. - */ -export class LegacyConfigMock { - public readonly set = jest.fn((key, value) => { - // Real legacy config throws error if key is not presented in the schema. - if (!this.rawData.has(key)) { - throw new TypeError(`Unknown schema key: ${key}`); - } - - this.rawData.set(key, value); - }); - - public readonly get = jest.fn(key => { - // Real legacy config throws error if key is not presented in the schema. - if (!this.rawData.has(key)) { - throw new TypeError(`Unknown schema key: ${key}`); - } - - return this.rawData.get(key); - }); - - public readonly has = jest.fn(key => this.rawData.has(key)); - - constructor(public rawData: Map = new Map()) {} -} diff --git a/src/core/server/legacy_compat/__snapshots__/legacy_service.test.ts.snap b/src/core/server/legacy_compat/__snapshots__/legacy_service.test.ts.snap new file mode 100644 index 00000000000000..99e6a29a8c5ee4 --- /dev/null +++ b/src/core/server/legacy_compat/__snapshots__/legacy_service.test.ts.snap @@ -0,0 +1,143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`once LegacyService is started in \`devClusterMaster\` mode creates ClusterManager with base path proxy.: cluster manager with base path proxy 1`] = ` +Array [ + Array [ + Object { + "basePath": true, + "dev": true, + "quiet": true, + "repl": false, + "silent": false, + "watch": false, + }, + Object { + "server": Object { + "autoListen": true, + }, + }, + BasePathProxyServer { + "devConfig": Object { + "basePathProxyTargetPort": 100500, + }, + "httpConfig": Object { + "basePath": "/abc", + "maxPayload": ByteSizeValue { + "valueInBytes": 1073741824, + }, + }, + "log": Object { + "debug": [MockFunction] { + "calls": Array [ + Array [ + "starting legacy service", + ], + ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + }, + ], +] +`; + +exports[`once LegacyService is started in \`devClusterMaster\` mode creates ClusterManager without base path proxy.: cluster manager without base path proxy 1`] = ` +Array [ + Array [ + Object { + "basePath": false, + "dev": true, + "quiet": false, + "repl": false, + "silent": true, + "watch": false, + }, + Object { + "server": Object { + "autoListen": true, + }, + }, + undefined, + ], +] +`; + +exports[`once LegacyService is started with connection info creates legacy kbnServer and closes it if \`listen\` fails. 1`] = `"something failed"`; + +exports[`once LegacyService is started with connection info proxy route responds with \`503\` if \`kbnServer\` is not ready yet.: 503 response 1`] = ` +Object { + "body": Array [ + Array [ + "Kibana server is not ready yet", + ], + ], + "code": Array [ + Array [ + 503, + ], + ], + "header": Array [ + Array [ + "Retry-After", + "30", + ], + ], +} +`; + +exports[`once LegacyService is started with connection info reconfigures logging configuration if new config is received.: applyLoggingConfiguration params 1`] = ` +Array [ + Array [ + Object { + "logging": Object { + "verbose": true, + }, + }, + ], +] +`; + +exports[`once LegacyService is started with connection info register proxy route.: proxy route options 1`] = ` +Array [ + Array [ + Object { + "handler": [Function], + "method": "*", + "options": Object { + "payload": Object { + "maxBytes": 9007199254740991, + "output": "stream", + "parse": false, + "timeout": false, + }, + }, + "path": "/{p*}", + }, + ], +] +`; + +exports[`once LegacyService is started with connection info throws if fails to retrieve initial config. 1`] = `"something failed"`; + +exports[`once LegacyService is started without connection info reconfigures logging configuration if new config is received.: applyLoggingConfiguration params 1`] = ` +Array [ + Array [ + Object { + "logging": Object { + "verbose": true, + }, + }, + ], +] +`; diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap deleted file mode 100644 index 527f2154eb250b..00000000000000 --- a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_config.test.ts.snap +++ /dev/null @@ -1,74 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#get correctly handles paths that do not exist in legacy config. 1`] = `"Unknown schema key: one"`; - -exports[`#get correctly handles paths that do not exist in legacy config. 2`] = `"Unknown schema key: one.two"`; - -exports[`#get correctly handles paths that do not exist in legacy config. 3`] = `"Unknown schema key: one.three"`; - -exports[`#get correctly handles silent logging config. 1`] = ` -Object { - "appenders": Object { - "default": Object { - "kind": "legacy-appender", - "legacyLoggingConfig": Object { - "silent": true, - }, - }, - }, - "root": Object { - "level": "off", - }, -} -`; - -exports[`#get correctly handles verbose file logging config with json format. 1`] = ` -Object { - "appenders": Object { - "default": Object { - "kind": "legacy-appender", - "legacyLoggingConfig": Object { - "dest": "/some/path.log", - "json": true, - "verbose": true, - }, - }, - }, - "root": Object { - "level": "all", - }, -} -`; - -exports[`#set correctly sets values for new platform config. 1`] = ` -Object { - "plugins": Object { - "scanDirs": Array [ - "bar", - ], - }, -} -`; - -exports[`#set correctly sets values for new platform config. 2`] = ` -Object { - "plugins": Object { - "scanDirs": Array [ - "baz", - ], - }, -} -`; - -exports[`#set tries to set values for paths that do not exist in legacy config. 1`] = `"Unknown schema key: unknown"`; - -exports[`#set tries to set values for paths that do not exist in legacy config. 2`] = `"Unknown schema key: unknown.sub1"`; - -exports[`#set tries to set values for paths that do not exist in legacy config. 3`] = `"Unknown schema key: unknown.sub2"`; - -exports[`\`getFlattenedPaths\` returns paths from new platform config only. 1`] = ` -Array [ - "__newPlatform.known", - "__newPlatform.known2.sub", -] -`; diff --git a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap b/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap deleted file mode 100644 index 41d10685923dfb..00000000000000 --- a/src/core/server/legacy_compat/__tests__/__snapshots__/legacy_platform_proxifier.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`correctly unbinds from the previous server. 1`] = `"Unhandled \\"error\\" event. (Error: Some error)"`; diff --git a/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts b/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts deleted file mode 100644 index f15baebcb2434a..00000000000000 --- a/src/core/server/legacy_compat/__tests__/legacy_platform_config.test.ts +++ /dev/null @@ -1,170 +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 { LegacyConfigToRawConfigAdapter } from '..'; -import { LegacyConfigMock } from '../__mocks__/legacy_config_mock'; - -let legacyConfigMock: LegacyConfigMock; -let configAdapter: LegacyConfigToRawConfigAdapter; -beforeEach(() => { - legacyConfigMock = new LegacyConfigMock(new Map([['__newPlatform', null]])); - configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); -}); - -describe('#get', () => { - test('correctly handles paths that do not exist in legacy config.', () => { - expect(() => configAdapter.get('one')).toThrowErrorMatchingSnapshot(); - expect(() => configAdapter.get(['one', 'two'])).toThrowErrorMatchingSnapshot(); - expect(() => configAdapter.get(['one.three'])).toThrowErrorMatchingSnapshot(); - }); - - test('returns undefined for new platform config values, even if they do not exist', () => { - expect(configAdapter.get(['__newPlatform', 'plugins'])).toBe(undefined); - }); - - test('returns new platform config values if they exist', () => { - configAdapter = new LegacyConfigToRawConfigAdapter( - new LegacyConfigMock( - new Map([['__newPlatform', { plugins: { scanDirs: ['foo'] } }]]) - ) - ); - expect(configAdapter.get(['__newPlatform', 'plugins'])).toEqual({ - scanDirs: ['foo'], - }); - expect(configAdapter.get('__newPlatform.plugins')).toEqual({ - scanDirs: ['foo'], - }); - }); - - test('correctly handles paths that do not need to be transformed.', () => { - legacyConfigMock.rawData = new Map([ - ['one', 'value-one'], - ['one.sub', 'value-one-sub'], - ['container', { value: 'some' }], - ]); - - expect(configAdapter.get('one')).toEqual('value-one'); - expect(configAdapter.get(['one', 'sub'])).toEqual('value-one-sub'); - expect(configAdapter.get('one.sub')).toEqual('value-one-sub'); - expect(configAdapter.get('container')).toEqual({ value: 'some' }); - }); - - test('correctly handles silent logging config.', () => { - legacyConfigMock.rawData = new Map([['logging', { silent: true }]]); - - expect(configAdapter.get('logging')).toMatchSnapshot(); - }); - - test('correctly handles verbose file logging config with json format.', () => { - legacyConfigMock.rawData = new Map([ - ['logging', { verbose: true, json: true, dest: '/some/path.log' }], - ]); - - expect(configAdapter.get('logging')).toMatchSnapshot(); - }); -}); - -describe('#set', () => { - test('tries to set values for paths that do not exist in legacy config.', () => { - expect(() => configAdapter.set('unknown', 'value')).toThrowErrorMatchingSnapshot(); - - expect(() => - configAdapter.set(['unknown', 'sub1'], 'sub-value-1') - ).toThrowErrorMatchingSnapshot(); - - expect(() => configAdapter.set('unknown.sub2', 'sub-value-2')).toThrowErrorMatchingSnapshot(); - }); - - test('correctly sets values for existing paths.', () => { - legacyConfigMock.rawData = new Map([['known', ''], ['known.sub1', ''], ['known.sub2', '']]); - - configAdapter.set('known', 'value'); - configAdapter.set(['known', 'sub1'], 'sub-value-1'); - configAdapter.set('known.sub2', 'sub-value-2'); - - expect(legacyConfigMock.rawData.get('known')).toEqual('value'); - expect(legacyConfigMock.rawData.get('known.sub1')).toEqual('sub-value-1'); - expect(legacyConfigMock.rawData.get('known.sub2')).toEqual('sub-value-2'); - }); - - test('correctly sets values for new platform config.', () => { - legacyConfigMock.rawData = new Map([ - ['__newPlatform', { plugins: { scanDirs: ['foo'] } }], - ]); - - configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); - - configAdapter.set(['__newPlatform', 'plugins', 'scanDirs'], ['bar']); - expect(legacyConfigMock.rawData.get('__newPlatform')).toMatchSnapshot(); - - configAdapter.set('__newPlatform.plugins.scanDirs', ['baz']); - expect(legacyConfigMock.rawData.get('__newPlatform')).toMatchSnapshot(); - }); -}); - -describe('#has', () => { - test('returns false if config is not set', () => { - expect(configAdapter.has('unknown')).toBe(false); - expect(configAdapter.has(['unknown', 'sub1'])).toBe(false); - expect(configAdapter.has('unknown.sub2')).toBe(false); - }); - - test('returns false if new platform config is not set', () => { - expect(configAdapter.has('__newPlatform.unknown')).toBe(false); - expect(configAdapter.has(['__newPlatform', 'unknown'])).toBe(false); - }); - - test('returns true if config is set.', () => { - legacyConfigMock.rawData = new Map([ - ['known', 'foo'], - ['known.sub1', 'bar'], - ['known.sub2', 'baz'], - ]); - - expect(configAdapter.has('known')).toBe(true); - expect(configAdapter.has(['known', 'sub1'])).toBe(true); - expect(configAdapter.has('known.sub2')).toBe(true); - }); - - test('returns true if new platform config is set.', () => { - legacyConfigMock.rawData = new Map([ - ['__newPlatform', { known: 'foo', known2: { sub: 'bar' } }], - ]); - - configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); - - expect(configAdapter.has('__newPlatform.known')).toBe(true); - expect(configAdapter.has('__newPlatform.known2')).toBe(true); - expect(configAdapter.has('__newPlatform.known2.sub')).toBe(true); - expect(configAdapter.has(['__newPlatform', 'known'])).toBe(true); - expect(configAdapter.has(['__newPlatform', 'known2'])).toBe(true); - expect(configAdapter.has(['__newPlatform', 'known2', 'sub'])).toBe(true); - }); -}); - -test('`getFlattenedPaths` returns paths from new platform config only.', () => { - legacyConfigMock.rawData = new Map([ - ['__newPlatform', { known: 'foo', known2: { sub: 'bar' } }], - ['legacy', { known: 'baz' }], - ]); - - configAdapter = new LegacyConfigToRawConfigAdapter(legacyConfigMock); - - expect(configAdapter.getFlattenedPaths()).toMatchSnapshot(); -}); diff --git a/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts b/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts deleted file mode 100644 index a441b81bd171e7..00000000000000 --- a/src/core/server/legacy_compat/__tests__/legacy_platform_proxifier.test.ts +++ /dev/null @@ -1,200 +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 { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; - -class MockNetServer extends EventEmitter { - public address() { - return { port: 1234, family: 'test-family', address: 'test-address' }; - } - - public getConnections(callback: (error: Error | null, count: number) => void) { - callback(null, 100500); - } -} - -function mockNetServer() { - return new MockNetServer(); -} - -jest.mock('net', () => ({ - createServer: jest.fn(() => mockNetServer()), -})); - -import { createServer } from 'net'; -import { LegacyPlatformProxifier } from '..'; - -let root: any; -let proxifier: LegacyPlatformProxifier; -beforeEach(() => { - root = { - logger: { - get: jest.fn(() => ({ - debug: jest.fn(), - info: jest.fn(), - })), - }, - shutdown: jest.fn(), - start: jest.fn(), - } as any; - - proxifier = new LegacyPlatformProxifier(root); -}); - -test('correctly binds to the server.', () => { - const server = createServer(); - jest.spyOn(server, 'addListener'); - proxifier.bind(server); - - expect(server.addListener).toHaveBeenCalledTimes(4); - for (const eventName of ['listening', 'error', 'clientError', 'connection']) { - expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function)); - } -}); - -test('correctly binds to the server and redirects its events.', () => { - const server = createServer(); - proxifier.bind(server); - - const eventsAndListeners = new Map( - ['listening', 'error', 'clientError', 'connection'].map(eventName => { - const listener = jest.fn(); - proxifier.addListener(eventName, listener); - - return [eventName, listener] as [string, () => void]; - }) - ); - - for (const [eventName, listener] of eventsAndListeners) { - expect(listener).not.toHaveBeenCalled(); - - // Emit several events, to make sure that server is not being listened with `once`. - server.emit(eventName, 1, 2, 3, 4); - server.emit(eventName, 5, 6, 7, 8); - - expect(listener).toHaveBeenCalledTimes(2); - expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); - expect(listener).toHaveBeenCalledWith(5, 6, 7, 8); - } -}); - -test('correctly unbinds from the previous server.', () => { - const previousServer = createServer(); - proxifier.bind(previousServer); - - const currentServer = createServer(); - proxifier.bind(currentServer); - - const eventsAndListeners = new Map( - ['listening', 'error', 'clientError', 'connection'].map(eventName => { - const listener = jest.fn(); - proxifier.addListener(eventName, listener); - - return [eventName, listener] as [string, () => void]; - }) - ); - - // Any events from the previous server should not be forwarded. - for (const [eventName, listener] of eventsAndListeners) { - // `error` event is a special case in node, if `error` is emitted, but - // there is no listener for it error will be thrown. - if (eventName === 'error') { - expect(() => - previousServer.emit(eventName, new Error('Some error')) - ).toThrowErrorMatchingSnapshot(); - } else { - previousServer.emit(eventName, 1, 2, 3, 4); - } - - expect(listener).not.toHaveBeenCalled(); - } - - // Only events from the last server should be forwarded. - for (const [eventName, listener] of eventsAndListeners) { - expect(listener).not.toHaveBeenCalled(); - - currentServer.emit(eventName, 1, 2, 3, 4); - - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); - } -}); - -test('returns `address` from the underlying server.', () => { - expect(proxifier.address()).toBeUndefined(); - - proxifier.bind(createServer()); - - expect(proxifier.address()).toEqual({ - address: 'test-address', - family: 'test-family', - port: 1234, - }); -}); - -test('`listen` starts the `root`.', async () => { - const onListenComplete = jest.fn(); - - await proxifier.listen(1234, 'host-1', onListenComplete); - - expect(root.start).toHaveBeenCalledTimes(1); - expect(onListenComplete).toHaveBeenCalledTimes(1); -}); - -test('`close` shuts down the `root`.', async () => { - const onCloseComplete = jest.fn(); - - await proxifier.close(onCloseComplete); - - expect(root.shutdown).toHaveBeenCalledTimes(1); - expect(onCloseComplete).toHaveBeenCalledTimes(1); -}); - -test('returns connection count from the underlying server.', () => { - const onGetConnectionsComplete = jest.fn(); - - proxifier.getConnections(onGetConnectionsComplete); - - expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); - expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 0); - onGetConnectionsComplete.mockReset(); - - proxifier.bind(createServer()); - proxifier.getConnections(onGetConnectionsComplete); - - expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); - expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 100500); -}); - -test('correctly proxies request and response objects.', () => { - const onRequest = jest.fn(); - proxifier.addListener('request', onRequest); - - const request = {} as IncomingMessage; - const response = {} as ServerResponse; - proxifier.proxy(request, response); - - expect(onRequest).toHaveBeenCalledTimes(1); - expect(onRequest).toHaveBeenCalledWith(request, response); - - // Check that exactly same objects were passed as event arguments. - expect(onRequest.mock.calls[0][0]).toBe(request); - expect(onRequest.mock.calls[0][1]).toBe(response); -}); diff --git a/src/core/server/legacy_compat/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/src/core/server/legacy_compat/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap new file mode 100644 index 00000000000000..af2bfff0abfe39 --- /dev/null +++ b/src/core/server/legacy_compat/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#get correctly handles server config. 1`] = ` +Object { + "autoListen": true, + "basePath": "/abc", + "cors": false, + "host": "host", + "maxPayload": 1000, + "port": 1234, + "rewriteBasePath": false, + "ssl": Object { + "enabled": true, + "keyPassphrase": "some-phrase", + "someNewValue": "new", + }, +} +`; + +exports[`#get correctly handles silent logging config. 1`] = ` +Object { + "appenders": Object { + "default": Object { + "kind": "legacy-appender", + "legacyLoggingConfig": Object { + "silent": true, + }, + }, + }, + "root": Object { + "level": "off", + }, +} +`; + +exports[`#get correctly handles verbose file logging config with json format. 1`] = ` +Object { + "appenders": Object { + "default": Object { + "kind": "legacy-appender", + "legacyLoggingConfig": Object { + "dest": "/some/path.log", + "json": true, + "verbose": true, + }, + }, + }, + "root": Object { + "level": "all", + }, +} +`; + +exports[`#getFlattenedPaths returns all paths of the underlying object. 1`] = ` +Array [ + "known", + "knownContainer.sub1", + "knownContainer.sub2", + "legacy.known", +] +`; + +exports[`#set correctly sets values for existing paths. 1`] = ` +Object { + "known": "value", + "knownContainer": Object { + "sub1": "sub-value-1", + "sub2": "sub-value-2", + }, +} +`; + +exports[`#set correctly sets values for paths that do not exist. 1`] = ` +Object { + "unknown": "value", +} +`; + +exports[`#toRaw returns a deep copy of the underlying raw config object. 1`] = ` +Object { + "known": "foo", + "knownContainer": Object { + "sub1": "bar", + "sub2": "baz", + }, + "legacy": Object { + "known": "baz", + }, +} +`; + +exports[`#toRaw returns a deep copy of the underlying raw config object. 2`] = ` +Object { + "known": "bar", + "knownContainer": Object { + "sub1": "baz", + "sub2": "baz", + }, + "legacy": Object { + "known": "baz", + }, +} +`; diff --git a/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.test.ts b/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.test.ts new file mode 100644 index 00000000000000..afa3cf03fe9a9d --- /dev/null +++ b/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; + +describe('#get', () => { + test('correctly handles paths that do not exist.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({}); + + expect(configAdapter.get('one')).not.toBeDefined(); + expect(configAdapter.get(['one', 'two'])).not.toBeDefined(); + expect(configAdapter.get(['one.three'])).not.toBeDefined(); + }); + + test('correctly handles paths that do not need to be transformed.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + one: 'value-one', + two: { + sub: 'value-two-sub', + }, + container: { + value: 'some', + }, + }); + + expect(configAdapter.get('one')).toEqual('value-one'); + expect(configAdapter.get(['two', 'sub'])).toEqual('value-two-sub'); + expect(configAdapter.get('two.sub')).toEqual('value-two-sub'); + expect(configAdapter.get('container')).toEqual({ value: 'some' }); + }); + + test('correctly handles silent logging config.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + logging: { silent: true }, + }); + + expect(configAdapter.get('logging')).toMatchSnapshot(); + }); + + test('correctly handles verbose file logging config with json format.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + logging: { verbose: true, json: true, dest: '/some/path.log' }, + }); + + expect(configAdapter.get('logging')).toMatchSnapshot(); + }); + + test('correctly handles server config.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + server: { + autoListen: true, + basePath: '/abc', + cors: false, + host: 'host', + maxPayloadBytes: 1000, + port: 1234, + rewriteBasePath: false, + ssl: { + enabled: true, + keyPassphrase: 'some-phrase', + someNewValue: 'new', + }, + someNotSupportedValue: 'val', + }, + }); + + expect(configAdapter.get('server')).toMatchSnapshot(); + }); +}); + +describe('#set', () => { + test('correctly sets values for paths that do not exist.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({}); + + configAdapter.set('unknown', 'value'); + configAdapter.set(['unknown', 'sub1'], 'sub-value-1'); + configAdapter.set('unknown.sub2', 'sub-value-2'); + + expect(configAdapter.toRaw()).toMatchSnapshot(); + }); + + test('correctly sets values for existing paths.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + known: '', + knownContainer: { + sub1: 'sub-1', + sub2: 'sub-2', + }, + }); + + configAdapter.set('known', 'value'); + configAdapter.set(['knownContainer', 'sub1'], 'sub-value-1'); + configAdapter.set('knownContainer.sub2', 'sub-value-2'); + + expect(configAdapter.toRaw()).toMatchSnapshot(); + }); +}); + +describe('#has', () => { + test('returns false if config is not set', () => { + const configAdapter = new LegacyObjectToConfigAdapter({}); + + expect(configAdapter.has('unknown')).toBe(false); + expect(configAdapter.has(['unknown', 'sub1'])).toBe(false); + expect(configAdapter.has('unknown.sub2')).toBe(false); + }); + + test('returns true if config is set.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + known: 'foo', + knownContainer: { + sub1: 'bar', + sub2: 'baz', + }, + }); + + expect(configAdapter.has('known')).toBe(true); + expect(configAdapter.has(['knownContainer', 'sub1'])).toBe(true); + expect(configAdapter.has('knownContainer.sub2')).toBe(true); + }); +}); + +describe('#toRaw', () => { + test('returns a deep copy of the underlying raw config object.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + known: 'foo', + knownContainer: { + sub1: 'bar', + sub2: 'baz', + }, + legacy: { known: 'baz' }, + }); + + const firstRawCopy = configAdapter.toRaw(); + + configAdapter.set('known', 'bar'); + configAdapter.set(['knownContainer', 'sub1'], 'baz'); + + const secondRawCopy = configAdapter.toRaw(); + + expect(firstRawCopy).not.toBe(secondRawCopy); + expect(firstRawCopy.knownContainer).not.toBe(secondRawCopy.knownContainer); + + expect(firstRawCopy).toMatchSnapshot(); + expect(secondRawCopy).toMatchSnapshot(); + }); +}); + +describe('#getFlattenedPaths', () => { + test('returns all paths of the underlying object.', () => { + const configAdapter = new LegacyObjectToConfigAdapter({ + known: 'foo', + knownContainer: { + sub1: 'bar', + sub2: 'baz', + }, + legacy: { known: 'baz' }, + }); + + expect(configAdapter.getFlattenedPaths()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.ts new file mode 100644 index 00000000000000..483e156f4697dd --- /dev/null +++ b/src/core/server/legacy_compat/config/legacy_object_to_config_adapter.ts @@ -0,0 +1,84 @@ +/* + * 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 { ConfigPath, ObjectToConfigAdapter } from '../../config'; + +/** + * Represents logging config supported by the legacy platform. + */ +interface LegacyLoggingConfig { + silent?: boolean; + verbose?: boolean; + quiet?: boolean; + dest?: string; + json?: boolean; + events?: Record; +} + +/** + * Represents adapter between config provided by legacy platform and `Config` + * supported by the current platform. + */ +export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { + private static transformLogging(configValue: LegacyLoggingConfig = {}) { + const loggingConfig = { + appenders: { + default: { kind: 'legacy-appender', legacyLoggingConfig: configValue }, + }, + root: { level: 'info' }, + }; + + if (configValue.silent) { + loggingConfig.root.level = 'off'; + } else if (configValue.quiet) { + loggingConfig.root.level = 'error'; + } else if (configValue.verbose) { + loggingConfig.root.level = 'all'; + } + + return loggingConfig; + } + + private static transformServer(configValue: any = {}) { + // TODO: New platform uses just a subset of `server` config from the legacy platform, + // new values will be exposed once we need them (eg. customResponseHeaders or xsrf). + return { + autoListen: configValue.autoListen, + basePath: configValue.basePath, + cors: configValue.cors, + host: configValue.host, + maxPayload: configValue.maxPayloadBytes, + port: configValue.port, + rewriteBasePath: configValue.rewriteBasePath, + ssl: configValue.ssl, + }; + } + + public get(configPath: ConfigPath) { + const configValue = super.get(configPath); + switch (configPath) { + case 'logging': + return LegacyObjectToConfigAdapter.transformLogging(configValue); + case 'server': + return LegacyObjectToConfigAdapter.transformServer(configValue); + default: + return configValue; + } + } +} diff --git a/src/core/server/legacy_compat/index.ts b/src/core/server/legacy_compat/index.ts index feed0001ee2161..3e10928aa34569 100644 --- a/src/core/server/legacy_compat/index.ts +++ b/src/core/server/legacy_compat/index.ts @@ -17,66 +17,17 @@ * under the License. */ -import { BehaviorSubject } from 'rxjs'; -import { map } from 'rxjs/operators'; -/** @internal */ -export { LegacyPlatformProxifier } from './legacy_platform_proxifier'; -/** @internal */ -export { LegacyConfigToRawConfigAdapter, LegacyConfig } from './legacy_platform_config'; -/** @internal */ -export { LegacyKbnServer } from './legacy_kbn_server'; +import { ConfigService, Env } from '../config'; +import { LoggerFactory } from '../logging'; +import { LegacyService } from './legacy_service'; -import { - LegacyConfig, - LegacyConfigToRawConfigAdapter, - LegacyKbnServer, - LegacyPlatformProxifier, -} from '.'; -import { Env } from '../config'; -import { Root } from '../root'; -import { BasePathProxyRoot } from '../root/base_path_proxy_root'; +export { LegacyObjectToConfigAdapter } from './config/legacy_object_to_config_adapter'; +export { LegacyService } from './legacy_service'; -function initEnvironment(rawKbnServer: any) { - const config: LegacyConfig = rawKbnServer.config; +export class LegacyCompatModule { + public readonly service: LegacyService; - const legacyConfig$ = new BehaviorSubject(config); - const config$ = legacyConfig$.pipe( - map(legacyConfig => new LegacyConfigToRawConfigAdapter(legacyConfig)) - ); - - const env = Env.createDefault({ - kbnServer: new LegacyKbnServer(rawKbnServer), - // The defaults for the following parameters are retrieved by the legacy - // platform from the command line or from `package.json` and stored in the - // config, so we can borrow these parameters and avoid double parsing. - mode: config.get('env'), - packageInfo: config.get('pkg'), - }); - - return { - config$, - env, - // Propagates legacy config updates to the new platform. - updateConfig(legacyConfig: LegacyConfig) { - legacyConfig$.next(legacyConfig); - }, - }; + constructor(private readonly configService: ConfigService, logger: LoggerFactory, env: Env) { + this.service = new LegacyService(env, logger, this.configService); + } } - -/** - * @internal - */ -export const injectIntoKbnServer = (rawKbnServer: any) => { - const { env, config$, updateConfig } = initEnvironment(rawKbnServer); - - rawKbnServer.newPlatform = { - // Custom HTTP Listener that will be used within legacy platform by HapiJS server. - proxyListener: new LegacyPlatformProxifier(new Root(config$, env)), - updateConfig, - }; -}; - -export const createBasePathProxy = (rawKbnServer: any) => { - const { env, config$ } = initEnvironment(rawKbnServer); - return new BasePathProxyRoot(config$, env); -}; diff --git a/src/core/server/legacy_compat/legacy_platform_config.ts b/src/core/server/legacy_compat/legacy_platform_config.ts deleted file mode 100644 index 47dc2082e2ccd3..00000000000000 --- a/src/core/server/legacy_compat/legacy_platform_config.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { NEW_PLATFORM_CONFIG_ROOT, ObjectToRawConfigAdapter, RawConfig } from '../config'; -import { ConfigPath } from '../config/config_service'; - -/** - * Represents legacy Kibana config class. - * @internal - */ -export interface LegacyConfig { - get: (configPath: string) => any; - set: (configPath: string, configValue: any) => void; - has: (configPath: string) => boolean; -} - -/** - * Represents logging config supported by the legacy platform. - */ -interface LegacyLoggingConfig { - silent?: boolean; - verbose?: boolean; - quiet?: boolean; - dest?: string; - json?: boolean; -} - -/** - * Represents adapter between config provided by legacy platform and `RawConfig` - * supported by the current platform. - */ -export class LegacyConfigToRawConfigAdapter implements RawConfig { - private static flattenConfigPath(configPath: ConfigPath) { - if (!Array.isArray(configPath)) { - return configPath; - } - - return configPath.join('.'); - } - - private static transformLogging(configValue: LegacyLoggingConfig) { - const loggingConfig = { - appenders: { - default: { kind: 'legacy-appender', legacyLoggingConfig: configValue }, - }, - root: { level: 'info' }, - }; - - if (configValue.silent) { - loggingConfig.root.level = 'off'; - } else if (configValue.quiet) { - loggingConfig.root.level = 'error'; - } else if (configValue.verbose) { - loggingConfig.root.level = 'all'; - } - - return loggingConfig; - } - - private static transformServer(configValue: any) { - // TODO: New platform uses just a subset of `server` config from the legacy platform, - // new values will be exposed once we need them (eg. customResponseHeaders, cors or xsrf). - return { - basePath: configValue.basePath, - cors: configValue.cors, - host: configValue.host, - maxPayload: configValue.maxPayloadBytes, - port: configValue.port, - rewriteBasePath: configValue.rewriteBasePath, - ssl: configValue.ssl, - }; - } - - private static isNewPlatformConfig(configPath: ConfigPath) { - if (Array.isArray(configPath)) { - return configPath[0] === NEW_PLATFORM_CONFIG_ROOT; - } - - return configPath.startsWith(NEW_PLATFORM_CONFIG_ROOT); - } - - private newPlatformConfig: ObjectToRawConfigAdapter; - - constructor(private readonly legacyConfig: LegacyConfig) { - this.newPlatformConfig = new ObjectToRawConfigAdapter({ - [NEW_PLATFORM_CONFIG_ROOT]: legacyConfig.get(NEW_PLATFORM_CONFIG_ROOT) || {}, - }); - } - - public has(configPath: ConfigPath) { - if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { - return this.newPlatformConfig.has(configPath); - } - - return this.legacyConfig.has(LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath)); - } - - public get(configPath: ConfigPath) { - if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { - return this.newPlatformConfig.get(configPath); - } - - configPath = LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath); - - const configValue = this.legacyConfig.get(configPath); - - switch (configPath) { - case 'logging': - return LegacyConfigToRawConfigAdapter.transformLogging(configValue); - case 'server': - return LegacyConfigToRawConfigAdapter.transformServer(configValue); - default: - return configValue; - } - } - - public set(configPath: ConfigPath, value: any) { - if (LegacyConfigToRawConfigAdapter.isNewPlatformConfig(configPath)) { - return this.newPlatformConfig.set(configPath, value); - } - - this.legacyConfig.set(LegacyConfigToRawConfigAdapter.flattenConfigPath(configPath), value); - } - - public getFlattenedPaths() { - // This method is only used to detect unused config paths, but when we run - // new platform within the legacy one then the new platform is in charge of - // only `__newPlatform` config node and the legacy platform will check the rest. - return this.newPlatformConfig.getFlattenedPaths(); - } -} diff --git a/src/core/server/legacy_compat/legacy_platform_proxy.test.ts b/src/core/server/legacy_compat/legacy_platform_proxy.test.ts new file mode 100644 index 00000000000000..cc7436ce32170a --- /dev/null +++ b/src/core/server/legacy_compat/legacy_platform_proxy.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { Server } from 'net'; + +import { LegacyPlatformProxy } from './legacy_platform_proxy'; + +let server: jest.Mocked; +let proxy: LegacyPlatformProxy; +beforeEach(() => { + server = { + addListener: jest.fn(), + address: jest + .fn() + .mockReturnValue({ port: 1234, family: 'test-family', address: 'test-address' }), + getConnections: jest.fn(), + } as any; + proxy = new LegacyPlatformProxy({ debug: jest.fn() } as any, server); +}); + +test('correctly redirects server events.', () => { + for (const eventName of ['clientError', 'close', 'connection', 'error', 'listening', 'upgrade']) { + expect(server.addListener).toHaveBeenCalledWith(eventName, expect.any(Function)); + + const listener = jest.fn(); + proxy.addListener(eventName, listener); + + // Emit several events, to make sure that server is not being listened with `once`. + const [, serverListener] = server.addListener.mock.calls.find( + ([serverEventName]) => serverEventName === eventName + )!; + + serverListener(1, 2, 3, 4); + serverListener(5, 6, 7, 8); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledWith(1, 2, 3, 4); + + proxy.removeListener(eventName, listener); + } +}); + +test('returns `address` from the underlying server.', () => { + expect(proxy.address()).toEqual({ + address: 'test-address', + family: 'test-family', + port: 1234, + }); +}); + +test('`listen` calls callback immediately.', async () => { + const onListenComplete = jest.fn(); + + await proxy.listen(1234, 'host-1', onListenComplete); + + expect(onListenComplete).toHaveBeenCalledTimes(1); +}); + +test('`close` calls callback immediately.', async () => { + const onCloseComplete = jest.fn(); + + await proxy.close(onCloseComplete); + + expect(onCloseComplete).toHaveBeenCalledTimes(1); +}); + +test('returns connection count from the underlying server.', () => { + server.getConnections.mockImplementation(callback => callback(null, 0)); + const onGetConnectionsComplete = jest.fn(); + proxy.getConnections(onGetConnectionsComplete); + + expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); + expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 0); + onGetConnectionsComplete.mockReset(); + + server.getConnections.mockImplementation(callback => callback(null, 100500)); + proxy.getConnections(onGetConnectionsComplete); + + expect(onGetConnectionsComplete).toHaveBeenCalledTimes(1); + expect(onGetConnectionsComplete).toHaveBeenCalledWith(null, 100500); +}); diff --git a/src/core/server/legacy_compat/legacy_platform_proxifier.ts b/src/core/server/legacy_compat/legacy_platform_proxy.ts similarity index 58% rename from src/core/server/legacy_compat/legacy_platform_proxifier.ts rename to src/core/server/legacy_compat/legacy_platform_proxy.ts index 8e9198799988a9..e91d661e302388 100644 --- a/src/core/server/legacy_compat/legacy_platform_proxifier.ts +++ b/src/core/server/legacy_compat/legacy_platform_proxy.ts @@ -18,31 +18,32 @@ */ import { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; import { Server } from 'net'; import { Logger } from '../logging'; -import { Root } from '../root'; /** * List of the server events to be forwarded to the legacy platform. */ -const ServerEventsToForward = ['listening', 'error', 'clientError', 'connection']; +const ServerEventsToForward = [ + 'clientError', + 'close', + 'connection', + 'error', + 'listening', + 'upgrade', +]; /** * Represents "proxy" between legacy and current platform. * @internal */ -export class LegacyPlatformProxifier extends EventEmitter { +export class LegacyPlatformProxy extends EventEmitter { private readonly eventHandlers: Map void>; - private readonly log: Logger; - private server?: Server; - constructor(private readonly root: Root) { + constructor(private readonly log: Logger, private readonly server: Server) { super(); - this.log = root.logger.get('legacy-platform-proxifier'); - // HapiJS expects that the following events will be generated by `listener`, see: // https://github.com/hapijs/hapi/blob/v14.2.0/lib/connection.js. this.eventHandlers = new Map( @@ -56,50 +57,40 @@ export class LegacyPlatformProxifier extends EventEmitter { ] as [string, (...args: any[]) => void]; }) ); + + for (const [eventName, eventHandler] of this.eventHandlers) { + this.server.addListener(eventName, eventHandler); + } } /** * Neither new nor legacy platform should use this method directly. */ public address() { - return this.server && this.server.address(); + this.log.debug('"address" has been called.'); + + return this.server.address(); } /** * Neither new nor legacy platform should use this method directly. */ - public async listen(port: number, host: string, callback?: (error?: Error) => void) { + public listen(port: number, host: string, callback?: (error?: Error) => void) { this.log.debug(`"listen" has been called (${host}:${port}).`); - let error: Error | undefined; - try { - await this.root.start(); - } catch (err) { - error = err; - this.emit('error', err); - } - if (callback !== undefined) { - callback(error); + callback(); } } /** * Neither new nor legacy platform should use this method directly. */ - public async close(callback?: (error?: Error) => void) { + public close(callback?: (error?: Error) => void) { this.log.debug('"close" has been called.'); - let error: Error | undefined; - try { - await this.root.shutdown(); - } catch (err) { - error = err; - this.emit('error', err); - } - if (callback !== undefined) { - callback(error); + callback(); } } @@ -107,40 +98,10 @@ export class LegacyPlatformProxifier extends EventEmitter { * Neither new nor legacy platform should use this method directly. */ public getConnections(callback: (error: Error | null, count?: number) => void) { + this.log.debug('"getConnections" has been called.'); + // This method is used by `even-better` (before we start platform). // It seems that the latest version of parent `good` doesn't use this anymore. - if (this.server) { - this.server.getConnections(callback); - } else { - callback(null, 0); - } - } - - /** - * Binds Http/Https server to the LegacyPlatformProxifier. - * @param server Server to bind to. - */ - public bind(server: Server) { - const oldServer = this.server; - this.server = server; - - for (const [eventName, eventHandler] of this.eventHandlers) { - if (oldServer !== undefined) { - oldServer.removeListener(eventName, eventHandler); - } - - this.server.addListener(eventName, eventHandler); - } - } - - /** - * Forwards request and response objects to the legacy platform. - * This method is used whenever new platform doesn't know how to handle the request. - * @param request Native Node request object instance. - * @param response Native Node response object instance. - */ - public proxy(request: IncomingMessage, response: ServerResponse) { - this.log.debug(`Request will be handled by proxy ${request.method}:${request.url}.`); - this.emit('request', request, response); + this.server.getConnections(callback); } } diff --git a/src/core/server/legacy_compat/legacy_service.test.ts b/src/core/server/legacy_compat/legacy_service.test.ts new file mode 100644 index 00000000000000..70c71e3b4b0b84 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_service.test.ts @@ -0,0 +1,339 @@ +/* + * 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, Subject, throwError } from 'rxjs'; + +jest.mock('./legacy_platform_proxy'); +jest.mock('../../../server/kbn_server'); +jest.mock('../../../cli/cluster/cluster_manager'); + +import { first } from 'rxjs/operators'; +import { LegacyService } from '.'; +// @ts-ignore: implicit any for JS file +import MockClusterManager from '../../../cli/cluster/cluster_manager'; +// @ts-ignore: implicit any for JS file +import MockKbnServer from '../../../server/kbn_server'; +import { Config, ConfigService, Env, ObjectToConfigAdapter } from '../config'; +import { getEnvOptions } from '../config/__mocks__/env'; +import { logger } from '../logging/__mocks__'; +import { LegacyPlatformProxy } from './legacy_platform_proxy'; + +const MockLegacyPlatformProxy: jest.Mock = LegacyPlatformProxy as any; + +let legacyService: LegacyService; +let configService: jest.Mocked; +let env: Env; +let mockHttpServerInfo: any; +let config$: BehaviorSubject; +beforeEach(() => { + env = Env.createDefault(getEnvOptions()); + + MockKbnServer.prototype.ready = jest.fn().mockReturnValue(Promise.resolve()); + + mockHttpServerInfo = { + server: { listener: { addListener: jest.fn() }, route: jest.fn() }, + options: { someOption: 'foo', someAnotherOption: 'bar' }, + }; + + config$ = new BehaviorSubject( + new ObjectToConfigAdapter({ + server: { autoListen: true }, + }) + ); + + configService = { + getConfig$: jest.fn().mockReturnValue(config$), + atPath: jest.fn().mockReturnValue(new BehaviorSubject({})), + } as any; + legacyService = new LegacyService(env, logger, configService); +}); + +afterEach(() => { + MockLegacyPlatformProxy.mockClear(); + MockKbnServer.mockClear(); + MockClusterManager.create.mockClear(); + logger.mockClear(); +}); + +describe('once LegacyService is started with connection info', () => { + test('register proxy route.', async () => { + await legacyService.start(mockHttpServerInfo); + + expect(mockHttpServerInfo.server.route.mock.calls).toMatchSnapshot('proxy route options'); + }); + + test('proxy route responds with `503` if `kbnServer` is not ready yet.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + + const kbnServerListen$ = new Subject(); + MockKbnServer.prototype.listen = jest.fn(() => { + kbnServerListen$.next(); + return kbnServerListen$.toPromise(); + }); + + // Wait until listen is called and proxy route is registered, but don't allow + // listen to complete and make kbnServer available. + const legacyStartPromise = legacyService.start(mockHttpServerInfo); + await kbnServerListen$.pipe(first()).toPromise(); + + const mockResponse: any = { + code: jest.fn().mockImplementation(() => mockResponse), + header: jest.fn().mockImplementation(() => mockResponse), + }; + const mockResponseToolkit = { + response: jest.fn().mockReturnValue(mockResponse), + abandon: Symbol('abandon'), + }; + const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } }; + + const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls; + const response503 = await handler(mockRequest, mockResponseToolkit); + + expect(response503).toBe(mockResponse); + expect({ + body: mockResponseToolkit.response.mock.calls, + code: mockResponse.code.mock.calls, + header: mockResponse.header.mock.calls, + }).toMatchSnapshot('503 response'); + + // Make sure request hasn't been passed to the legacy platform. + const [mockedLegacyPlatformProxy] = MockLegacyPlatformProxy.mock.instances; + expect(mockedLegacyPlatformProxy.emit).not.toHaveBeenCalled(); + + // Now wait until kibana is ready and try to request once again. + kbnServerListen$.complete(); + await legacyStartPromise; + mockResponseToolkit.response.mockClear(); + + const responseProxy = await handler(mockRequest, mockResponseToolkit); + expect(responseProxy).toBe(mockResponseToolkit.abandon); + expect(mockResponseToolkit.response).not.toHaveBeenCalled(); + + // Make sure request has been passed to the legacy platform. + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledTimes(1); + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledWith( + 'request', + mockRequest.raw.req, + mockRequest.raw.res + ); + }); + + test('creates legacy kbnServer and calls `listen`.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + + await legacyService.start(mockHttpServerInfo); + + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { + serverOptions: { + listener: expect.any(LegacyPlatformProxy), + someAnotherOption: 'bar', + someOption: 'foo', + }, + } + ); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.listen).toHaveBeenCalledTimes(1); + expect(mockKbnServer.close).not.toHaveBeenCalled(); + }); + + test('creates legacy kbnServer but does not call `listen` if `autoListen: false`.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: false })); + + await legacyService.start(mockHttpServerInfo); + + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { + serverOptions: { + listener: expect.any(LegacyPlatformProxy), + someAnotherOption: 'bar', + someOption: 'foo', + }, + } + ); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.ready).toHaveBeenCalledTimes(1); + expect(mockKbnServer.listen).not.toHaveBeenCalled(); + expect(mockKbnServer.close).not.toHaveBeenCalled(); + }); + + test('creates legacy kbnServer and closes it if `listen` fails.', async () => { + configService.atPath.mockReturnValue(new BehaviorSubject({ autoListen: true })); + MockKbnServer.prototype.listen.mockRejectedValue(new Error('something failed')); + + await expect(legacyService.start(mockHttpServerInfo)).rejects.toThrowErrorMatchingSnapshot(); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.listen).toHaveBeenCalled(); + expect(mockKbnServer.close).toHaveBeenCalled(); + }); + + test('throws if fails to retrieve initial config.', async () => { + configService.getConfig$.mockReturnValue(throwError(new Error('something failed'))); + + await expect(legacyService.start(mockHttpServerInfo)).rejects.toThrowErrorMatchingSnapshot(); + + expect(MockKbnServer).not.toHaveBeenCalled(); + expect(MockClusterManager).not.toHaveBeenCalled(); + }); + + test('reconfigures logging configuration if new config is received.', async () => { + await legacyService.start(mockHttpServerInfo); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + + config$.next(new ObjectToConfigAdapter({ logging: { verbose: true } })); + + expect(mockKbnServer.applyLoggingConfiguration.mock.calls).toMatchSnapshot( + `applyLoggingConfiguration params` + ); + }); + + test('logs error if re-configuring fails.', async () => { + await legacyService.start(mockHttpServerInfo); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + expect(logger.mockCollect().error).toEqual([]); + + const configError = new Error('something went wrong'); + mockKbnServer.applyLoggingConfiguration.mockImplementation(() => { + throw configError; + }); + + config$.next(new ObjectToConfigAdapter({ logging: { verbose: true } })); + + expect(logger.mockCollect().error).toEqual([[configError]]); + }); + + test('logs error if config service fails.', async () => { + await legacyService.start(mockHttpServerInfo); + + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + expect(logger.mockCollect().error).toEqual([]); + + const configError = new Error('something went wrong'); + config$.error(configError); + + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + expect(logger.mockCollect().error).toEqual([[configError]]); + }); + + test('proxy route abandons request processing and forwards it to the legacy Kibana', async () => { + const mockResponseToolkit = { response: jest.fn(), abandon: Symbol('abandon') }; + const mockRequest = { raw: { req: { a: 1 }, res: { b: 2 } } }; + + await legacyService.start(mockHttpServerInfo); + + const [[{ handler }]] = mockHttpServerInfo.server.route.mock.calls; + const response = await handler(mockRequest, mockResponseToolkit); + + expect(response).toBe(mockResponseToolkit.abandon); + expect(mockResponseToolkit.response).not.toHaveBeenCalled(); + + // Make sure request has been passed to the legacy platform. + const [mockedLegacyPlatformProxy] = MockLegacyPlatformProxy.mock.instances; + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledTimes(1); + expect(mockedLegacyPlatformProxy.emit).toHaveBeenCalledWith( + 'request', + mockRequest.raw.req, + mockRequest.raw.res + ); + }); +}); + +describe('once LegacyService is started without connection info', () => { + beforeEach(async () => await legacyService.start()); + + test('creates legacy kbnServer with `autoListen: false`.', () => { + expect(mockHttpServerInfo.server.route).not.toHaveBeenCalled(); + expect(MockKbnServer).toHaveBeenCalledTimes(1); + expect(MockKbnServer).toHaveBeenCalledWith( + { server: { autoListen: true } }, + { serverOptions: { autoListen: false } } + ); + }); + + test('reconfigures logging configuration if new config is received.', async () => { + const [mockKbnServer] = MockKbnServer.mock.instances; + expect(mockKbnServer.applyLoggingConfiguration).not.toHaveBeenCalled(); + + config$.next(new ObjectToConfigAdapter({ logging: { verbose: true } })); + + expect(mockKbnServer.applyLoggingConfiguration.mock.calls).toMatchSnapshot( + `applyLoggingConfiguration params` + ); + }); +}); + +describe('once LegacyService is started in `devClusterMaster` mode', () => { + beforeEach(() => { + configService.atPath.mockImplementation(path => { + return new BehaviorSubject( + path === 'dev' ? { basePathProxyTargetPort: 100500 } : { basePath: '/abc' } + ); + }); + }); + + test('creates ClusterManager without base path proxy.', async () => { + const devClusterLegacyService = new LegacyService( + Env.createDefault( + getEnvOptions({ + cliArgs: { silent: true, basePath: false }, + isDevClusterMaster: true, + }) + ), + logger, + configService + ); + + await devClusterLegacyService.start(); + + expect(MockClusterManager.create.mock.calls).toMatchSnapshot( + 'cluster manager without base path proxy' + ); + }); + + test('creates ClusterManager with base path proxy.', async () => { + const devClusterLegacyService = new LegacyService( + Env.createDefault( + getEnvOptions({ + cliArgs: { quiet: true, basePath: true }, + isDevClusterMaster: true, + }) + ), + logger, + configService + ); + + await devClusterLegacyService.start(); + + expect(MockClusterManager.create.mock.calls).toMatchSnapshot( + 'cluster manager with base path proxy' + ); + }); +}); diff --git a/src/core/server/legacy_compat/legacy_service.ts b/src/core/server/legacy_compat/legacy_service.ts new file mode 100644 index 00000000000000..092057874fa737 --- /dev/null +++ b/src/core/server/legacy_compat/legacy_service.ts @@ -0,0 +1,204 @@ +/* + * 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 { Server as HapiServer } from 'hapi-latest'; +import { combineLatest, ConnectableObservable, EMPTY, Subscription } from 'rxjs'; +import { first, map, mergeMap, publishReplay, tap } from 'rxjs/operators'; +import { CoreService } from '../../types/core_service'; +import { Config, ConfigService, Env } from '../config'; +import { DevConfig } from '../dev'; +import { BasePathProxyServer, HttpConfig, HttpServerInfo } from '../http'; +import { Logger, LoggerFactory } from '../logging'; +import { LegacyPlatformProxy } from './legacy_platform_proxy'; + +interface LegacyKbnServer { + applyLoggingConfiguration: (settings: Readonly>) => void; + listen: () => Promise; + ready: () => Promise; + close: () => Promise; +} + +export class LegacyService implements CoreService { + private readonly log: Logger; + private kbnServer?: LegacyKbnServer; + private configSubscription?: Subscription; + + constructor( + private readonly env: Env, + private readonly logger: LoggerFactory, + private readonly configService: ConfigService + ) { + this.log = logger.get('legacy', 'service'); + } + + public async start(httpServerInfo?: HttpServerInfo) { + this.log.debug('starting legacy service'); + + const update$ = this.configService.getConfig$().pipe( + tap(config => { + if (this.kbnServer !== undefined) { + this.kbnServer.applyLoggingConfiguration(config.toRaw()); + } + }), + tap({ error: err => this.log.error(err) }), + publishReplay(1) + ) as ConnectableObservable; + + this.configSubscription = update$.connect(); + + // Receive initial config and create kbnServer/ClusterManager. + this.kbnServer = await update$ + .pipe( + first(), + mergeMap(async config => { + if (this.env.isDevClusterMaster) { + await this.createClusterManager(config); + return; + } + + return await this.createKbnServer(config, httpServerInfo); + }) + ) + .toPromise(); + } + + public async stop() { + this.log.debug('stopping legacy service'); + + if (this.configSubscription !== undefined) { + this.configSubscription.unsubscribe(); + this.configSubscription = undefined; + } + + if (this.kbnServer !== undefined) { + await this.kbnServer.close(); + this.kbnServer = undefined; + } + } + + private async createClusterManager(config: Config) { + const basePathProxy$ = this.env.cliArgs.basePath + ? combineLatest( + this.configService.atPath('dev', DevConfig), + this.configService.atPath('server', HttpConfig) + ).pipe( + first(), + map(([devConfig, httpConfig]) => { + return new BasePathProxyServer(this.logger.get('server'), httpConfig, devConfig); + }) + ) + : EMPTY; + + require('../../../cli/cluster/cluster_manager').create( + this.env.cliArgs, + config.toRaw(), + await basePathProxy$.toPromise() + ); + } + + private async createKbnServer(config: Config, httpServerInfo?: HttpServerInfo) { + const KbnServer = require('../../../server/kbn_server'); + const kbnServer: LegacyKbnServer = new KbnServer(config.toRaw(), { + // If core HTTP service is run we'll receive internal server reference and + // options that were used to create that server so that we can properly + // bridge with the "legacy" Kibana. If server isn't run (e.g. if process is + // managed by ClusterManager or optimizer) then we won't have that info, + // so we can't start "legacy" server either. + serverOptions: + httpServerInfo !== undefined + ? { + ...httpServerInfo.options, + listener: this.setupProxyListener(httpServerInfo.server), + } + : { autoListen: false }, + }); + + // The kbnWorkerType check is necessary to prevent the repl + // from being started multiple times in different processes. + // We only want one REPL. + if (this.env.cliArgs.repl && process.env.kbnWorkerType === 'server') { + require('../../../cli/repl').startRepl(kbnServer); + } + + const httpConfig = await this.configService + .atPath('server', HttpConfig) + .pipe(first()) + .toPromise(); + + if (httpConfig.autoListen) { + try { + await kbnServer.listen(); + } catch (err) { + await kbnServer.close(); + throw err; + } + } else { + await kbnServer.ready(); + } + + return kbnServer; + } + + private setupProxyListener(server: HapiServer) { + const legacyProxy = new LegacyPlatformProxy( + this.logger.get('legacy', 'proxy'), + server.listener + ); + + // We register Kibana proxy middleware right before we start server to allow + // all new platform plugins register their routes, so that `legacyProxy` + // handles only requests that aren't handled by the new platform. + server.route({ + path: '/{p*}', + method: '*', + options: { + payload: { + output: 'stream', + parse: false, + timeout: false, + // Having such a large value here will allow legacy routes to override + // maximum allowed payload size set in the core http server if needed. + maxBytes: Number.MAX_SAFE_INTEGER, + }, + }, + handler: async ({ raw: { req, res } }, responseToolkit) => { + if (this.kbnServer === undefined) { + this.log.debug(`Kibana server is not ready yet ${req.method}:${req.url}.`); + + // If legacy server is not ready yet (e.g. it's still in optimization phase), + // we should let client know that and ask to retry after 30 seconds. + return responseToolkit + .response('Kibana server is not ready yet') + .code(503) + .header('Retry-After', '30'); + } + + this.log.trace(`Request will be handled by proxy ${req.method}:${req.url}.`); + + // Forward request and response objects to the legacy platform. This method + // is used whenever new platform doesn't know how to handle the request. + legacyProxy.emit('request', req, res); + + return responseToolkit.abandon; + }, + }); + + return legacyProxy; + } +} diff --git a/src/core/server/legacy_compat/logging/appenders/__tests__/__snapshots__/legacy_appender.test.ts.snap b/src/core/server/legacy_compat/logging/appenders/__snapshots__/legacy_appender.test.ts.snap similarity index 100% rename from src/core/server/legacy_compat/logging/appenders/__tests__/__snapshots__/legacy_appender.test.ts.snap rename to src/core/server/legacy_compat/logging/appenders/__snapshots__/legacy_appender.test.ts.snap diff --git a/src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts b/src/core/server/legacy_compat/logging/appenders/legacy_appender.test.ts similarity index 93% rename from src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts rename to src/core/server/legacy_compat/logging/appenders/legacy_appender.test.ts index 5f62a853666d9c..adc5dcae3ec9d3 100644 --- a/src/core/server/legacy_compat/logging/appenders/__tests__/legacy_appender.test.ts +++ b/src/core/server/legacy_compat/logging/appenders/legacy_appender.test.ts @@ -17,12 +17,12 @@ * under the License. */ -jest.mock('../../legacy_logging_server'); +jest.mock('../legacy_logging_server'); -import { LogLevel } from '../../../../logging/log_level'; -import { LogRecord } from '../../../../logging/log_record'; -import { LegacyLoggingServer } from '../../legacy_logging_server'; -import { LegacyAppender } from '../legacy_appender'; +import { LogLevel } from '../../../logging/log_level'; +import { LogRecord } from '../../../logging/log_record'; +import { LegacyLoggingServer } from '../legacy_logging_server'; +import { LegacyAppender } from './legacy_appender'; afterEach(() => (LegacyLoggingServer as any).mockClear()); diff --git a/src/core/server/logging/__tests__/__snapshots__/logging_config.test.ts.snap b/src/core/server/logging/__snapshots__/logging_config.test.ts.snap similarity index 100% rename from src/core/server/logging/__tests__/__snapshots__/logging_config.test.ts.snap rename to src/core/server/logging/__snapshots__/logging_config.test.ts.snap diff --git a/src/core/server/logging/__tests__/__snapshots__/logging_service.test.ts.snap b/src/core/server/logging/__snapshots__/logging_service.test.ts.snap similarity index 100% rename from src/core/server/logging/__tests__/__snapshots__/logging_service.test.ts.snap rename to src/core/server/logging/__snapshots__/logging_service.test.ts.snap diff --git a/src/core/server/logging/appenders/__tests__/appenders.test.ts b/src/core/server/logging/appenders/appenders.test.ts similarity index 88% rename from src/core/server/logging/appenders/__tests__/appenders.test.ts rename to src/core/server/logging/appenders/appenders.test.ts index f141de991f453d..2103f9d8187b2a 100644 --- a/src/core/server/logging/appenders/__tests__/appenders.test.ts +++ b/src/core/server/logging/appenders/appenders.test.ts @@ -18,8 +18,8 @@ */ const mockCreateLayout = jest.fn(); -jest.mock('../../layouts/layouts', () => { - const { schema } = require('../../../config/schema'); +jest.mock('../layouts/layouts', () => { + const { schema } = require('../../config/schema'); return { Layouts: { configSchema: schema.object({ kind: schema.literal('mock') }), @@ -28,10 +28,10 @@ jest.mock('../../layouts/layouts', () => { }; }); -import { LegacyAppender } from '../../../legacy_compat/logging/appenders/legacy_appender'; -import { Appenders } from '../appenders'; -import { ConsoleAppender } from '../console/console_appender'; -import { FileAppender } from '../file/file_appender'; +import { LegacyAppender } from '../../legacy_compat/logging/appenders/legacy_appender'; +import { Appenders } from './appenders'; +import { ConsoleAppender } from './console/console_appender'; +import { FileAppender } from './file/file_appender'; beforeEach(() => { mockCreateLayout.mockReset(); diff --git a/src/core/server/logging/appenders/__tests__/buffer_appender.test.ts b/src/core/server/logging/appenders/buffer/buffer_appender.test.ts similarity index 97% rename from src/core/server/logging/appenders/__tests__/buffer_appender.test.ts rename to src/core/server/logging/appenders/buffer/buffer_appender.test.ts index cdf2714f44e298..453a29271c5821 100644 --- a/src/core/server/logging/appenders/__tests__/buffer_appender.test.ts +++ b/src/core/server/logging/appenders/buffer/buffer_appender.test.ts @@ -19,7 +19,7 @@ import { LogLevel } from '../../log_level'; import { LogRecord } from '../../log_record'; -import { BufferAppender } from '../buffer/buffer_appender'; +import { BufferAppender } from './buffer_appender'; test('`flush()` does not return any record buffered at the beginning.', () => { const appender = new BufferAppender(); diff --git a/src/core/server/logging/appenders/__tests__/console_appender.test.ts b/src/core/server/logging/appenders/console/console_appender.test.ts similarity index 95% rename from src/core/server/logging/appenders/__tests__/console_appender.test.ts rename to src/core/server/logging/appenders/console/console_appender.test.ts index 35128bd6ba1fdb..fe5602a8096991 100644 --- a/src/core/server/logging/appenders/__tests__/console_appender.test.ts +++ b/src/core/server/logging/appenders/console/console_appender.test.ts @@ -30,7 +30,7 @@ jest.mock('../../layouts/layouts', () => { import { LogLevel } from '../../log_level'; import { LogRecord } from '../../log_record'; -import { ConsoleAppender } from '../console/console_appender'; +import { ConsoleAppender } from './console_appender'; test('`configSchema` creates correct schema.', () => { const appenderSchema = ConsoleAppender.configSchema; @@ -82,8 +82,10 @@ test('`append()` correctly formats records and pushes them to console.', () => { for (const record of records) { appender.append(record); + // tslint:disable-next-line no-console expect(console.log).toHaveBeenCalledWith(`mock-${JSON.stringify(record)}`); } + // tslint:disable-next-line no-console expect(console.log).toHaveBeenCalledTimes(records.length); }); diff --git a/src/core/server/logging/appenders/__tests__/file_appender.test.ts b/src/core/server/logging/appenders/file/file_appender.test.ts similarity index 98% rename from src/core/server/logging/appenders/__tests__/file_appender.test.ts rename to src/core/server/logging/appenders/file/file_appender.test.ts index 69b4980dff1f01..cc8f0196bff7c2 100644 --- a/src/core/server/logging/appenders/__tests__/file_appender.test.ts +++ b/src/core/server/logging/appenders/file/file_appender.test.ts @@ -33,7 +33,7 @@ jest.mock('fs', () => ({ createWriteStream: mockCreateWriteStream })); import { LogLevel } from '../../log_level'; import { LogRecord } from '../../log_record'; -import { FileAppender } from '../file/file_appender'; +import { FileAppender } from './file_appender'; const tickMs = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/core/server/logging/layouts/__tests__/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap similarity index 100% rename from src/core/server/logging/layouts/__tests__/__snapshots__/json_layout.test.ts.snap rename to src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap diff --git a/src/core/server/logging/layouts/__tests__/__snapshots__/pattern_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/pattern_layout.test.ts.snap similarity index 100% rename from src/core/server/logging/layouts/__tests__/__snapshots__/pattern_layout.test.ts.snap rename to src/core/server/logging/layouts/__snapshots__/pattern_layout.test.ts.snap diff --git a/src/core/server/logging/layouts/__tests__/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts similarity index 94% rename from src/core/server/logging/layouts/__tests__/json_layout.test.ts rename to src/core/server/logging/layouts/json_layout.test.ts index ec94d023b2d64f..49b8ddef07a63c 100644 --- a/src/core/server/logging/layouts/__tests__/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -17,9 +17,9 @@ * under the License. */ -import { LogLevel } from '../../log_level'; -import { LogRecord } from '../../log_record'; -import { JsonLayout } from '../json_layout'; +import { LogLevel } from '../log_level'; +import { LogRecord } from '../log_record'; +import { JsonLayout } from './json_layout'; const records: LogRecord[] = [ { diff --git a/src/core/server/logging/layouts/__tests__/layouts.test.ts b/src/core/server/logging/layouts/layouts.test.ts similarity index 93% rename from src/core/server/logging/layouts/__tests__/layouts.test.ts rename to src/core/server/logging/layouts/layouts.test.ts index ca70710233fee3..aa1c54c846bc66 100644 --- a/src/core/server/logging/layouts/__tests__/layouts.test.ts +++ b/src/core/server/logging/layouts/layouts.test.ts @@ -17,9 +17,9 @@ * under the License. */ -import { JsonLayout } from '../json_layout'; -import { Layouts } from '../layouts'; -import { PatternLayout } from '../pattern_layout'; +import { JsonLayout } from './json_layout'; +import { Layouts } from './layouts'; +import { PatternLayout } from './pattern_layout'; test('`configSchema` creates correct schema for `pattern` layout.', () => { const layoutsSchema = Layouts.configSchema; diff --git a/src/core/server/logging/layouts/__tests__/pattern_layout.test.ts b/src/core/server/logging/layouts/pattern_layout.test.ts similarity index 93% rename from src/core/server/logging/layouts/__tests__/pattern_layout.test.ts rename to src/core/server/logging/layouts/pattern_layout.test.ts index 4e6ddf2c097ed1..ae8b39b9cc99ae 100644 --- a/src/core/server/logging/layouts/__tests__/pattern_layout.test.ts +++ b/src/core/server/logging/layouts/pattern_layout.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { stripAnsiSnapshotSerializer } from '../../../../test_helpers/strip_ansi_snapshot_serializer'; -import { LogLevel } from '../../log_level'; -import { LogRecord } from '../../log_record'; -import { PatternLayout } from '../pattern_layout'; +import { stripAnsiSnapshotSerializer } from '../../../test_helpers/strip_ansi_snapshot_serializer'; +import { LogLevel } from '../log_level'; +import { LogRecord } from '../log_record'; +import { PatternLayout } from './pattern_layout'; const records: LogRecord[] = [ { diff --git a/src/core/server/logging/__tests__/log_level.test.ts b/src/core/server/logging/log_level.test.ts similarity index 98% rename from src/core/server/logging/__tests__/log_level.test.ts rename to src/core/server/logging/log_level.test.ts index 43de344b34cffb..1f86cf21037a6f 100644 --- a/src/core/server/logging/__tests__/log_level.test.ts +++ b/src/core/server/logging/log_level.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { LogLevel } from '../log_level'; +import { LogLevel } from './log_level'; const allLogLevels = [ LogLevel.Off, diff --git a/src/core/server/logging/__tests__/logger.test.ts b/src/core/server/logging/logger.test.ts similarity index 98% rename from src/core/server/logging/__tests__/logger.test.ts rename to src/core/server/logging/logger.test.ts index 2dc16178fb47b9..61eaa4912185b3 100644 --- a/src/core/server/logging/__tests__/logger.test.ts +++ b/src/core/server/logging/logger.test.ts @@ -17,10 +17,10 @@ * under the License. */ -import { Appender } from '../appenders/appenders'; -import { LogLevel } from '../log_level'; -import { BaseLogger } from '../logger'; -import { LoggingConfig } from '../logging_config'; +import { LoggingConfig } from '.'; +import { Appender } from './appenders/appenders'; +import { LogLevel } from './log_level'; +import { BaseLogger } from './logger'; const context = LoggingConfig.getLoggerContext(['context', 'parent', 'child']); let appenderMocks: Appender[]; diff --git a/src/core/server/logging/__tests__/logger_adapter.test.ts b/src/core/server/logging/logger_adapter.test.ts similarity index 97% rename from src/core/server/logging/__tests__/logger_adapter.test.ts rename to src/core/server/logging/logger_adapter.test.ts index 25a9c01b108d69..075e8f4d47ffe1 100644 --- a/src/core/server/logging/__tests__/logger_adapter.test.ts +++ b/src/core/server/logging/logger_adapter.test.ts @@ -17,8 +17,8 @@ * under the License. */ -import { Logger } from '../logger'; -import { LoggerAdapter } from '../logger_adapter'; +import { Logger } from '.'; +import { LoggerAdapter } from './logger_adapter'; test('proxies all method calls to the internal logger.', () => { const internalLogger: Logger = { diff --git a/src/core/server/logging/__tests__/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts similarity index 98% rename from src/core/server/logging/__tests__/logging_config.test.ts rename to src/core/server/logging/logging_config.test.ts index 2f1f1d9f2f7c02..f21b5aaf3c1a72 100644 --- a/src/core/server/logging/__tests__/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { LoggingConfig } from '../logging_config'; +import { LoggingConfig } from '.'; test('`schema` creates correct schema with defaults.', () => { const loggingConfigSchema = LoggingConfig.schema; diff --git a/src/core/server/logging/__tests__/logging_service.test.ts b/src/core/server/logging/logging_service.test.ts similarity index 98% rename from src/core/server/logging/__tests__/logging_service.test.ts rename to src/core/server/logging/logging_service.test.ts index eb452376d6ccc0..b6aeb88b500523 100644 --- a/src/core/server/logging/__tests__/logging_service.test.ts +++ b/src/core/server/logging/logging_service.test.ts @@ -32,8 +32,7 @@ jest.spyOn(global, 'Date').mockImplementation(() => timestamp); import { createWriteStream } from 'fs'; const mockCreateWriteStream = createWriteStream as jest.Mock; -import { LoggingConfig } from '../logging_config'; -import { LoggingService } from '../logging_service'; +import { LoggingConfig, LoggingService } from '.'; let service: LoggingService; beforeEach(() => (service = new LoggingService())); diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts index 90ee9524381dee..966bd74a0df416 100644 --- a/src/core/server/logging/logging_service.ts +++ b/src/core/server/logging/logging_service.ts @@ -71,7 +71,7 @@ export class LoggingService implements LoggerFactory { this.appenders.set(appenderKey, Appenders.create(appenderConfig)); } - for (const [loggerKey, loggerAdapter] of this.loggers.entries()) { + for (const [loggerKey, loggerAdapter] of this.loggers) { loggerAdapter.updateLogger(this.createLogger(loggerKey, config)); } diff --git a/src/core/server/root/__tests__/__snapshots__/index.test.ts.snap b/src/core/server/root/__snapshots__/index.test.ts.snap similarity index 100% rename from src/core/server/root/__tests__/__snapshots__/index.test.ts.snap rename to src/core/server/root/__snapshots__/index.test.ts.snap diff --git a/src/core/server/root/base_path_proxy_root.ts b/src/core/server/root/base_path_proxy_root.ts deleted file mode 100644 index 80ab7d1c606770..00000000000000 --- a/src/core/server/root/base_path_proxy_root.ts +++ /dev/null @@ -1,80 +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 { first } from 'rxjs/operators'; - -import { Root } from '.'; -import { DevConfig } from '../dev'; -import { HttpConfig } from '../http'; -import { BasePathProxyServer, BasePathProxyServerOptions } from '../http/base_path_proxy_server'; - -/** - * Top-level entry point to start BasePathProxy server. - */ -export class BasePathProxyRoot extends Root { - private basePathProxy?: BasePathProxyServer; - - public async configure({ - blockUntil, - shouldRedirectFromOldBasePath, - }: Pick) { - const [devConfig, httpConfig] = await Promise.all([ - this.configService - .atPath('dev', DevConfig) - .pipe(first()) - .toPromise(), - this.configService - .atPath('server', HttpConfig) - .pipe(first()) - .toPromise(), - ]); - - this.basePathProxy = new BasePathProxyServer(this.logger.get('server'), { - blockUntil, - devConfig, - httpConfig, - shouldRedirectFromOldBasePath, - }); - } - - public getBasePath() { - return this.getBasePathProxy().basePath; - } - - public getTargetPort() { - return this.getBasePathProxy().targetPort; - } - - protected async startServer() { - return this.getBasePathProxy().start(); - } - - protected async stopServer() { - await this.getBasePathProxy().stop(); - this.basePathProxy = undefined; - } - - private getBasePathProxy() { - if (this.basePathProxy === undefined) { - throw new Error('BasePathProxyRoot is not configured!'); - } - - return this.basePathProxy; - } -} diff --git a/src/core/server/root/__tests__/index.test.ts b/src/core/server/root/index.test.ts similarity index 94% rename from src/core/server/root/__tests__/index.test.ts rename to src/core/server/root/index.test.ts index d59cee896cc1e6..97308ef484f4f9 100644 --- a/src/core/server/root/__tests__/index.test.ts +++ b/src/core/server/root/index.test.ts @@ -18,27 +18,27 @@ */ const mockLoggingService = { asLoggerFactory: jest.fn(), upgrade: jest.fn(), stop: jest.fn() }; -jest.mock('../../logging', () => ({ +jest.mock('../logging', () => ({ LoggingService: jest.fn(() => mockLoggingService), })); const mockConfigService = { atPath: jest.fn() }; -jest.mock('../../config/config_service', () => ({ +jest.mock('../config/config_service', () => ({ ConfigService: jest.fn(() => mockConfigService), })); const mockServer = { start: jest.fn(), stop: jest.fn() }; -jest.mock('../../', () => ({ Server: jest.fn(() => mockServer) })); +jest.mock('../', () => ({ Server: jest.fn(() => mockServer) })); import { BehaviorSubject } from 'rxjs'; import { filter, first } from 'rxjs/operators'; -import { Root } from '../'; -import { Env, RawConfig } from '../../config'; -import { getEnvOptions } from '../../config/__tests__/__mocks__/env'; -import { logger } from '../../logging/__mocks__'; +import { Root } from '.'; +import { Config, Env } from '../config'; +import { getEnvOptions } from '../config/__mocks__/env'; +import { logger } from '../logging/__mocks__'; const env = new Env('.', getEnvOptions()); -const config$ = new BehaviorSubject({} as RawConfig); +const config$ = new BehaviorSubject({} as Config); const mockProcessExit = jest.spyOn(global.process, 'exit').mockImplementation(() => { // noop diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 25cba09681ac88..02a5c9e5595446 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -17,39 +17,35 @@ * under the License. */ -import { Observable, Subscription } from 'rxjs'; -import { catchError, first, map, shareReplay } from 'rxjs/operators'; +import { ConnectableObservable, Observable, Subscription } from 'rxjs'; +import { first, map, publishReplay, tap } from 'rxjs/operators'; import { Server } from '..'; -import { ConfigService, Env, RawConfig } from '../config'; - +import { Config, ConfigService, Env } from '../config'; import { Logger, LoggerFactory, LoggingConfig, LoggingService } from '../logging'; -export type OnShutdown = (reason?: Error) => void; - /** * Top-level entry point to kick off the app and start the Kibana server. */ export class Root { public readonly logger: LoggerFactory; - protected readonly configService: ConfigService; + private readonly configService: ConfigService; private readonly log: Logger; - private server?: Server; + private readonly server: Server; private readonly loggingService: LoggingService; private loggingConfigSubscription?: Subscription; constructor( - rawConfig$: Observable, + config$: Observable, private readonly env: Env, - private readonly onShutdown: OnShutdown = () => { - // noop - } + private readonly onShutdown?: (reason?: Error | string) => void ) { this.loggingService = new LoggingService(); this.logger = this.loggingService.asLoggerFactory(); - this.log = this.logger.get('root'); - this.configService = new ConfigService(rawConfig$, env, this.logger); + + this.configService = new ConfigService(config$, env, this.logger); + this.server = new Server(this.configService, this.logger, this.env); } public async start() { @@ -57,62 +53,63 @@ export class Root { try { await this.setupLogging(); - await this.startServer(); + await this.server.start(); } catch (e) { await this.shutdown(e); throw e; } } - public async shutdown(reason?: Error) { + public async shutdown(reason?: any) { this.log.debug('shutting root down'); - await this.stopServer(); + if (reason) { + if (reason.code === 'EADDRINUSE' && Number.isInteger(reason.port)) { + reason = new Error( + `Port ${reason.port} is already in use. Another instance of Kibana may be running!` + ); + } + + this.log.fatal(reason); + } + + await this.server.stop(); if (this.loggingConfigSubscription !== undefined) { this.loggingConfigSubscription.unsubscribe(); this.loggingConfigSubscription = undefined; } - await this.loggingService.stop(); - this.onShutdown(reason); - } - - protected async startServer() { - this.server = new Server(this.configService, this.logger, this.env); - return this.server.start(); - } - - protected async stopServer() { - if (this.server === undefined) { - return; + if (this.onShutdown !== undefined) { + this.onShutdown(reason); } - - await this.server.stop(); - this.server = undefined; } private async setupLogging() { // Stream that maps config updates to logger updates, including update failures. const update$ = this.configService.atPath('logging', LoggingConfig).pipe( map(config => this.loggingService.upgrade(config)), - catchError(err => { - // This specifically console.logs because we were not able to configure the logger. - // tslint:disable-next-line no-console - console.error('Configuring logger failed:', err); - - throw err; - }), - shareReplay(1) - ); - - // Wait for the first update to complete and throw if it fails. + // This specifically console.logs because we were not able to configure the logger. + // tslint:disable-next-line no-console + tap({ error: err => console.error('Configuring logger failed:', err) }), + publishReplay(1) + ) as ConnectableObservable; + + // Subscription and wait for the first update to complete and throw if it fails. + const connectSubscription = update$.connect(); await update$.pipe(first()).toPromise(); // Send subsequent update failures to this.shutdown(), stopped via loggingConfigSubscription. this.loggingConfigSubscription = update$.subscribe({ - error: error => this.shutdown(error), + error: err => this.shutdown(err), }); + + // Add subscription we got from `connect` so that we can dispose both of them + // at once. We can't inverse this and add consequent updates subscription to + // the one we got from `connect` because in the error case the latter will be + // automatically disposed before the error is forwarded to the former one so + // the shutdown logic won't be called. + this.loggingConfigSubscription.add(connectSubscription); } } diff --git a/src/core/types/core_service.ts b/src/core/types/core_service.ts index b6031e0deb7bae..8a8ac92b93cccd 100644 --- a/src/core/types/core_service.ts +++ b/src/core/types/core_service.ts @@ -17,7 +17,7 @@ * under the License. */ -export interface CoreService { - start(): Promise; +export interface CoreService { + start(): Promise; stop(): Promise; } diff --git a/src/core/utils/__tests__/__snapshots__/get.test.ts.snap b/src/core/utils/__snapshots__/get.test.ts.snap similarity index 100% rename from src/core/utils/__tests__/__snapshots__/get.test.ts.snap rename to src/core/utils/__snapshots__/get.test.ts.snap diff --git a/src/core/utils/__tests__/get.test.ts b/src/core/utils/get.test.ts similarity index 97% rename from src/core/utils/__tests__/get.test.ts rename to src/core/utils/get.test.ts index a93ad6f6d708eb..f409638b5d4915 100644 --- a/src/core/utils/__tests__/get.test.ts +++ b/src/core/utils/get.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { get } from '../get'; +import { get } from './get'; const obj = { bar: { diff --git a/src/core/utils/__tests__/url.test.ts b/src/core/utils/url.test.ts similarity index 95% rename from src/core/utils/__tests__/url.test.ts rename to src/core/utils/url.test.ts index 6ff3a75d6e725f..3c35ba44455bc4 100644 --- a/src/core/utils/__tests__/url.test.ts +++ b/src/core/utils/url.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { modifyUrl } from '../url'; +import { modifyUrl } from './url'; describe('modifyUrl()', () => { test('throws an error with invalid input', () => { @@ -52,7 +52,7 @@ describe('modifyUrl()', () => { test('supports changing port', () => { expect( modifyUrl('http://localhost:5601', parsed => { - parsed.port = (Number.parseInt(parsed.port!) + 1).toString(); + parsed.port = (Number(parsed.port!) + 1).toString(); return parsed; }) ).toEqual('http://localhost:5602/'); diff --git a/src/core_plugins/apm_oss/index.js b/src/core_plugins/apm_oss/index.js index 69ff8901d2f3fb..fcbc188f89066d 100644 --- a/src/core_plugins/apm_oss/index.js +++ b/src/core_plugins/apm_oss/index.js @@ -30,10 +30,10 @@ export default function apmOss(kibana) { indexPattern: Joi.string().default('apm-*'), // ES Indices - errorIndices: Joi.string().default('apm-*-error-*'), - onboardingIndices: Joi.string().default('apm-*-onboarding-*'), - spanIndices: Joi.string().default('apm-*-span-*'), - transactionIndices: Joi.string().default('apm-*-transaction-*'), + errorIndices: Joi.string().default('apm-*'), + onboardingIndices: Joi.string().default('apm-*'), + spanIndices: Joi.string().default('apm-*'), + transactionIndices: Joi.string().default('apm-*'), }).default(); }, }); diff --git a/src/core_plugins/console/public/src/sense_editor/theme-sense-dark.js b/src/core_plugins/console/public/src/sense_editor/theme-sense-dark.js index 99f055760345eb..045f1042febe60 100644 --- a/src/core_plugins/console/public/src/sense_editor/theme-sense-dark.js +++ b/src/core_plugins/console/public/src/sense_editor/theme-sense-dark.js @@ -18,7 +18,6 @@ */ /* eslint import/no-unresolved: 0 */ -/* eslint no-multi-str: 0 */ /* eslint max-len: 0 */ const ace = require('brace'); diff --git a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.js.snap b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.js.snap index 8f7e4014bd7866..57dbeedd94a778 100644 --- a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.js.snap +++ b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/controls_tab.test.js.snap @@ -2,7 +2,7 @@ exports[`renders ControlsTab 1`] = `
- - - Add + diff --git a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/list_control_editor.test.js.snap b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/list_control_editor.test.js.snap index 692a57aec1b116..1d7e06fdd4bcd0 100644 --- a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/list_control_editor.test.js.snap +++ b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/list_control_editor.test.js.snap @@ -2,14 +2,14 @@ exports[`renders dynamic options should display disabled dynamic options with tooltip for non-string fields 1`] = `
- - - - - - - - - - + } onChange={[Function]} /> @@ -24,7 +30,13 @@ exports[`renders OptionsTab 1`] = ` + } onChange={[Function]} /> @@ -36,7 +48,13 @@ exports[`renders OptionsTab 1`] = ` > + } onChange={[Function]} /> diff --git a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/range_control_editor.test.js.snap b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/range_control_editor.test.js.snap index 43f936ce6e588b..c3c1b2b607ace2 100644 --- a/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/range_control_editor.test.js.snap +++ b/src/core_plugins/input_control_vis/public/components/editor/__snapshots__/range_control_editor.test.js.snap @@ -2,14 +2,14 @@ exports[`renders RangeControlEditor 1`] = `
- - + } > + } > { this.props.handleLabelChange(this.props.controlIndex, evt); @@ -101,7 +102,7 @@ export class ControlEditor extends Component { } > @@ -151,12 +154,21 @@ export class ControlsTab extends Component { > this.setState({ type: evt.target.value })} - aria-label="Select control type" + aria-label={intl.formatMessage({ + id: 'inputControl.editor.controlsTab.select.controlTypeAriaLabel', + defaultMessage: 'Select control type' + })} /> @@ -169,9 +181,12 @@ export class ControlsTab extends Component { onClick={this.handleAddControl} iconType="plusInCircle" data-test-subj="inputControlEditorAddBtn" - aria-label="Add control" + aria-label={intl.formatMessage({ + id: 'inputControl.editor.controlsTab.select.addControlAriaLabel', + defaultMessage: 'Add control' + })} > - Add + @@ -183,7 +198,9 @@ export class ControlsTab extends Component { } } -ControlsTab.propTypes = { +ControlsTabUi.propTypes = { scope: PropTypes.object.isRequired, stageEditorParams: PropTypes.func.isRequired }; + +export const ControlsTab = injectI18n(ControlsTabUi); diff --git a/src/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js b/src/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js index ee933d8894f378..b6a49cdaa06199 100644 --- a/src/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js +++ b/src/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js @@ -19,7 +19,7 @@ import React from 'react'; import sinon from 'sinon'; -import { mount, shallow } from 'enzyme'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { getIndexPatternMock } from './__tests__/get_index_pattern_mock'; import { @@ -87,7 +87,7 @@ beforeEach(() => { }); test('renders ControlsTab', () => { - const component = shallow( { describe('behavior', () => { test('add control button', () => { - const component = mount( { }); test('remove control button', () => { - const component = mount( { test('move down control button', () => { - const component = mount( { }); test('move up control button', () => { - const component = mount( 0) { const parentCandidatesOptions = [ { value: '', text: '' }, @@ -110,8 +112,14 @@ export class ListControlEditor extends Component { options.push( { this.props.handleCheckboxOptionChange(this.props.controlIndex, 'multiselect', evt); @@ -143,8 +157,14 @@ export class ListControlEditor extends Component { ); const dynamicOptionsHelpText = this.state.isStringField - ? 'Update options in response to user input' - : 'Only available for "string" fields'; + ? intl.formatMessage({ + id: 'inputControl.editor.listControl.dynamicOptions.updateDescription', + defaultMessage: 'Update options in response to user input' + }) + : intl.formatMessage({ + id: 'inputControl.editor.listControl.dynamicOptions.stringFieldDescription', + defaultMessage: 'Only available for "string" fields' + }); options.push( { this.props.handleCheckboxOptionChange(this.props.controlIndex, 'dynamicOptions', evt); @@ -168,9 +191,15 @@ export class ListControlEditor extends Component { options.push( { size: 5, } }; - const component = shallow( { { value: '1', text: 'fieldA' }, { value: '2', text: 'fieldB' } ]; - const component = shallow( { size: 5, } }; - const component = shallow( { size: 5, } }; - const component = shallow( { size: 5, } }; - const component = shallow( { }); test('handleCheckboxOptionChange - multiselect', async () => { - const component = mount( { }); test('handleNumberOptionChange - size', async () => { - const component = mount( { @@ -54,7 +56,10 @@ export class OptionsTab extends Component { id="updateFiltersOnChange" > } checked={this.props.editorState.params.updateFiltersOnChange} onChange={this.handleUpdateFiltersChange} data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox" @@ -65,7 +70,10 @@ export class OptionsTab extends Component { id="useTimeFilter" > } checked={this.props.editorState.params.useTimeFilter} onChange={this.handleUseTimeFilter} data-test-subj="inputControlEditorUseTimeFilterCheckbox" @@ -76,7 +84,10 @@ export class OptionsTab extends Component { id="pinFilters" > } checked={this.props.editorState.params.pinFilters} onChange={this.handlePinFilters} data-test-subj="inputControlEditorPinFiltersCheckbox" diff --git a/src/core_plugins/input_control_vis/public/components/editor/options_tab.test.js b/src/core_plugins/input_control_vis/public/components/editor/options_tab.test.js index b4540c60374777..ba4d43bea133f9 100644 --- a/src/core_plugins/input_control_vis/public/components/editor/options_tab.test.js +++ b/src/core_plugins/input_control_vis/public/components/editor/options_tab.test.js @@ -19,7 +19,8 @@ import React from 'react'; import sinon from 'sinon'; -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { OptionsTab, @@ -49,7 +50,7 @@ test('renders OptionsTab', () => { }); test('updateFiltersOnChange', () => { - const component = mount( { }); test('useTimeFilter', () => { - const component = mount( { }); test('pinFilters', () => { - const component = mount(} > } > { }); test('handleNumberOptionChange - step', () => { - const component = mount( { }); test('handleNumberOptionChange - decimalPlaces', () => { - const component = mount( - - Clear form + @@ -97,7 +101,11 @@ exports[`Apply and Cancel change btns enabled when there are changes 1`] = ` onClick={[Function]} type="button" > - Cancel changes + @@ -119,7 +127,11 @@ exports[`Apply and Cancel change btns enabled when there are changes 1`] = ` onClick={[Function]} type="button" > - Apply changes + @@ -151,7 +163,7 @@ exports[`Clear btns enabled when there are values 1`] = ` } } > - - Clear form + @@ -224,7 +240,11 @@ exports[`Clear btns enabled when there are values 1`] = ` onClick={[Function]} type="button" > - Cancel changes + @@ -246,7 +266,11 @@ exports[`Clear btns enabled when there are values 1`] = ` onClick={[Function]} type="button" > - Apply changes + @@ -278,7 +302,7 @@ exports[`Renders list control 1`] = ` } } > - - Clear form + @@ -351,7 +379,11 @@ exports[`Renders list control 1`] = ` onClick={[Function]} type="button" > - Cancel changes + @@ -373,7 +405,11 @@ exports[`Renders list control 1`] = ` onClick={[Function]} type="button" > - Apply changes + @@ -405,7 +441,7 @@ exports[`Renders range control 1`] = ` } } > - - Clear form + @@ -478,7 +518,11 @@ exports[`Renders range control 1`] = ` onClick={[Function]} type="button" > - Cancel changes + @@ -500,7 +544,11 @@ exports[`Renders range control 1`] = ` onClick={[Function]} type="button" > - Apply changes + diff --git a/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.js b/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.js index bf96d98efca6d7..112ec9c43ae9f0 100644 --- a/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.js +++ b/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.js @@ -28,6 +28,8 @@ import { EuiFormRow, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + export class InputControlVis extends Component { constructor(props) { super(props); @@ -104,7 +106,7 @@ export class InputControlVis extends Component { disabled={!this.props.hasValues()} data-test-subj="inputControlClearBtn" > - Clear form + @@ -115,7 +117,7 @@ export class InputControlVis extends Component { disabled={!this.props.hasChanges()} data-test-subj="inputControlCancelBtn" > - Cancel changes + @@ -127,7 +129,7 @@ export class InputControlVis extends Component { disabled={!this.props.hasChanges()} data-test-subj="inputControlSubmitBtn" > - Apply changes + diff --git a/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.test.js b/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.test.js index e72a5cf7f6afbe..5feb0e447c0a18 100644 --- a/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.test.js +++ b/src/core_plugins/input_control_vis/public/components/vis/input_control_vis.test.js @@ -19,7 +19,8 @@ import React from 'react'; import sinon from 'sinon'; -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { @@ -129,7 +130,7 @@ test('Clear btns enabled when there are values', () => { }); test('clearControls', () => { - const component = mount( { }); test('submitFilters', () => { - const component = mount( { }); test('resetControls', () => { - const component = mount( ); @@ -81,7 +87,10 @@ export class ListControl extends Component { return ( { }); test('renders ListControl', () => { - const component = shallow( { }); test('disableMsg', () => { - const component = shallow( { return state; }; -export class RangeControl extends Component { +class RangeControlUi extends Component { constructor(props) { super(props); @@ -93,12 +94,18 @@ export class RangeControl extends Component { if ((!isMinValid && isMaxValid) || (isMinValid && !isMaxValid)) { isRangeValid = false; - errorMessage = 'both min and max must be set'; + errorMessage = this.props.intl.formatMessage({ + id: 'inputControl.vis.rangeControl.minMaxValidErrorMessage', + defaultMessage: 'both min and max must be set' + }); } if (isMinValid && isMaxValid && max < min) { isRangeValid = false; - errorMessage = 'max must be greater or equal to min'; + errorMessage = this.props.intl.formatMessage({ + id: 'inputControl.vis.rangeControl.maxValidErrorMessage', + defaultMessage: 'max must be greater or equal to min' + }); } this.setState({ @@ -196,8 +203,10 @@ export class RangeControl extends Component { } } -RangeControl.propTypes = { +RangeControlUi.propTypes = { control: PropTypes.object.isRequired, controlIndex: PropTypes.number.isRequired, stageFilter: PropTypes.func.isRequired }; + +export const RangeControl = injectI18n(RangeControlUi); \ No newline at end of file diff --git a/src/core_plugins/input_control_vis/public/components/vis/range_control.test.js b/src/core_plugins/input_control_vis/public/components/vis/range_control.test.js index 0d12cf496b71fd..2697a898d8566b 100644 --- a/src/core_plugins/input_control_vis/public/components/vis/range_control.test.js +++ b/src/core_plugins/input_control_vis/public/components/vis/range_control.test.js @@ -18,7 +18,7 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { @@ -43,7 +43,7 @@ const control = { }; test('renders RangeControl', () => { - const component = shallow( {}} @@ -66,7 +66,7 @@ test('disabled', () => { return false; } }; - const component = shallow( {}} @@ -75,7 +75,7 @@ test('disabled', () => { }); describe('min and max input values', () => { - const component = mount( {}} diff --git a/src/core_plugins/input_control_vis/public/control/control.js b/src/core_plugins/input_control_vis/public/control/control.js index 7f1bcfb4e398ac..636b9ba699d3e7 100644 --- a/src/core_plugins/input_control_vis/public/control/control.js +++ b/src/core_plugins/input_control_vis/public/control/control.js @@ -17,16 +17,24 @@ * under the License. */ +/* eslint-disable no-multi-str*/ + import _ from 'lodash'; +import { i18n } from '@kbn/i18n'; export function noValuesDisableMsg(fieldName, indexPatternName) { - return `Filtering occurs on the "${fieldName}" field, -which doesn't exist on any documents in the "${indexPatternName}" index pattern. -Choose a different field or index documents that contain values for this field.`; + return i18n.translate('inputControl.control.noValuesDisableTootip', { + defaultMessage: 'Filtering occurs on the "{fieldName}" field, which doesn\'t exist on any documents in the "{indexPatternName}" \ +index pattern. Choose a different field or index documents that contain values for this field.', + values: { fieldName: fieldName, indexPatternName: indexPatternName } + }); } export function noIndexPatternMsg(indexPatternId) { - return `Could not locate index-pattern id: ${indexPatternId}.`; + return i18n.translate('inputControl.control.noIndexPatternTootip', { + defaultMessage: 'Could not locate index-pattern id: {indexPatternId}.', + values: { indexPatternId } + }); } export class Control { @@ -43,7 +51,9 @@ export class Control { // restore state from kibana filter context this.reset(); // disable until initialized - this.disable('Control has not been initialized'); + this.disable(i18n.translate('inputControl.control.notInitializedTootip', { + defaultMessage: 'Control has not been initialized' + })); } async fetch() { diff --git a/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.js b/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.js index 1122e104cfe568..4c3a9265c3b671 100644 --- a/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.js +++ b/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.js @@ -61,10 +61,17 @@ export class PhraseFilterManager extends FilterManager { return; } - return kbnFilters + const values = kbnFilters .map((kbnFilter) => { return this._getValueFromFilter(kbnFilter); }) + .filter(value => value != null); + + if (values.length === 0) { + return; + } + + return values .reduce((accumulator, currentValue) => { return accumulator.concat(currentValue); }, []) diff --git a/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.js b/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.js index 0246cb02beeec4..98b6eef1da51ab 100644 --- a/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.js +++ b/src/core_plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.js @@ -154,6 +154,22 @@ describe('PhraseFilterManager', function () { ]); expect(filterManager.getValueFromFilterBar()).to.eql([{ value: 'ios', label: 'ios' }, { value: 'win xp', label: 'win xp' }]); }); + + test('should return undefined when filter value can not be extracted from Kibana filter', function () { + filterManager.setMockFilters([ + { + query: { + match: { + myFieldWhichIsNotField1: { + query: 'ios', + type: 'phrase' + } + } + } + } + ]); + expect(filterManager.getValueFromFilterBar()).to.eql(undefined); + }); }); }); diff --git a/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.js b/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.js index 2217f0fc945b70..3ce646716f48cb 100644 --- a/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.js +++ b/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.js @@ -69,14 +69,17 @@ export class RangeFilterManager extends FilterManager { return; } - let range = null; + let range; if (_.has(kbnFilters[0], 'script')) { range = _.get(kbnFilters[0], 'script.script.params'); } else { range = _.get(kbnFilters[0], ['range', this.fieldName]); } - return fromRange(range); + if (!range) { + return; + } + return fromRange(range); } } diff --git a/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.js b/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.js index 369afd735bd147..be700ad72ab425 100644 --- a/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.js +++ b/src/core_plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.js @@ -91,6 +91,20 @@ describe('RangeFilterManager', function () { expect(value).to.have.property('max'); expect(value.max).to.be(3); }); + + test('should return undefined when filter value can not be extracted from Kibana filter', function () { + filterManager.setMockFilters([ + { + range: { + myFieldWhichIsNotField1: { + gte: 1, + lte: 3 + } + } + } + ]); + expect(filterManager.getValueFromFilterBar()).to.eql(undefined); + }); }); }); diff --git a/src/core_plugins/input_control_vis/public/control/list_control_factory.js b/src/core_plugins/input_control_vis/public/control/list_control_factory.js index bb9a05711bfcd3..65471f15657bcf 100644 --- a/src/core_plugins/input_control_vis/public/control/list_control_factory.js +++ b/src/core_plugins/input_control_vis/public/control/list_control_factory.js @@ -25,6 +25,7 @@ import { } from './control'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; import { createSearchSource } from './create_search_source'; +import { i18n } from '@kbn/i18n'; function getEscapedQuery(query = '') { // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators @@ -75,7 +76,10 @@ class ListControl extends Control { let ancestorFilters; if (this.hasAncestors()) { if (this.hasUnsetAncestor()) { - this.disable(`Disabled until '${this.ancestors[0].label}' is set.`); + this.disable(i18n.translate('inputControl.listControl.disableTootip', { + defaultMessage: 'Disabled until \'{label}\' is set.', + values: { label: this.ancestors[0].label } + })); return; } @@ -113,7 +117,10 @@ class ListControl extends Control { try { resp = await searchSource.fetch(); } catch(error) { - this.disable(`Unable to fetch terms, error: ${error.message}`); + this.disable(i18n.translate('inputControl.listControl.unableToFetchTootip', { + defaultMessage: 'Unable to fetch terms, error: {errorMessage}', + values: { errorMessage: error.message } + })); return; } diff --git a/src/core_plugins/input_control_vis/public/control/range_control_factory.js b/src/core_plugins/input_control_vis/public/control/range_control_factory.js index b7e5e114b949df..4e8cb18a82100b 100644 --- a/src/core_plugins/input_control_vis/public/control/range_control_factory.js +++ b/src/core_plugins/input_control_vis/public/control/range_control_factory.js @@ -25,6 +25,7 @@ import { } from './control'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { createSearchSource } from './create_search_source'; +import { i18n } from '@kbn/i18n'; const minMaxAgg = (field) => { const aggBody = {}; @@ -64,7 +65,10 @@ class RangeControl extends Control { try { resp = await searchSource.fetch(); } catch(error) { - this.disable(`Unable to fetch range min and max, error: ${error.message}`); + this.disable(i18n.translate('inputControl.rangeControl.unableToFetchTootip', { + defaultMessage: 'Unable to fetch range min and max, error: {errorMessage}', + values: { errorMessage: error.message } + })); return; } diff --git a/src/core_plugins/input_control_vis/public/register_vis.js b/src/core_plugins/input_control_vis/public/register_vis.js index 4bca018e06599f..41c659f7896b12 100644 --- a/src/core_plugins/input_control_vis/public/register_vis.js +++ b/src/core_plugins/input_control_vis/public/register_vis.js @@ -27,6 +27,7 @@ import { OptionsTab } from './components/editor/options_tab'; import { defaultFeedbackMessage } from 'ui/vis/default_feedback_message'; import image from './images/icon-input-control.svg'; import { Status } from 'ui/vis/update_status'; +import { i18n } from '@kbn/i18n'; function InputControlVisProvider(Private) { const VisFactory = Private(VisFactoryProvider); @@ -34,9 +35,13 @@ function InputControlVisProvider(Private) { // return the visType object, which kibana will use to display and configure new Vis object of this type. return VisFactory.createBaseVisualization({ name: 'input_control_vis', - title: 'Controls', + title: i18n.translate('inputControl.register.controlsTitle', { + defaultMessage: 'Controls' + }), image, - description: 'Create interactive controls for easy dashboard manipulation.', + description: i18n.translate('inputControl.register.controlsDescription', { + defaultMessage: 'Create interactive controls for easy dashboard manipulation.' + }), category: CATEGORY.OTHER, stage: 'lab', requiresUpdateStatus: [Status.PARAMS, Status.TIME], @@ -55,12 +60,16 @@ function InputControlVisProvider(Private) { optionTabs: [ { name: 'controls', - title: 'Controls', + title: i18n.translate('inputControl.register.tabs.controlsTitle', { + defaultMessage: 'Controls' + }), editor: ControlsTab }, { name: 'options', - title: 'Options', + title: i18n.translate('inputControl.register.tabs.optionsTitle', { + defaultMessage: 'Options' + }), editor: OptionsTab } ] diff --git a/src/core_plugins/input_control_vis/public/vis_controller.js b/src/core_plugins/input_control_vis/public/vis_controller.js index 480fd8bd13f54b..3a92c7e10b229b 100644 --- a/src/core_plugins/input_control_vis/public/vis_controller.js +++ b/src/core_plugins/input_control_vis/public/vis_controller.js @@ -23,6 +23,8 @@ import { InputControlVis } from './components/vis/input_control_vis'; import { controlFactory } from './control/control_factory'; import { getLineageMap } from './lineage'; +import { I18nProvider } from '@kbn/i18n/react'; + class VisController { constructor(el, vis) { this.el = el; @@ -50,17 +52,19 @@ class VisController { drawVis = () => { render( - , + + + , this.el); } diff --git a/src/core_plugins/inspector_views/public/data/__snapshots__/data_view.test.js.snap b/src/core_plugins/inspector_views/public/data/__snapshots__/data_view.test.js.snap index 65dbad218a28ab..86ba5ce7cd2bfb 100644 --- a/src/core_plugins/inspector_views/public/data/__snapshots__/data_view.test.js.snap +++ b/src/core_plugins/inspector_views/public/data/__snapshots__/data_view.test.js.snap @@ -29,11 +29,11 @@ exports[`Inspector Data View component should render empty state 1`] = ` > +

The element did not provide any data.

- +
} iconColor="subdued" title={ diff --git a/src/core_plugins/kbn_vislib_vis_types/public/editors/__tests__/point_series.js b/src/core_plugins/kbn_vislib_vis_types/public/editors/__tests__/point_series.js index 57db9b330962d1..a4384ae2a1fea9 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/editors/__tests__/point_series.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/editors/__tests__/point_series.js @@ -87,7 +87,7 @@ describe('point series editor', function () { }); it('should update series when new agg is added', function () { - const aggConfig = new AggConfig($parentScope.vis, { type: 'avg', schema: 'metric', params: { field: 'bytes' } }); + const aggConfig = new AggConfig($parentScope.vis.aggs, { type: 'avg', schema: 'metric', params: { field: 'bytes' } }); $parentScope.vis.aggs.push(aggConfig); $parentScope.$digest(); expect($parentScope.editorState.params.seriesParams.length).to.be(2); diff --git a/src/core_plugins/kbn_vislib_vis_types/public/gauge.js b/src/core_plugins/kbn_vislib_vis_types/public/gauge.js index bcf5e3f6850c7a..fae7248e82e1c0 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/gauge.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/gauge.js @@ -110,6 +110,7 @@ export default function GaugeVisType(Private) { aggFilter: ['!geohash_grid', '!filter'] } ]) - } + }, + useCustomNoDataScreen: true }); } diff --git a/src/core_plugins/kbn_vislib_vis_types/public/goal.js b/src/core_plugins/kbn_vislib_vis_types/public/goal.js index 5d94ebb0f5d4b5..2ce74fd4d98124 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/goal.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/goal.js @@ -105,6 +105,7 @@ export default function GoalVisType(Private) { aggFilter: ['!geohash_grid', '!filter'] } ]) - } + }, + useCustomNoDataScreen: true }); } diff --git a/src/core_plugins/kibana/common/field_formats/types/source.js b/src/core_plugins/kibana/common/field_formats/types/source.js index 81a6eb51eb20dd..61509a746c163f 100644 --- a/src/core_plugins/kibana/common/field_formats/types/source.js +++ b/src/core_plugins/kibana/common/field_formats/types/source.js @@ -48,7 +48,7 @@ export function createSourceFormat(FieldFormat) { SourceFormat.prototype._convert = { text: (value) => toJson(value), html: function sourceToHtml(source, field, hit) { - if (!field) return this.getConverterFor('text')(source, field, hit); + if (!field) return _.escape(this.getConverterFor('text')(source)); const highlights = (hit && hit.highlight) || {}; const formatted = field.indexPattern.formatHit(hit); diff --git a/src/core_plugins/kibana/common/tutorials/filebeat_instructions.js b/src/core_plugins/kibana/common/tutorials/filebeat_instructions.js index 6ba8f0ec697cf2..b96f1f6b1b7d03 100644 --- a/src/core_plugins/kibana/common/tutorials/filebeat_instructions.js +++ b/src/core_plugins/kibana/common/tutorials/filebeat_instructions.js @@ -17,278 +17,476 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { INSTRUCTION_VARIANT } from './instruction_variant'; -import { - TRYCLOUD_OPTION1, - TRYCLOUD_OPTION2 -} from './onprem_cloud_instructions'; +import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions'; -export const FILEBEAT_INSTRUCTIONS = { +export const createFilebeatInstructions = () => ({ INSTALL: { OSX: { - title: 'Download and install Filebeat', - textPre: 'First time using Filebeat? See the [Getting Started Guide]' + - '({config.docs.beats.filebeat}/filebeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.osxTitle', { + defaultMessage: 'Download and install Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.osxTextPre', { + defaultMessage: 'First time using Filebeat? See the [Getting Started Guide]({linkUrl}).', + values: { + linkUrl: '{config.docs.beats.filebeat}/filebeat-getting-started.html', + }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-{config.kibana.version}-darwin-x86_64.tar.gz', 'tar xzvf filebeat-{config.kibana.version}-darwin-x86_64.tar.gz', 'cd filebeat-{config.kibana.version}-darwin-x86_64/', - ] + ], }, DEB: { - title: 'Download and install Filebeat', - textPre: 'First time using Filebeat? See the [Getting Started Guide]' + - '({config.docs.beats.filebeat}/filebeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.debTitle', { + defaultMessage: 'Download and install Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.debTextPre', { + defaultMessage: 'First time using Filebeat? See the [Getting Started Guide]({linkUrl}).', + values: { + linkUrl: '{config.docs.beats.filebeat}/filebeat-getting-started.html', + }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-{config.kibana.version}-amd64.deb', - 'sudo dpkg -i filebeat-{config.kibana.version}-amd64.deb' + 'sudo dpkg -i filebeat-{config.kibana.version}-amd64.deb', ], - textPost: 'Looking for the 32-bit packages? See the [Download page](https://www.elastic.co/downloads/beats/filebeat).' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.debTextPost', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({linkUrl}).', + values: { + linkUrl: 'https://www.elastic.co/downloads/beats/filebeat', + }, + }), }, RPM: { - title: 'Download and install Filebeat', - textPre: 'First time using Filebeat? See the [Getting Started Guide]' + - '({config.docs.beats.filebeat}/filebeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.rpmTitle', { + defaultMessage: 'Download and install Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.rpmTextPre', { + defaultMessage: 'First time using Filebeat? See the [Getting Started Guide]({linkUrl}).', + values: { + linkUrl: '{config.docs.beats.filebeat}/filebeat-getting-started.html', + }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-{config.kibana.version}-x86_64.rpm', - 'sudo rpm -vi filebeat-{config.kibana.version}-x86_64.rpm' + 'sudo rpm -vi filebeat-{config.kibana.version}-x86_64.rpm', ], - textPost: 'Looking for the 32-bit packages? See the [Download page](https://www.elastic.co/downloads/beats/filebeat).' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.rpmTextPost', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({linkUrl}).', + values: { + linkUrl: 'https://www.elastic.co/downloads/beats/filebeat', + }, + }), }, WINDOWS: { - title: 'Download and install Filebeat', - textPre: 'First time using Filebeat? See the [Getting Started Guide]' + - '({config.docs.beats.filebeat}/filebeat-getting-started.html).\n' + - '1. Download the Filebeat Windows zip file from the [Download](https://www.elastic.co/downloads/beats/filebeat) page.\n' + - '2. Extract the contents of the zip file into `C:\\Program Files`.\n' + - '3. Rename the `filebeat-{config.kibana.version}-windows` directory to `Filebeat`.\n' + - '4. Open a PowerShell prompt as an Administrator (right-click the PowerShell icon and select' + - ' **Run As Administrator**). If you are running Windows XP, you might need to download and install PowerShell.\n' + - '5. From the PowerShell prompt, run the following commands to install Filebeat as a Windows service.', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.windowsTitle', { + defaultMessage: 'Download and install Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.windowsTextPre', { + defaultMessage: 'First time using Filebeat? See the [Getting Started Guide]({guideLinkUrl}).\n\ + 1. Download the Filebeat Windows zip file from the [Download]({filebeatLinkUrl}) page.\n\ + 2. Extract the contents of the zip file into {folderPath}.\n\ + 3. Rename the `{directoryName}` directory to `Filebeat`.\n\ + 4. Open a PowerShell prompt as an Administrator (right-click the PowerShell icon and select \ +**Run As Administrator**). If you are running Windows XP, you might need to download and install PowerShell.\n\ + 5. From the PowerShell prompt, run the following commands to install Filebeat as a Windows service.', + values: { + folderPath: '`C:\\Program Files`', + guideLinkUrl: '{config.docs.beats.filebeat}/filebeat-getting-started.html', + filebeatLinkUrl: 'https://www.elastic.co/downloads/beats/filebeat', + directoryName: 'filebeat-{config.kibana.version}-windows', + } + }), commands: [ 'PS > cd C:\\Program Files\\Filebeat', - 'PS C:\\Program Files\\Filebeat> .\\install-service-filebeat.ps1' + 'PS C:\\Program Files\\Filebeat> .\\install-service-filebeat.ps1', ], - textPost: 'Modify the settings under `output.elasticsearch` in the ' + - '`C:\\Program Files\\Filebeat\\filebeat.yml` file to point to your Elasticsearch installation.' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.install.windowsTextPost', { + defaultMessage: 'Modify the settings under {propertyName} in the {filebeatPath} file to point to your Elasticsearch installation.', + values: { + propertyName: '`output.elasticsearch`', + filebeatPath: '`C:\\Program Files\\Filebeat\\filebeat.yml`', + } + }), } }, START: { OSX: { - title: 'Start Filebeat', - textPre: 'The `setup` command loads the Kibana dashboards.' + - ' If the dashboards are already set up, omit this command.', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.osxTitle', { + defaultMessage: 'Start Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.osxTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ './filebeat setup', './filebeat -e', ] }, DEB: { - title: 'Start Filebeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.debTitle', { + defaultMessage: 'Start Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.debTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'sudo filebeat setup', 'sudo service filebeat start', ] }, RPM: { - title: 'Start Filebeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.rpmTitle', { + defaultMessage: 'Start Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.rpmTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'sudo filebeat setup', 'sudo service filebeat start', ], }, WINDOWS: { - title: 'Start Filebeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.windowsTitle', { + defaultMessage: 'Start Filebeat', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.start.windowsTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'PS C:\\Program Files\\Filebeat> filebeat.exe setup', 'PS C:\\Program Files\\Filebeat> Start-Service filebeat', - ] - } + ], + }, }, CONFIG: { OSX: { - title: 'Edit the configuration', - textPre: 'Modify `filebeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.osxTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.osxTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`filebeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.osxTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, DEB: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/filebeat/filebeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.debTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.debTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`/etc/filebeat/filebeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.debTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, RPM: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/filebeat/filebeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.rpmTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.rpmTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`/etc/filebeat/filebeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.rpmTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, WINDOWS: { - title: 'Edit the configuration', - textPre: 'Modify `C:\\Program Files\\Filebeat\\filebeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.windowsTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.windowsTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`C:\\Program Files\\Filebeat\\filebeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.filebeatInstructions.config.windowsTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), } }, PLUGINS: { GEOIP_AND_UA: { - title: 'Install Elasticsearch GeoIP and user agent plugins', - textPre: 'This module requires two Elasticsearch plugins that are not ' + - 'installed by default.\n\nFrom the Elasticsearch installation folder, run:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.plugins.geoipUaTitle', { + defaultMessage: 'Install Elasticsearch GeoIP and user agent plugins', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.plugins.geoipUaTextPre', { + defaultMessage: 'This module requires two Elasticsearch plugins that are not installed by default.\n\n\ +From the Elasticsearch installation folder, run:', + }), commands: [ 'bin/elasticsearch-plugin install ingest-geoip', - 'bin/elasticsearch-plugin install ingest-user-agent' - ] + 'bin/elasticsearch-plugin install ingest-user-agent', + ], }, GEOIP: { - title: 'Install Elasticsearch GeoIP plugin', - textPre: 'This module requires an Elasticsearch plugin that is not ' + - 'installed by default.\n\nFrom the Elasticsearch installation folder, run:', + title: i18n.translate('kbn.common.tutorials.filebeatInstructions.plugins.geoipTitle', { + defaultMessage: 'Install Elasticsearch GeoIP plugin', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatInstructions.plugins.geoipTextPre', { + defaultMessage: 'This module requires two Elasticsearch plugins that are not installed by default.\n\n\ +From the Elasticsearch installation folder, run:', + }), commands: [ 'bin/elasticsearch-plugin install ingest-geoip' ] } } -}; +}); -export const FILEBEAT_CLOUD_INSTRUCTIONS = { +export const createFilebeatCloudInstructions = () => ({ CONFIG: { OSX: { - title: 'Edit the configuration', - textPre: 'Modify `filebeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.osxTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.osxTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`filebeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.osxTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, DEB: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/filebeat/filebeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.debTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.debTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`/etc/filebeat/filebeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.debTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, RPM: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/filebeat/filebeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.rpmTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.rpmTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`/etc/filebeat/filebeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.rpmTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, WINDOWS: { - title: 'Edit the configuration', - textPre: 'Modify `C:\\Program Files\\Filebeat\\filebeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.windowsTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.windowsTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`C:\\Program Files\\Filebeat\\filebeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.filebeatCloudInstructions.config.windowsTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), } } -}; +}); export function filebeatEnableInstructions(moduleName) { return { OSX: { - title: 'Enable and configure the ' + moduleName + ' module', - textPre: 'From the installation directory, run:', + title: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.osxTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.osxTextPre', { + defaultMessage: 'From the installation directory, run:', + }), commands: [ './filebeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.osxTextPost', { + defaultMessage: 'Modify the settings in the `modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, DEB: { - title: 'Enable and configure the ' + moduleName + ' module', + title: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.debTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), commands: [ 'sudo filebeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `/etc/filebeat/modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.debTextPost', { + defaultMessage: 'Modify the settings in the `/etc/filebeat/modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, RPM: { - title: 'Enable and configure the ' + moduleName + ' module', + title: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.rpmTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), commands: [ 'sudo filebeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `/etc/filebeat/modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.rpmTextPost', { + defaultMessage: 'Modify the settings in the `/etc/filebeat/modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, WINDOWS: { - title: 'Enable and configure the ' + moduleName + ' module', - textPre: 'From the `C:\\Program Files\\Filebeat` folder, run:', + title: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.windowsTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), + textPre: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.windowsTextPre', { + defaultMessage: 'From the {path} folder, run:', + values: { path: `C:\\Program Files\\Filebeat` }, + }), commands: [ 'PS C:\\Program Files\\Filebeat> filebeat.exe modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.filebeatEnableInstructions.windowsTextPost', { + defaultMessage: 'Modify the settings in the `modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), } }; } export function filebeatStatusCheck(moduleName) { return { - title: 'Module status', - text: 'Check that data is received from the Filebeat `' + moduleName + '` module', - btnLabel: 'Check data', - success: 'Data successfully received from this module', - error: 'No data has been received from this module yet', + title: i18n.translate('kbn.common.tutorials.filebeatStatusCheck.title', { + defaultMessage: 'Module status', + }), + text: i18n.translate('kbn.common.tutorials.filebeatStatusCheck.text', { + defaultMessage: 'Check that data is received from the Filebeat `{moduleName}` module', + values: { moduleName }, + }), + btnLabel: i18n.translate('kbn.common.tutorials.filebeatStatusCheck.buttonLabel', { + defaultMessage: 'Check data', + }), + success: i18n.translate('kbn.common.tutorials.filebeatStatusCheck.successText', { + defaultMessage: 'Data successfully received from this module', + }), + error: i18n.translate('kbn.common.tutorials.filebeatStatusCheck.errorText', { + defaultMessage: 'No data has been received from this module yet', + }), esHitsCheck: { index: 'filebeat-*', query: { bool: { filter: { term: { - 'fileset.module': moduleName - } - } - } - } - } + 'fileset.module': moduleName, + }, + }, + }, + }, + }, }; } export function onPremInstructions(moduleName, platforms, geoipRequired, uaRequired) { + const FILEBEAT_INSTRUCTIONS = createFilebeatInstructions(); + const variants = []; for (let i = 0; i < platforms.length; i++) { const platform = platforms[i]; @@ -304,21 +502,27 @@ export function onPremInstructions(moduleName, platforms, geoipRequired, uaRequi instructions.push(FILEBEAT_INSTRUCTIONS.START[platform]); variants.push({ id: INSTRUCTION_VARIANT[platform], - instructions: instructions + instructions: instructions, }); } return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.filebeat.premInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: variants, - statusCheck: filebeatStatusCheck(moduleName) - } - ] + statusCheck: filebeatStatusCheck(moduleName), + }, + ], }; } export function onPremCloudInstructions(moduleName, platforms) { + const FILEBEAT_INSTRUCTIONS = createFilebeatInstructions(); + const TRYCLOUD_OPTION1 = createTrycloudOption1(); + const TRYCLOUD_OPTION2 = createTrycloudOption2(); + const variants = []; for (let i = 0; i < platforms.length; i++) { const platform = platforms[i]; @@ -330,23 +534,28 @@ export function onPremCloudInstructions(moduleName, platforms) { FILEBEAT_INSTRUCTIONS.INSTALL[platform], FILEBEAT_INSTRUCTIONS.CONFIG[platform], filebeatEnableInstructions(moduleName)[platform], - FILEBEAT_INSTRUCTIONS.START[platform] - ] + FILEBEAT_INSTRUCTIONS.START[platform], + ], }); } return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.filebeat.premCloudInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: variants, - statusCheck: filebeatStatusCheck(moduleName) - } - ] + statusCheck: filebeatStatusCheck(moduleName), + }, + ], }; } export function cloudInstructions(moduleName, platforms) { + const FILEBEAT_INSTRUCTIONS = createFilebeatInstructions(); + const FILEBEAT_CLOUD_INSTRUCTIONS = createFilebeatCloudInstructions(); + const variants = []; for (let i = 0; i < platforms.length; i++) { const platform = platforms[i]; @@ -356,18 +565,20 @@ export function cloudInstructions(moduleName, platforms) { FILEBEAT_INSTRUCTIONS.INSTALL[platform], FILEBEAT_CLOUD_INSTRUCTIONS.CONFIG[platform], filebeatEnableInstructions(moduleName)[platform], - FILEBEAT_INSTRUCTIONS.START[platform] - ] + FILEBEAT_INSTRUCTIONS.START[platform], + ], }); } return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.filebeat.cloudInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: variants, - statusCheck: filebeatStatusCheck(moduleName) - } - ] + statusCheck: filebeatStatusCheck(moduleName), + }, + ], }; } diff --git a/src/core_plugins/kibana/common/tutorials/logstash_instructions.js b/src/core_plugins/kibana/common/tutorials/logstash_instructions.js index c231fda0a6152c..5203f44ef9c8dc 100644 --- a/src/core_plugins/kibana/common/tutorials/logstash_instructions.js +++ b/src/core_plugins/kibana/common/tutorials/logstash_instructions.js @@ -17,35 +17,58 @@ * under the License. */ -export const LOGSTASH_INSTRUCTIONS = { +import { i18n } from '@kbn/i18n'; + +export const createLogstashInstructions = () => ({ INSTALL: { OSX: [ { - title: 'Download and install the Java Runtime Environment', - textPre: 'Follow the installation instructions [here](https://docs.oracle.com/javase/8/docs/technotes/guides/install/mac_jre.html).' + title: i18n.translate('kbn.common.tutorials.logstashInstructions.install.java.osxTitle', { + defaultMessage: 'Download and install the Java Runtime Environment', + }), + textPre: i18n.translate('kbn.common.tutorials.logstashInstructions.install.java.osxTextPre', { + defaultMessage: 'Follow the installation instructions [here]({link}).', + values: { link: 'https://docs.oracle.com/javase/8/docs/technotes/guides/install/mac_jre.html' }, + }), }, { - title: 'Download and install Logstash', - textPre: 'First time using Logstash? See the ' + - '[Getting Started Guide]({config.docs.base_url}guide/en/logstash/current/getting-started-with-logstash.html).', + title: i18n.translate('kbn.common.tutorials.logstashInstructions.install.logstash.osxTitle', { + defaultMessage: 'Download and install Logstash', + }), + textPre: i18n.translate('kbn.common.tutorials.logstashInstructions.install.logstash.osxTextPre', { + defaultMessage: 'First time using Logstash? See the [Getting Started Guide]({link}).', + values: { link: '{config.docs.base_url}guide/en/logstash/current/getting-started-with-logstash.html' }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/logstash/logstash-{config.kibana.version}.tar.gz', - 'tar xzvf logstash-{config.kibana.version}.tar.gz' - ] - } + 'tar xzvf logstash-{config.kibana.version}.tar.gz', + ], + }, ], WINDOWS: [ { - title: 'Download and install the Java Runtime Environment', - textPre: 'Follow the installation instructions [here](https://docs.oracle.com/javase/8/docs/technotes/guides/install/windows_jre_install.html).' + title: i18n.translate('kbn.common.tutorials.logstashInstructions.install.java.windowsTitle', { + defaultMessage: 'Download and install the Java Runtime Environment', + }), + textPre: i18n.translate('kbn.common.tutorials.logstashInstructions.install.java.windowsTextPre', { + defaultMessage: 'Follow the installation instructions [here]({link}).', + values: { link: 'https://docs.oracle.com/javase/8/docs/technotes/guides/install/windows_jre_install.html' }, + }), }, { - title: 'Download and install Logstash', - textPre: 'First time using Logstash? See the ' + - '[Getting Started Guide]({config.docs.base_url}guide/en/logstash/current/getting-started-with-logstash.html).\n' + - ' 1. [Download](https://artifacts.elastic.co/downloads/logstash/logstash-{config.kibana.version}.zip) the Logstash Windows zip file.\n' + - ' 2. Extract the contents of the zip file.' + title: i18n.translate('kbn.common.tutorials.logstashInstructions.install.logstash.windowsTitle', { + defaultMessage: 'Download and install Logstash', + }), + textPre: i18n.translate('kbn.common.tutorials.logstashInstructions.install.logstash.windowsTextPre', { + defaultMessage: 'First time using Logstash? See the [Getting Started Guide]({logstashLink}).\n\ + 1. [Download]({elasticLink}) the Logstash Windows zip file.\n\ + 2. Extract the contents of the zip file.', + values: { + logstashLink: '{config.docs.base_url}guide/en/logstash/current/getting-started-with-logstash.html', + elasticLink: 'https://artifacts.elastic.co/downloads/logstash/logstash-{config.kibana.version}.zip' + }, + }), } ], - } -}; + }, +}); diff --git a/src/core_plugins/kibana/common/tutorials/metricbeat_instructions.js b/src/core_plugins/kibana/common/tutorials/metricbeat_instructions.js index 13a9aabb5a60b9..b15d3aaab1e7ed 100644 --- a/src/core_plugins/kibana/common/tutorials/metricbeat_instructions.js +++ b/src/core_plugins/kibana/common/tutorials/metricbeat_instructions.js @@ -17,260 +17,442 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { INSTRUCTION_VARIANT } from './instruction_variant'; -import { TRYCLOUD_OPTION1, TRYCLOUD_OPTION2 } from './onprem_cloud_instructions'; +import { createTrycloudOption1, createTrycloudOption2 } from './onprem_cloud_instructions'; -export const METRICBEAT_INSTRUCTIONS = { +export const createMetricbeatInstructions = () => ({ INSTALL: { OSX: { - title: 'Download and install Metricbeat', - textPre: 'First time using Metricbeat? See the [Getting Started Guide]' + - '({config.docs.beats.metricbeat}/metricbeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.osxTitle', { + defaultMessage: 'Download and install Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.osxTextPre', { + defaultMessage: 'First time using Metricbeat? See the [Getting Started Guide]({link}).', + values: { link: '{config.docs.beats.metricbeat}/metricbeat-getting-started.html' }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/metricbeat/metricbeat-{config.kibana.version}-darwin-x86_64.tar.gz', 'tar xzvf metricbeat-{config.kibana.version}-darwin-x86_64.tar.gz', 'cd metricbeat-{config.kibana.version}-darwin-x86_64/', - ] + ], }, DEB: { - title: 'Download and install Metricbeat', - textPre: 'First time using Metricbeat? See the [Getting Started Guide]' + - '({config.docs.beats.metricbeat}/metricbeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.debTitle', { + defaultMessage: 'Download and install Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.debTextPre', { + defaultMessage: 'First time using Metricbeat? See the [Getting Started Guide]({link}).', + values: { link: '{config.docs.beats.metricbeat}/metricbeat-getting-started.html' }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/metricbeat/metricbeat-{config.kibana.version}-amd64.deb', - 'sudo dpkg -i metricbeat-{config.kibana.version}-amd64.deb' + 'sudo dpkg -i metricbeat-{config.kibana.version}-amd64.deb', ], - textPost: 'Looking for the 32-bit packages? See the [Download page](https://www.elastic.co/downloads/beats/metricbeat).' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.debTextPost', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({link}).', + values: { link: 'https://www.elastic.co/downloads/beats/metricbeat' }, + }), }, RPM: { - title: 'Download and install Metricbeat', - textPre: 'First time using Metricbeat? See the [Getting Started Guide]' + - '({config.docs.beats.metricbeat}/metricbeat-getting-started.html).', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.rpmTitle', { + defaultMessage: 'Download and install Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.rpmTextPre', { + defaultMessage: 'First time using Metricbeat? See the [Getting Started Guide]({link}).', + values: { link: '{config.docs.beats.metricbeat}/metricbeat-getting-started.html' }, + }), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/beats/metricbeat/metricbeat-{config.kibana.version}-x86_64.rpm', - 'sudo rpm -vi metricbeat-{config.kibana.version}-x86_64.rpm' + 'sudo rpm -vi metricbeat-{config.kibana.version}-x86_64.rpm', ], - textPost: 'Looking for the 32-bit packages? See the [Download page](https://www.elastic.co/downloads/beats/metricbeat).' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.debTextPost', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({link}).', + values: { link: 'https://www.elastic.co/downloads/beats/metricbeat' }, + }), }, WINDOWS: { - title: 'Download and install Metricbeat', - textPre: 'First time using Metricbeat? See the [Getting Started Guide]' + - '({config.docs.beats.metricbeat}/metricbeat-getting-started.html).\n' + - '1. Download the Metricbeat Windows zip file from the [Download](https://www.elastic.co/downloads/beats/metricbeat) page.\n' + - '2. Extract the contents of the zip file into `C:\\Program Files`.\n' + - '3. Rename the `metricbeat-{config.kibana.version}-windows` directory to `Metricbeat`.\n' + - '4. Open a PowerShell prompt as an Administrator (right-click the PowerShell icon and select' + - ' **Run As Administrator**). If you are running Windows XP, you might need to download and install PowerShell.\n' + - '5. From the PowerShell prompt, run the following commands to install Metricbeat as a Windows service.', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.windowsTitle', { + defaultMessage: 'Download and install Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.windowsTextPre', { + defaultMessage: 'First time using Metricbeat? See the [Getting Started Guide]({metricbeatLink}).\n\ + 1. Download the Metricbeat Windows zip file from the [Download]({elasticLink}) page.\n\ + 2. Extract the contents of the zip file into {folderPath}.\n\ + 3. Rename the {directoryName} directory to `Metricbeat`.\n\ + 4. Open a PowerShell prompt as an Administrator (right-click the PowerShell icon and select \ +**Run As Administrator**). If you are running Windows XP, you might need to download and install PowerShell.\n\ + 5. From the PowerShell prompt, run the following commands to install Metricbeat as a Windows service.', + values: { + directoryName: '`metricbeat-{config.kibana.version}-windows`', + folderPath: '`C:\\Program Files`', + metricbeatLink: '{config.docs.beats.metricbeat}/metricbeat-getting-started.html', + elasticLink: 'https://www.elastic.co/downloads/beats/metricbeat', + }, + }), commands: [ 'PS > cd C:\\Program Files\\Metricbeat', - 'PS C:\\Program Files\\Metricbeat> .\\install-service-metricbeat.ps1' + 'PS C:\\Program Files\\Metricbeat> .\\install-service-metricbeat.ps1', ], - textPost: 'Modify the settings under `output.elasticsearch` in the ' + - '`C:\\Program Files\\Metricbeat\\metricbeat.yml` file to point to your Elasticsearch installation.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.install.windowsTextPost', { + defaultMessage: 'Modify the settings under `output.elasticsearch` in the {path} file to point to your Elasticsearch installation.', + values: { path: '`C:\\Program Files\\Metricbeat\\metricbeat.yml`' }, + }), } }, START: { OSX: { - title: 'Start Metricbeat', - textPre: 'The `setup` command loads the Kibana dashboards.' + - ' If the dashboards are already set up, omit this command.', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.osxTitle', { + defaultMessage: 'Start Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.osxTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ './metricbeat setup', './metricbeat -e', ] }, DEB: { - title: 'Start Metricbeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.debTitle', { + defaultMessage: 'Start Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.debTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'sudo metricbeat setup', 'sudo service metricbeat start', ] }, RPM: { - title: 'Start Metricbeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.rpmTitle', { + defaultMessage: 'Start Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.rpmTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'sudo metricbeat setup', 'sudo service metricbeat start', ], }, WINDOWS: { - title: 'Start Metricbeat', - textPre: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, ' + - 'omit this command.', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.windowsTitle', { + defaultMessage: 'Start Metricbeat', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.start.windowsTextPre', { + defaultMessage: 'The `setup` command loads the Kibana dashboards. If the dashboards are already set up, omit this command.', + }), commands: [ 'PS C:\\Program Files\\Metricbeat> metricbeat.exe setup', 'PS C:\\Program Files\\Metricbeat> Start-Service metricbeat', - ] - } + ], + }, }, CONFIG: { OSX: { - title: 'Edit the configuration', - textPre: 'Modify `metricbeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.osxTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.osxTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`metricbeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.osxTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, DEB: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/metricbeat/metricbeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.debTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.debTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`/etc/metricbeat/metricbeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.debTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, RPM: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/metricbeat/metricbeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.rpmTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.rpmTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`/etc/metricbeat/metricbeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.rpmTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), }, WINDOWS: { - title: 'Edit the configuration', - textPre: 'Modify `C:\\Program Files\\Metricbeat\\metricbeat.yml` to set the connection information:', + title: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.windowsTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.windowsTextPre', { + defaultMessage: 'Modify {path} to set the connection information:', + values: { + path: '`C:\\Program Files\\Metricbeat\\metricbeat.yml`', + }, + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: "elastic"', ' password: ""', 'setup.kibana:', - ' host: ""' + ' host: ""', ], - textPost: 'Where `` is the password of the `elastic` user, ' + - '`` is the URL of Elasticsearch, and `` is the URL of Kibana.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatInstructions.config.windowsTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user, {esUrlTemplate} is the URL of Elasticsearch, \ +and {kibanaUrlTemplate} is the URL of Kibana.', + values: { + passwordTemplate: '``', + esUrlTemplate: '``', + kibanaUrlTemplate: '``', + }, + }), } } -}; +}); -export const METRICBEAT_CLOUD_INSTRUCTIONS = { +export const createMetricbeatCloudInstructions = () => ({ CONFIG: { OSX: { - title: 'Edit the configuration', - textPre: 'Modify `metricbeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.osxTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.osxTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`metricbeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.osxTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, DEB: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/metricbeat/metricbeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.debTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.debTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`/etc/metricbeat/metricbeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.debTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, RPM: { - title: 'Edit the configuration', - textPre: 'Modify `/etc/metricbeat/metricbeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.rpmTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.rpmTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`/etc/metricbeat/metricbeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.rpmTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), }, WINDOWS: { - title: 'Edit the configuration', - textPre: 'Modify `C:\\Program Files\\Filebeat\\metricbeat.yml` to set the connection information for Elastic Cloud:', + title: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.windowsTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.windowsTextPre', { + defaultMessage: 'Modify {path} to set the connection information for Elastic Cloud:', + values: { + path: '`C:\\Program Files\\Metricbeat\\metricbeat.yml`', + }, + }), commands: [ 'cloud.id: "{config.cloud.id}"', 'cloud.auth: "elastic:"' ], - textPost: 'Where `` is the password of the `elastic` user.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatCloudInstructions.config.windowsTextPost', { + defaultMessage: 'Where {passwordTemplate} is the password of the `elastic` user.', + values: { passwordTemplate: '``' }, + }), } } -}; +}); export function metricbeatEnableInstructions(moduleName) { return { OSX: { - title: 'Enable and configure the ' + moduleName + ' module', - textPre: 'From the installation directory, run:', + title: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.osxTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.osxTextPre', { + defaultMessage: 'From the installation directory, run:', + }), commands: [ './metricbeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.osxTextPost', { + defaultMessage: 'Modify the settings in the `modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, DEB: { - title: 'Enable and configure the ' + moduleName + ' module', + title: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.debTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), commands: [ 'sudo metricbeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `/etc/metricbeat/modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.debTextPost', { + defaultMessage: 'Modify the settings in the `/etc/metricbeat/modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, RPM: { - title: 'Enable and configure the ' + moduleName + ' module', + title: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.rpmTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), commands: [ 'sudo metricbeat modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `/etc/metricbeat/modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.rpmTextPost', { + defaultMessage: 'Modify the settings in the `/etc/metricbeat/modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), }, WINDOWS: { - title: 'Enable and configure the ' + moduleName + ' module', - textPre: 'From the `C:\\Program Files\\Metricbeat` folder, run:', + title: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.windowsTitle', { + defaultMessage: 'Enable and configure the {moduleName} module', + values: { moduleName }, + }), + textPre: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.windowsTextPre', { + defaultMessage: 'From the {path} folder, run:', + values: { path: `C:\\Program Files\\Metricbeat` }, + }), commands: [ 'PS C:\\Program Files\\Metricbeat> metricbeat.exe modules enable ' + moduleName, ], - textPost: 'Modify the settings in the `modules.d/' + moduleName + '.yml` file.' + textPost: i18n.translate('kbn.common.tutorials.metricbeatEnableInstructions.windowsTextPost', { + defaultMessage: 'Modify the settings in the `modules.d/{moduleName}.yml` file.', + values: { moduleName }, + }), } }; } export function metricbeatStatusCheck(moduleName) { return { - title: 'Module status', - text: 'Check that data is received from the Metricbeat `' + moduleName + '` module', - btnLabel: 'Check data', - success: 'Data successfully received from this module', - error: 'No data has been received from this module yet', + title: i18n.translate('kbn.common.tutorials.metricbeatStatusCheck.title', { + defaultMessage: 'Module status', + }), + text: i18n.translate('kbn.common.tutorials.metricbeatStatusCheck.text', { + defaultMessage: 'Check that data is received from the Metricbeat `{moduleName}` module', + values: { moduleName }, + }), + btnLabel: i18n.translate('kbn.common.tutorials.metricbeatStatusCheck.buttonLabel', { + defaultMessage: 'Check data', + }), + success: i18n.translate('kbn.common.tutorials.metricbeatStatusCheck.successText', { + defaultMessage: 'Data successfully received from this module', + }), + error: i18n.translate('kbn.common.tutorials.metricbeatStatusCheck.errorText', { + defaultMessage: 'No data has been received from this module yet', + }), esHitsCheck: { index: 'metricbeat-*', query: { bool: { filter: { term: { - 'metricset.module': moduleName - } - } - } - } - } + 'metricset.module': moduleName, + }, + }, + }, + }, + }, }; } export function onPremInstructions(moduleName) { + const METRICBEAT_INSTRUCTIONS = createMetricbeatInstructions(); + return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.metricbeat.premInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: [ { id: INSTRUCTION_VARIANT.OSX, @@ -278,8 +460,8 @@ export function onPremInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.OSX, METRICBEAT_INSTRUCTIONS.CONFIG.OSX, metricbeatEnableInstructions(moduleName).OSX, - METRICBEAT_INSTRUCTIONS.START.OSX - ] + METRICBEAT_INSTRUCTIONS.START.OSX, + ], }, { id: INSTRUCTION_VARIANT.DEB, @@ -287,8 +469,8 @@ export function onPremInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.DEB, METRICBEAT_INSTRUCTIONS.CONFIG.DEB, metricbeatEnableInstructions(moduleName).DEB, - METRICBEAT_INSTRUCTIONS.START.DEB - ] + METRICBEAT_INSTRUCTIONS.START.DEB, + ], }, { id: INSTRUCTION_VARIANT.RPM, @@ -296,8 +478,8 @@ export function onPremInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.RPM, METRICBEAT_INSTRUCTIONS.CONFIG.RPM, metricbeatEnableInstructions(moduleName).RPM, - METRICBEAT_INSTRUCTIONS.START.RPM - ] + METRICBEAT_INSTRUCTIONS.START.RPM, + ], }, { id: INSTRUCTION_VARIANT.WINDOWS, @@ -305,21 +487,27 @@ export function onPremInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.WINDOWS, METRICBEAT_INSTRUCTIONS.CONFIG.WINDOWS, metricbeatEnableInstructions(moduleName).WINDOWS, - METRICBEAT_INSTRUCTIONS.START.WINDOWS - ] - } + METRICBEAT_INSTRUCTIONS.START.WINDOWS, + ], + }, ], - statusCheck: metricbeatStatusCheck(moduleName) - } - ] + statusCheck: metricbeatStatusCheck(moduleName), + }, + ], }; } export function onPremCloudInstructions(moduleName) { + const TRYCLOUD_OPTION1 = createTrycloudOption1(); + const TRYCLOUD_OPTION2 = createTrycloudOption2(); + const METRICBEAT_INSTRUCTIONS = createMetricbeatInstructions(); + return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.metricbeat.premCloudInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: [ { id: INSTRUCTION_VARIANT.OSX, @@ -329,8 +517,8 @@ export function onPremCloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.OSX, METRICBEAT_INSTRUCTIONS.CONFIG.OSX, metricbeatEnableInstructions(moduleName).OSX, - METRICBEAT_INSTRUCTIONS.START.OSX - ] + METRICBEAT_INSTRUCTIONS.START.OSX, + ], }, { id: INSTRUCTION_VARIANT.DEB, @@ -340,8 +528,8 @@ export function onPremCloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.DEB, METRICBEAT_INSTRUCTIONS.CONFIG.DEB, metricbeatEnableInstructions(moduleName).DEB, - METRICBEAT_INSTRUCTIONS.START.DEB - ] + METRICBEAT_INSTRUCTIONS.START.DEB, + ], }, { id: INSTRUCTION_VARIANT.RPM, @@ -351,8 +539,8 @@ export function onPremCloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.RPM, METRICBEAT_INSTRUCTIONS.CONFIG.RPM, metricbeatEnableInstructions(moduleName).RPM, - METRICBEAT_INSTRUCTIONS.START.RPM - ] + METRICBEAT_INSTRUCTIONS.START.RPM, + ], }, { id: INSTRUCTION_VARIANT.WINDOWS, @@ -362,21 +550,26 @@ export function onPremCloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.WINDOWS, METRICBEAT_INSTRUCTIONS.CONFIG.WINDOWS, metricbeatEnableInstructions(moduleName).WINDOWS, - METRICBEAT_INSTRUCTIONS.START.WINDOWS - ] - } + METRICBEAT_INSTRUCTIONS.START.WINDOWS, + ], + }, ], - statusCheck: metricbeatStatusCheck(moduleName) - } - ] + statusCheck: metricbeatStatusCheck(moduleName), + }, + ], }; } export function cloudInstructions(moduleName) { + const METRICBEAT_INSTRUCTIONS = createMetricbeatInstructions(); + const METRICBEAT_CLOUD_INSTRUCTIONS = createMetricbeatCloudInstructions(); + return { instructionSets: [ { - title: 'Getting Started', + title: i18n.translate('kbn.common.tutorials.metricbeat.cloudInstructions.gettingStarted.title', { + defaultMessage: 'Getting Started', + }), instructionVariants: [ { id: INSTRUCTION_VARIANT.OSX, @@ -384,8 +577,8 @@ export function cloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.OSX, METRICBEAT_CLOUD_INSTRUCTIONS.CONFIG.OSX, metricbeatEnableInstructions(moduleName).OSX, - METRICBEAT_INSTRUCTIONS.START.OSX - ] + METRICBEAT_INSTRUCTIONS.START.OSX, + ], }, { id: INSTRUCTION_VARIANT.DEB, @@ -393,8 +586,8 @@ export function cloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.DEB, METRICBEAT_CLOUD_INSTRUCTIONS.CONFIG.DEB, metricbeatEnableInstructions(moduleName).DEB, - METRICBEAT_INSTRUCTIONS.START.DEB - ] + METRICBEAT_INSTRUCTIONS.START.DEB, + ], }, { id: INSTRUCTION_VARIANT.RPM, @@ -402,8 +595,8 @@ export function cloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.RPM, METRICBEAT_CLOUD_INSTRUCTIONS.CONFIG.RPM, metricbeatEnableInstructions(moduleName).RPM, - METRICBEAT_INSTRUCTIONS.START.RPM - ] + METRICBEAT_INSTRUCTIONS.START.RPM, + ], }, { id: INSTRUCTION_VARIANT.WINDOWS, @@ -411,12 +604,12 @@ export function cloudInstructions(moduleName) { METRICBEAT_INSTRUCTIONS.INSTALL.WINDOWS, METRICBEAT_CLOUD_INSTRUCTIONS.CONFIG.WINDOWS, metricbeatEnableInstructions(moduleName).WINDOWS, - METRICBEAT_INSTRUCTIONS.START.WINDOWS - ] - } + METRICBEAT_INSTRUCTIONS.START.WINDOWS, + ], + }, ], - statusCheck: metricbeatStatusCheck(moduleName) - } - ] + statusCheck: metricbeatStatusCheck(moduleName), + }, + ], }; } diff --git a/src/core_plugins/kibana/common/tutorials/onprem_cloud_instructions.js b/src/core_plugins/kibana/common/tutorials/onprem_cloud_instructions.js index 9b0326a1b647a2..088038ab5beb12 100644 --- a/src/core_plugins/kibana/common/tutorials/onprem_cloud_instructions.js +++ b/src/core_plugins/kibana/common/tutorials/onprem_cloud_instructions.js @@ -17,25 +17,39 @@ * under the License. */ -export const TRYCLOUD_OPTION1 = { - title: 'Option 1: Try module in Elastic Cloud', - textPre: 'Go to [Elastic Cloud](https://www.elastic.co/cloud/as-a-service/signup?blade=kib). Register if you ' + - 'do not already have an account. Free 14-day trial available.\n\n' + +import { i18n } from '@kbn/i18n'; - 'Log into the Elastic Cloud console\n\n' + +export const createTrycloudOption1 = () => ({ + title: i18n.translate('kbn.common.tutorials.premCloudInstructions.option1.title', { + defaultMessage: 'Option 1: Try module in Elastic Cloud', + }), + textPre: i18n.translate('kbn.common.tutorials.premCloudInstructions.option1.textPre', { + defaultMessage: 'Go to [Elastic Cloud]({link}). Register if you \ +do not already have an account. Free 14-day trial available.\n\n\ +Log into the Elastic Cloud console\n\n\ +To create a cluster, in Elastic Cloud console:\n\ + 1. Select **Create Deployment** and specify the **Deployment Name**\n\ + 2. Modify the other deployment options as needed (or not, the defaults are great to get started)\n\ + 3. Click **Create Deployment**\n\ + 4. Wait until deployment creation completes\n\ + 5. Go to the new Cloud Kibana instance and follow the Kibana Home instructions', + values: { + link: 'https://www.elastic.co/cloud/as-a-service/signup?blade=kib', + } + }), +}); - 'To create a cluster, in Elastic Cloud console:\n' + - ' 1. Select **Create Deployment** and specify the **Deployment Name**\n' + - ' 2. Modify the other deployment options as needed (or not, the defaults are great to get started)\n' + - ' 3. Click **Create Deployment**\n' + - ' 4. Wait until deployment creation completes\n' + - ' 5. Go to the new Cloud Kibana instance and follow the Kibana Home instructions' - -}; - -export const TRYCLOUD_OPTION2 = { - title: 'Option 2: Connect local Kibana to a Cloud instance', - textPre: 'If you are running this Kibana instance against a hosted Elasticsearch instance,' + - ' proceed with manual setup.\n\n' + - 'Save the **Elasticsearch** endpoint as `` and the cluster **Password** as `` for your records' -}; +export const createTrycloudOption2 = () => ({ + title: i18n.translate('kbn.common.tutorials.premCloudInstructions.option2.title', { + defaultMessage: 'Option 2: Connect local Kibana to a Cloud instance', + }), + textPre: i18n.translate('kbn.common.tutorials.premCloudInstructions.option2.textPre', { + defaultMessage: 'If you are running this Kibana instance against a hosted Elasticsearch instance, \ +proceed with manual setup.\n\n\ +Save the **Elasticsearch** endpoint as {urlTemplate} and the cluster **Password** as {passwordTemplate} for your records', + values: { + urlTemplate: '``', + passwordTemplate: '``', + } + }), +}); diff --git a/src/core_plugins/kibana/public/context/api/__tests__/predecessors.js b/src/core_plugins/kibana/public/context/api/__tests__/predecessors.js index 2e461ff836fe6d..7caeec57f4cf2d 100644 --- a/src/core_plugins/kibana/public/context/api/__tests__/predecessors.js +++ b/src/core_plugins/kibana/public/context/api/__tests__/predecessors.js @@ -103,7 +103,7 @@ describe('context app', function () { // should have started at the given time expect(intervals[0].gte).to.eql(MS_PER_DAY * 3000); // should have ended with a half-open interval - expect(_.last(intervals)).to.only.have.key('gte'); + expect(_.last(intervals)).to.only.have.keys('gte', 'format'); expect(intervals.length).to.be.greaterThan(1); expect(hits).to.eql(searchSourceStub._stubHits.slice(0, 3)); diff --git a/src/core_plugins/kibana/public/context/api/__tests__/successors.js b/src/core_plugins/kibana/public/context/api/__tests__/successors.js index 1974d55655e25f..e9c3e94829b71f 100644 --- a/src/core_plugins/kibana/public/context/api/__tests__/successors.js +++ b/src/core_plugins/kibana/public/context/api/__tests__/successors.js @@ -103,7 +103,7 @@ describe('context app', function () { // should have started at the given time expect(intervals[0].lte).to.eql(MS_PER_DAY * 3000); // should have ended with a half-open interval - expect(_.last(intervals)).to.only.have.key('lte'); + expect(_.last(intervals)).to.only.have.keys('lte', 'format'); expect(intervals.length).to.be.greaterThan(1); expect(hits).to.eql(searchSourceStub._stubHits.slice(-3)); diff --git a/src/core_plugins/kibana/public/context/api/context.js b/src/core_plugins/kibana/public/context/api/context.js index 68ca81e83a20eb..6b60eea7b76759 100644 --- a/src/core_plugins/kibana/public/context/api/context.js +++ b/src/core_plugins/kibana/public/context/api/context.js @@ -216,6 +216,7 @@ function fetchContextProvider(indexPatterns, Private) { filter: { range: { [timeField]: { + format: 'epoch_millis', ...startRange, ...endRange, } diff --git a/src/core_plugins/kibana/public/dashboard/actions/embeddables.ts b/src/core_plugins/kibana/public/dashboard/actions/embeddables.ts index f0b0388a01873d..f2b570ec25a082 100644 --- a/src/core_plugins/kibana/public/dashboard/actions/embeddables.ts +++ b/src/core_plugins/kibana/public/dashboard/actions/embeddables.ts @@ -18,14 +18,13 @@ */ import _ from 'lodash'; -import { Dispatch } from 'redux'; import { createAction } from 'redux-actions'; -import { CoreKibanaState, getEmbeddableCustomization, getPanel } from '../../selectors'; +import { getEmbeddableCustomization, getPanel } from '../../selectors'; import { PanelId, PanelState } from '../selectors'; import { updatePanel } from './panels'; import { EmbeddableMetadata, EmbeddableState } from 'ui/embeddable'; -import { KibanaAction } from '../../selectors/types'; +import { KibanaAction, KibanaThunk } from '../../selectors/types'; export enum EmbeddableActionTypeKeys { EMBEDDABLE_IS_INITIALIZING = 'EMBEDDABLE_IS_INITIALIZING', @@ -100,9 +99,9 @@ export const embeddableError = createAction( export function embeddableStateChanged(changeData: { panelId: PanelId; embeddableState: EmbeddableState; -}) { +}): KibanaThunk { const { panelId, embeddableState } = changeData; - return (dispatch: Dispatch, getState: () => CoreKibanaState) => { + return (dispatch, getState) => { // Translate embeddableState to things redux cares about. const customization = getEmbeddableCustomization(getState(), panelId); if (!_.isEqual(embeddableState.customization, customization)) { diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_app.js b/src/core_plugins/kibana/public/dashboard/dashboard_app.js index e2e12acafa4070..ce6ac9e7d48e47 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_app.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_app.js @@ -43,13 +43,15 @@ import { showCloneModal } from './top_nav/show_clone_modal'; import { showSaveModal } from './top_nav/show_save_modal'; import { showAddPanel } from './top_nav/show_add_panel'; import { showOptionsPopover } from './top_nav/show_options_popover'; +import { showShareContextMenu, ShareContextMenuExtensionsRegistryProvider } from 'ui/share'; import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery'; import * as filterActions from 'ui/doc_table/actions/filter'; import { FilterManagerProvider } from 'ui/filter_manager'; import { EmbeddableFactoriesRegistryProvider } from 'ui/embeddable/embeddable_factories_registry'; -import { DashboardPanelActionsRegistryProvider } from 'ui/dashboard_panel_actions/dashboard_panel_actions_registry'; +import { ContextMenuActionsRegistryProvider } from 'ui/embeddable'; import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; import { timefilter } from 'ui/timefilter'; +import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing'; import { DashboardViewportProvider } from './viewport/dashboard_viewport_provider'; @@ -82,7 +84,9 @@ app.directive('dashboardApp', function ($injector) { const filterBar = Private(FilterBarQueryFilterProvider); const docTitle = Private(DocTitleProvider); const embeddableFactories = Private(EmbeddableFactoriesRegistryProvider); - const panelActionsRegistry = Private(DashboardPanelActionsRegistryProvider); + const panelActionsRegistry = Private(ContextMenuActionsRegistryProvider); + const getUnhashableStates = Private(getUnhashableStatesProvider); + const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider); panelActionsStore.initializeFromRegistry(panelActionsRegistry); @@ -130,14 +134,6 @@ app.directive('dashboardApp', function ($injector) { dirty: !dash.id }; - this.getSharingTitle = () => { - return dash.title; - }; - - this.getSharingType = () => { - return 'dashboard'; - }; - dashboardStateManager.registerChangeListener(status => { this.appStatus.dirty = status.dirty || !dash.id; updateState(); @@ -399,6 +395,21 @@ app.directive('dashboardApp', function ($injector) { }, }); }; + navActions[TopNavIds.SHARE] = (menuItem, navController, anchorElement) => { + showShareContextMenu({ + anchorElement, + allowEmbed: true, + getUnhashableStates, + objectId: dash.id, + objectType: 'dashboard', + shareContextMenuExtensions, + sharingData: { + title: dash.title, + }, + isDirty: dashboardStateManager.getIsDirty(), + }); + }; + updateViewMode(dashboardStateManager.getViewMode()); // update root source when filters update @@ -438,11 +449,6 @@ app.directive('dashboardApp', function ($injector) { kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); kbnUrl.removeParam(DashboardConstants.NEW_VISUALIZATION_ID_PARAM); } - - // TODO remove opts once share has been converted to react - $scope.opts = { - dashboard: dash, // used in share.html - }; } }; }); diff --git a/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js index 4f208a63656b63..3b7c759967ec09 100644 --- a/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js +++ b/src/core_plugins/kibana/public/dashboard/dashboard_state_manager.js @@ -470,10 +470,10 @@ export class DashboardStateManager { * @returns {boolean} True if the dashboard has changed since the last save (or, is new). */ getIsDirty(timeFilter) { - return this.isDirty || - // Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker - // changes are not tracked by the state monitor. - this.getFiltersChanged(timeFilter); + // Filter bar comparison is done manually (see cleanFiltersForComparison for the reason) and time picker + // changes are not tracked by the state monitor. + const hasTimeFilterChanged = timeFilter ? this.getFiltersChanged(timeFilter) : false; + return this.isDirty || hasTimeFilterChanged; } getPanels() { diff --git a/src/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap b/src/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap index 664928fea677c4..45517f756370fa 100644 --- a/src/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap @@ -99,6 +99,7 @@ exports[`after fetch initialFilter 1`] = ` grow={true} > } body={ - +

You can combine data views from any Kibana app into one dashboard and see everything in one place.

@@ -226,7 +227,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` to take a test drive.

-
+ } iconColor="subdued" iconType="dashboardApp" @@ -311,6 +312,7 @@ exports[`after fetch renders table rows 1`] = ` grow={true} > void; closeContextMenu: () => void; title?: string; -}): DashboardPanelAction { - return new DashboardPanelAction( +}): ContextMenuAction { + return new ContextMenuAction( { displayName: 'Customize panel', id: 'customizePanel', parentPanelId: 'mainMenu', }, { - childContextMenuPanel: new DashboardContextMenuPanel( + childContextMenuPanel: new ContextMenuPanel( { id: 'panelSubOptionsMenu', title: 'Customize panel', diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_edit_panel_action.tsx b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_edit_panel_action.tsx index 5161b44f4b4905..0b7bda9d1dfbf2 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_edit_panel_action.tsx +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_edit_panel_action.tsx @@ -21,15 +21,15 @@ import React from 'react'; import { EuiIcon } from '@elastic/eui'; -import { DashboardPanelAction } from 'ui/dashboard_panel_actions'; +import { ContextMenuAction } from 'ui/embeddable'; import { DashboardViewMode } from '../../../dashboard_view_mode'; /** * - * @return {DashboardPanelAction} + * @return {ContextMenuAction} */ export function getEditPanelAction() { - return new DashboardPanelAction( + return new ContextMenuAction( { displayName: 'Edit visualization', id: 'editPanel', diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_inspector_panel_action.tsx b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_inspector_panel_action.tsx index 10dc81a6a2bb4b..2739e2859a4296 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_inspector_panel_action.tsx +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_inspector_panel_action.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { EuiIcon } from '@elastic/eui'; -import { DashboardPanelAction } from 'ui/dashboard_panel_actions'; +import { ContextMenuAction } from 'ui/embeddable'; import { Inspector } from 'ui/inspector'; /** @@ -29,7 +29,7 @@ import { Inspector } from 'ui/inspector'; * This will check if the embeddable inside the panel actually exposes inspector adapters * via its embeddable.getInspectorAdapters() method. If so - and if an inspector * could be shown for those adapters - the inspector icon will be visible. - * @return {DashboardPanelAction} + * @return {ContextMenuAction} */ export function getInspectorPanelAction({ closeContextMenu, @@ -38,7 +38,7 @@ export function getInspectorPanelAction({ closeContextMenu: () => void; panelTitle?: string; }) { - return new DashboardPanelAction( + return new ContextMenuAction( { id: 'openInspector', displayName: 'Inspect', diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_remove_panel_action.tsx b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_remove_panel_action.tsx index 0f501b3205a6b4..fce94f24b16ce9 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_remove_panel_action.tsx +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_remove_panel_action.tsx @@ -20,16 +20,16 @@ import { EuiIcon } from '@elastic/eui'; import React from 'react'; -import { DashboardPanelAction } from 'ui/dashboard_panel_actions'; +import { ContextMenuAction } from 'ui/embeddable'; import { DashboardViewMode } from '../../../dashboard_view_mode'; /** * * @param {function} onDeletePanel - * @return {DashboardPanelAction} + * @return {ContextMenuAction} */ export function getRemovePanelAction(onDeletePanel: () => void) { - return new DashboardPanelAction( + return new ContextMenuAction( { displayName: 'Delete from dashboard', id: 'deletePanel', diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_toggle_expand_panel_action.tsx b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_toggle_expand_panel_action.tsx index 9da8cb11d71d66..27dca29c01ba67 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_toggle_expand_panel_action.tsx +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/get_toggle_expand_panel_action.tsx @@ -20,13 +20,13 @@ import { EuiIcon } from '@elastic/eui'; import React from 'react'; -import { DashboardPanelAction } from 'ui/dashboard_panel_actions'; +import { ContextMenuAction } from 'ui/embeddable'; /** * Returns an action that toggles the panel into maximized or minimized state. * @param {boolean} isExpanded * @param {function} toggleExpandedPanel - * @return {DashboardPanelAction} + * @return {ContextMenuAction} */ export function getToggleExpandPanelAction({ isExpanded, @@ -35,7 +35,7 @@ export function getToggleExpandPanelAction({ isExpanded: boolean; toggleExpandedPanel: () => void; }) { - return new DashboardPanelAction( + return new ContextMenuAction( { displayName: isExpanded ? 'Minimize' : 'Full screen', id: 'togglePanel', diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/index.ts b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/index.ts index ec369ffa4badc9..b4cc5ea82e9484 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/index.ts +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_actions/index.ts @@ -19,7 +19,6 @@ export { getEditPanelAction } from './get_edit_panel_action'; export { getRemovePanelAction } from './get_remove_panel_action'; -export { buildEuiContextMenuPanels } from './build_context_menu'; export { getCustomizePanelAction } from './get_customize_panel_action'; export { getToggleExpandPanelAction } from './get_toggle_expand_panel_action'; export { getInspectorPanelAction } from './get_inspector_panel_action'; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_header_container.test.tsx b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_header_container.test.tsx index 4458fb07c56cde..998c1f6dc3ffc4 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_header_container.test.tsx +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_header_container.test.tsx @@ -63,7 +63,7 @@ beforeAll(() => { y: 1, w: 1, h: 1, - id: 'hi', + i: 'hi', }, }, }) diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_options_menu_container.ts b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_options_menu_container.ts index 1ae187156dc318..294a7dd2661ceb 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_options_menu_container.ts +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_header/panel_options_menu_container.ts @@ -18,11 +18,15 @@ */ import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; -import * as Redux from 'react-redux'; -import { ContainerState, Embeddable } from 'ui/embeddable'; -import { panelActionsStore } from '../../store/panel_actions_store'; +import { connect } from 'react-redux'; import { buildEuiContextMenuPanels, + ContainerState, + ContextMenuPanel, + Embeddable, +} from 'ui/embeddable'; +import { panelActionsStore } from '../../store/panel_actions_store'; +import { getCustomizePanelAction, getEditPanelAction, getInspectorPanelAction, @@ -41,7 +45,7 @@ import { setVisibleContextMenuPanelId, } from '../../actions'; -import { DashboardContextMenuPanel } from 'ui/dashboard_panel_actions'; +import { Dispatch } from 'redux'; import { CoreKibanaState } from '../../../selectors'; import { DashboardViewMode } from '../../dashboard_view_mode'; import { @@ -106,7 +110,7 @@ const mapStateToProps = ( * @param panelId {string} */ const mapDispatchToProps = ( - dispatch: Redux.Dispatch, + dispatch: Dispatch, { panelId }: PanelOptionsMenuContainerOwnProps ) => ({ onDeletePanel: () => { @@ -162,7 +166,7 @@ const mergeProps = ( // Don't build the panels if the pop over is not open, or this gets expensive - this function is called once for // every panel, every time any state changes. if (isPopoverOpen) { - const contextMenuPanel = new DashboardContextMenuPanel({ + const contextMenuPanel = new ContextMenuPanel({ title: 'Options', id: 'mainMenu', }); @@ -200,7 +204,7 @@ const mergeProps = ( }; }; -export const PanelOptionsMenuContainer = Redux.connect< +export const PanelOptionsMenuContainer = connect< PanelOptionsMenuContainerStateProps, PanelOptionsMenuContainerDispatchProps, PanelOptionsMenuContainerOwnProps, diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js b/src/core_plugins/kibana/public/dashboard/panel/panel_state.ts similarity index 86% rename from src/core_plugins/kibana/public/dashboard/panel/panel_state.js rename to src/core_plugins/kibana/public/dashboard/panel/panel_state.ts index f87b148c8c18f1..69a0ba24af543c 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_state.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_state.ts @@ -17,8 +17,13 @@ * under the License. */ -import { DASHBOARD_GRID_COLUMN_COUNT, DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT } from '../dashboard_constants'; import chrome from 'ui/chrome'; +import { + DASHBOARD_GRID_COLUMN_COUNT, + DEFAULT_PANEL_HEIGHT, + DEFAULT_PANEL_WIDTH, +} from '../dashboard_constants'; +import { PanelState } from '../selectors'; /** * Represents a panel on a grid. Keeps track of position in the grid and what visualization it @@ -40,13 +45,12 @@ import chrome from 'ui/chrome'; */ // Look for the smallest y and x value where the default panel will fit. -function findTopLeftMostOpenSpace(width, height, currentPanels) { +function findTopLeftMostOpenSpace(width: number, height: number, currentPanels: PanelState[]) { let maxY = -1; - for (let i = 0; i < currentPanels.length; i++) { - const panel = currentPanels[i]; + currentPanels.forEach(panel => { maxY = Math.max(panel.gridData.y + panel.gridData.h, maxY); - } + }); // Handle case of empty grid. if (maxY < 0) { @@ -58,14 +62,13 @@ function findTopLeftMostOpenSpace(width, height, currentPanels) { grid[y] = new Array(DASHBOARD_GRID_COLUMN_COUNT).fill(0); } - for (let i = 0; i < currentPanels.length; i++) { - const panel = currentPanels[i]; + currentPanels.forEach(panel => { for (let x = panel.gridData.x; x < panel.gridData.x + panel.gridData.w; x++) { for (let y = panel.gridData.y; y < panel.gridData.y + panel.gridData.h; y++) { grid[y][x] = 1; } } - } + }); for (let y = 0; y < maxY; y++) { for (let x = 0; x < DASHBOARD_GRID_COLUMN_COUNT; x++) { @@ -104,21 +107,29 @@ function findTopLeftMostOpenSpace(width, height, currentPanels) { * @param {Array} currentPanels * @return {PanelState} */ -export function createPanelState(id, type, panelIndex, currentPanels) { - const { x, y } = findTopLeftMostOpenSpace(DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT, currentPanels); +export function createPanelState( + id: string, + type: string, + panelIndex: string, + currentPanels: PanelState[] +) { + const { x, y } = findTopLeftMostOpenSpace( + DEFAULT_PANEL_WIDTH, + DEFAULT_PANEL_HEIGHT, + currentPanels + ); return { gridData: { w: DEFAULT_PANEL_WIDTH, h: DEFAULT_PANEL_HEIGHT, x, y, - i: panelIndex.toString() + i: panelIndex.toString(), }, version: chrome.getKibanaVersion(), panelIndex: panelIndex.toString(), - type: type, - id: id, + type, + id, embeddableConfig: {}, }; } - diff --git a/src/core_plugins/kibana/public/dashboard/reducers/embeddables.test.ts b/src/core_plugins/kibana/public/dashboard/reducers/embeddables.test.ts index 6bc1140e1ebf87..e85d197315d686 100644 --- a/src/core_plugins/kibana/public/dashboard/reducers/embeddables.test.ts +++ b/src/core_plugins/kibana/public/dashboard/reducers/embeddables.test.ts @@ -26,7 +26,7 @@ beforeAll(() => { embeddableConfig: {}, gridData: { h: 0, - id: '0', + i: '0', w: 0, x: 0, y: 0, diff --git a/src/core_plugins/kibana/public/dashboard/reducers/panels.test.ts b/src/core_plugins/kibana/public/dashboard/reducers/panels.test.ts index 6344f069cbac13..aa16dc2d4bd674 100644 --- a/src/core_plugins/kibana/public/dashboard/reducers/panels.test.ts +++ b/src/core_plugins/kibana/public/dashboard/reducers/panels.test.ts @@ -25,7 +25,7 @@ const originalPanelData = { embeddableConfig: {}, gridData: { h: 0, - id: '0', + i: '0', w: 0, x: 0, y: 0, @@ -47,7 +47,7 @@ describe('UpdatePanel', () => { ...originalPanelData, gridData: { h: 1, - id: '1', + i: '1', w: 10, x: 1, y: 5, @@ -60,7 +60,7 @@ describe('UpdatePanel', () => { expect(panel.gridData.y).toBe(5); expect(panel.gridData.w).toBe(10); expect(panel.gridData.h).toBe(1); - expect(panel.gridData.id).toBe('1'); + expect(panel.gridData.i).toBe('1'); }); test('should allow updating an array that contains fewer values', () => { diff --git a/src/core_plugins/kibana/public/dashboard/selectors/types.ts b/src/core_plugins/kibana/public/dashboard/selectors/types.ts index b453d2da0df3b7..a7cacf51f89adc 100644 --- a/src/core_plugins/kibana/public/dashboard/selectors/types.ts +++ b/src/core_plugins/kibana/public/dashboard/selectors/types.ts @@ -37,7 +37,7 @@ export interface GridData { readonly h: number; readonly x: number; readonly y: number; - readonly id: string; + readonly i: string; } export type PanelId = string; diff --git a/src/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts b/src/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts index 59aca3583b0c2a..449125d0ecfa4d 100644 --- a/src/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts +++ b/src/core_plugins/kibana/public/dashboard/store/panel_actions_store.ts @@ -17,16 +17,16 @@ * under the License. */ -import { DashboardPanelAction } from 'ui/dashboard_panel_actions'; +import { ContextMenuAction } from 'ui/embeddable'; class PanelActionsStore { - public actions: DashboardPanelAction[] = []; + public actions: ContextMenuAction[] = []; /** * * @type {IndexedArray} panelActionsRegistry */ - public initializeFromRegistry(panelActionsRegistry: DashboardPanelAction[]) { + public initializeFromRegistry(panelActionsRegistry: ContextMenuAction[]) { panelActionsRegistry.forEach(panelAction => { this.actions.push(panelAction); }); diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js index 7343443f7bf9e7..c0ac1cb2702b2c 100644 --- a/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js +++ b/src/core_plugins/kibana/public/dashboard/top_nav/get_top_nav_config.js @@ -38,7 +38,7 @@ export function getTopNavConfig(dashboardMode, actions, hideWriteControls) { ] : [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), - getShareConfig(), + getShareConfig(actions[TopNavIds.SHARE]), getCloneConfig(actions[TopNavIds.CLONE]), getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]) ] @@ -49,7 +49,7 @@ export function getTopNavConfig(dashboardMode, actions, hideWriteControls) { getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), getAddConfig(actions[TopNavIds.ADD]), getOptionsConfig(actions[TopNavIds.OPTIONS]), - getShareConfig()]; + getShareConfig(actions[TopNavIds.SHARE])]; default: return []; } @@ -127,12 +127,12 @@ function getAddConfig(action) { /** * @returns {kbnTopNavConfig} */ -function getShareConfig() { +function getShareConfig(action) { return { key: TopNavIds.SHARE, description: 'Share Dashboard', - testId: 'dashboardShareButton', - template: require('plugins/kibana/dashboard/top_nav/share.html') + testId: 'shareTopNavButton', + run: action, }; } diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/options_popover.less b/src/core_plugins/kibana/public/dashboard/top_nav/options_popover.less deleted file mode 100644 index b9c1271573ecbb..00000000000000 --- a/src/core_plugins/kibana/public/dashboard/top_nav/options_popover.less +++ /dev/null @@ -1,7 +0,0 @@ -.dashOptionsPopover { - height: 100%; - - .euiPopover__anchor { - height: 100%; - } -} diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/share.html b/src/core_plugins/kibana/public/dashboard/top_nav/share.html deleted file mode 100644 index 046acbb5c95b8e..00000000000000 --- a/src/core_plugins/kibana/public/dashboard/top_nav/share.html +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/src/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.js b/src/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.js index 68c95c6ad300a1..2d2dd83b4c34e9 100644 --- a/src/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.js +++ b/src/core_plugins/kibana/public/dashboard/top_nav/show_options_popover.js @@ -17,7 +17,6 @@ * under the License. */ -import './options_popover.less'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -55,7 +54,7 @@ export function showOptionsPopover({ document.body.appendChild(container); const element = ( { + const sharingData = await this.getSharingData(); + showShareContextMenu({ + anchorElement, + allowEmbed: false, + getUnhashableStates, + objectId: savedSearch.id, + objectType: 'search', + shareContextMenuExtensions, + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: $appStatus.dirty, + }); + } + }, { + key: 'inspect', + description: 'Open Inspector for search', + testId: 'openInspectorButton', + run() { + Inspector.open(inspectorAdapters, { + title: savedSearch.title + }); + } }]; - // the saved savedSearch - const savedSearch = $route.current.locals.savedSearch; - $scope.$on('$destroy', savedSearch.destroy); - // the actual courier.SearchSource $scope.searchSource = savedSearch.searchSource; $scope.indexPattern = resolveIndexPatternLoading(); @@ -214,9 +251,6 @@ function discoverController( docTitle.change(`Discover${pageTitleSuffix}`); let stateMonitor; - const $appStatus = $scope.appStatus = this.appStatus = { - dirty: !savedSearch.id - }; const $state = $scope.state = new AppState(getStateDefaults()); @@ -281,14 +315,6 @@ function discoverController( }; }; - this.getSharingType = () => { - return 'search'; - }; - - this.getSharingTitle = () => { - return savedSearch.title; - }; - $scope.uiState = $state.makeStateful('uiState'); function getStateDefaults() { @@ -544,9 +570,38 @@ function discoverController( segmented.setSortFn(sortFn); segmented.setSize($scope.opts.sampleSize); + let inspectorRequests = []; + function logResponseInInspector(resp) { + if (inspectorRequests.length > 0) { + const inspectorRequest = inspectorRequests.shift(); + inspectorRequest + .stats(getResponseInspectorStats($scope.searchSource, resp)) + .ok({ json: resp }); + } + } + // triggered when the status updated segmented.on('status', function (status) { $scope.fetchStatus = status; + if (status.complete === 0) { + // starting new segmented search request + inspectorAdapters.requests.reset(); + inspectorRequests = []; + } + + if (status.remaining > 0) { + const inspectorRequest = inspectorAdapters.requests.start( + `Segment ${$scope.fetchStatus.complete}`, + { + description: `This request queries Elasticsearch to fetch the data for the search.`, + }); + inspectorRequest.stats(getRequestInspectorStats($scope.searchSource)); + $scope.searchSource.getSearchRequestBody().then(body => { + inspectorRequest.json(body); + }); + inspectorRequests.push(inspectorRequest); + } + }); segmented.on('first', function () { @@ -554,6 +609,7 @@ function discoverController( }); segmented.on('segment', (resp) => { + logResponseInInspector(resp); if (resp._shards.failed > 0) { $scope.failures = _.union($scope.failures, resp._shards.failures); $scope.failures = _.uniq($scope.failures, false, function (failure) { @@ -562,6 +618,10 @@ function discoverController( } }); + segmented.on('emptySegment', function (resp) { + logResponseInInspector(resp); + }); + segmented.on('mergedSegment', function (merged) { $scope.mergedEsResp = merged; @@ -709,7 +769,8 @@ function discoverController( schema: 'segment', params: { field: $scope.opts.timefield, - interval: $state.interval + interval: $state.interval, + timeRange: timefilter.getTime(), } } ]; diff --git a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable.js b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable.js index 8b076ce825e2e0..5a138f0897b881 100644 --- a/src/core_plugins/kibana/public/discover/embeddable/search_embeddable.js +++ b/src/core_plugins/kibana/public/discover/embeddable/search_embeddable.js @@ -22,6 +22,7 @@ import { Embeddable } from 'ui/embeddable'; import searchTemplate from './search_template.html'; import * as columnActions from 'ui/doc_table/actions/columns'; import { getTime } from 'ui/timefilter/get_time'; +import { RequestAdapter } from 'ui/inspector/adapters'; export class SearchEmbeddable extends Embeddable { constructor({ onEmbeddableStateChanged, savedSearch, editUrl, loader, $rootScope, $compile }) { @@ -38,6 +39,13 @@ export class SearchEmbeddable extends Embeddable { this.$rootScope = $rootScope; this.$compile = $compile; this.customization = {}; + this.inspectorAdaptors = { + requests: new RequestAdapter() + }; + } + + getInspectorAdapters() { + return this.inspectorAdaptors; } emitEmbeddableStateChange(embeddableState) { @@ -84,6 +92,7 @@ export class SearchEmbeddable extends Embeddable { this.searchScope.description = this.savedSearch.description; this.searchScope.searchSource = this.savedSearch.searchSource; + this.searchScope.inspectorAdapters = this.inspectorAdaptors; const timeRangeSearchSource = this.searchScope.searchSource.create(); timeRangeSearchSource.setField('filter', () => { diff --git a/src/core_plugins/kibana/public/discover/embeddable/search_template.html b/src/core_plugins/kibana/public/discover/embeddable/search_template.html index a6b763f9b4ca7e..3fbcb16ea17da3 100644 --- a/src/core_plugins/kibana/public/discover/embeddable/search_template.html +++ b/src/core_plugins/kibana/public/discover/embeddable/search_template.html @@ -13,5 +13,6 @@ on-move-column="moveColumn" on-remove-column="removeColumn" data-test-subj="embeddedSavedSearchDocTable" + inspector-adapters="inspectorAdapters" > diff --git a/src/core_plugins/kibana/public/discover/partials/share_search.html b/src/core_plugins/kibana/public/discover/partials/share_search.html deleted file mode 100644 index 69fee7ad756d0b..00000000000000 --- a/src/core_plugins/kibana/public/discover/partials/share_search.html +++ /dev/null @@ -1,5 +0,0 @@ - - diff --git a/src/core_plugins/kibana/public/field_formats/__tests__/_source.js b/src/core_plugins/kibana/public/field_formats/__tests__/_source.js index 09a84ae7754306..4726c3993771eb 100644 --- a/src/core_plugins/kibana/public/field_formats/__tests__/_source.js +++ b/src/core_plugins/kibana/public/field_formats/__tests__/_source.js @@ -43,8 +43,9 @@ describe('_source formatting', function () { })); it('should use the text content type if a field is not passed', function () { - const hit = _.first(hits); - expect(convertHtml(hit._source)).to.be(`${JSON.stringify(hit._source)}`); + const hit = { 'foo': 'bar', 'number': 42, 'hello': '

World

', 'also': 'with "quotes" or \'single quotes\'' }; + // eslint-disable-next-line + expect(convertHtml(hit)).to.be('{"foo":"bar","number":42,"hello":"<h1>World</h1>","also":"with \\"quotes\\" or 'single quotes'"}'); }); it('uses the _source, field, and hit to create a
', function () { diff --git a/src/core_plugins/kibana/public/home/components/__snapshots__/add_data.test.js.snap b/src/core_plugins/kibana/public/home/components/__snapshots__/add_data.test.js.snap index 51d080ee62c4c4..eeb15bf7877518 100644 --- a/src/core_plugins/kibana/public/home/components/__snapshots__/add_data.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/__snapshots__/add_data.test.js.snap @@ -23,14 +23,22 @@ exports[`apmUiEnabled 1`] = ` size="m" >

- Add Data to Kibana +

- Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems. +

@@ -63,7 +71,11 @@ exports[`apmUiEnabled 1`] = ` iconSide="left" type="button" > - Add APM + } icon={ @@ -95,7 +107,11 @@ exports[`apmUiEnabled 1`] = ` iconSide="left" type="button" > - Add log data + } icon={ @@ -127,7 +143,11 @@ exports[`apmUiEnabled 1`] = ` iconSide="left" type="button" > - Add metric data + } icon={ @@ -159,7 +179,11 @@ exports[`apmUiEnabled 1`] = ` iconSide="left" type="button" > - Add security events + } icon={ @@ -204,7 +228,11 @@ exports[`apmUiEnabled 1`] = ` } } > - Sample Data + - Load a data set and a Kibana dashboard + @@ -235,7 +267,11 @@ exports[`apmUiEnabled 1`] = ` } } > - Your Data + - Connect to your Elasticsearch index + @@ -278,14 +318,22 @@ exports[`isNewKibanaInstance 1`] = ` size="m" >

- Add Data to Kibana +

- Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems. +

@@ -318,7 +366,11 @@ exports[`isNewKibanaInstance 1`] = ` iconSide="left" type="button" > - Add log data + } icon={ @@ -350,7 +402,11 @@ exports[`isNewKibanaInstance 1`] = ` iconSide="left" type="button" > - Add metric data + } icon={ @@ -382,7 +438,11 @@ exports[`isNewKibanaInstance 1`] = ` iconSide="left" type="button" > - Add security events + } icon={ @@ -427,7 +487,11 @@ exports[`isNewKibanaInstance 1`] = ` } } > - Sample Data + - Load a data set and a Kibana dashboard + @@ -458,7 +526,11 @@ exports[`isNewKibanaInstance 1`] = ` } } > - Your Data + - Connect to your Elasticsearch index + @@ -501,14 +577,22 @@ exports[`render 1`] = ` size="m" >

- Add Data to Kibana +

- Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems. +

@@ -541,7 +625,11 @@ exports[`render 1`] = ` iconSide="left" type="button" > - Add log data + } icon={ @@ -573,7 +661,11 @@ exports[`render 1`] = ` iconSide="left" type="button" > - Add metric data + } icon={ @@ -605,7 +697,11 @@ exports[`render 1`] = ` iconSide="left" type="button" > - Add security events + } icon={ @@ -650,7 +746,11 @@ exports[`render 1`] = ` } } > - Sample Data + - Load a data set and a Kibana dashboard + @@ -681,7 +785,11 @@ exports[`render 1`] = ` } } > - Your Data + - Connect to your Elasticsearch index + diff --git a/src/core_plugins/kibana/public/home/components/__snapshots__/home.test.js.snap b/src/core_plugins/kibana/public/home/components/__snapshots__/home.test.js.snap index 4f245a6cc8ef7d..1781469eacfeff 100644 --- a/src/core_plugins/kibana/public/home/components/__snapshots__/home.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/__snapshots__/home.test.js.snap @@ -8,7 +8,7 @@ exports[`home directories should not render directory entry when showOnHomePage - @@ -37,7 +37,11 @@ exports[`home directories should not render directory entry when showOnHomePage size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -126,7 +142,7 @@ exports[`home directories should render ADMIN directory entry in "Manage" panel - @@ -155,7 +171,11 @@ exports[`home directories should render ADMIN directory entry in "Manage" panel size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -263,7 +295,7 @@ exports[`home directories should render DATA directory entry in "Explore Data" p - @@ -292,7 +324,11 @@ exports[`home directories should render DATA directory entry in "Explore Data" p size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -400,7 +448,7 @@ exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` - @@ -429,7 +477,11 @@ exports[`home isNewKibanaInstance should safely handle execeptions 1`] = ` size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -518,7 +582,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when t - @@ -547,7 +611,11 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to false when t size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -636,7 +716,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th - @@ -665,7 +745,11 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -754,7 +850,7 @@ exports[`home should not contain RecentlyAccessed panel when there is no recentl - @@ -783,7 +879,11 @@ exports[`home should not contain RecentlyAccessed panel when there is no recentl size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -888,7 +1000,7 @@ exports[`home should render home component 1`] = ` size="l" /> - @@ -917,7 +1029,11 @@ exports[`home should render home component 1`] = ` size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -1006,7 +1134,7 @@ exports[`home welcome should show the normal home page if loading fails 1`] = ` - @@ -1035,7 +1163,11 @@ exports[`home welcome should show the normal home page if loading fails 1`] = ` size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -1124,7 +1268,7 @@ exports[`home welcome should show the normal home page if welcome screen is disa - @@ -1153,7 +1297,11 @@ exports[`home welcome should show the normal home page if welcome screen is disa size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + @@ -1249,7 +1409,7 @@ exports[`home welcome stores skip welcome setting if skipped 1`] = ` - @@ -1278,7 +1438,11 @@ exports[`home welcome stores skip welcome setting if skipped 1`] = ` size="m" >

- Visualize and Explore Data +

- Manage and Administer the Elastic Stack +

- Didn’t find what you were looking for? +

- View full directory of Kibana plugins + diff --git a/src/core_plugins/kibana/public/home/components/__snapshots__/recently_accessed.test.js.snap b/src/core_plugins/kibana/public/home/components/__snapshots__/recently_accessed.test.js.snap index 98bd8db229a85d..c0e0c3ad3f2b4e 100644 --- a/src/core_plugins/kibana/public/home/components/__snapshots__/recently_accessed.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/__snapshots__/recently_accessed.test.js.snap @@ -14,7 +14,11 @@ exports[`render 1`] = ` color="subdued" component="span" > - Recently viewed + diff --git a/src/core_plugins/kibana/public/home/components/add_data.js b/src/core_plugins/kibana/public/home/components/add_data.js index 1b2e04164a7295..827143d1bffb8e 100644 --- a/src/core_plugins/kibana/public/home/components/add_data.js +++ b/src/core_plugins/kibana/public/home/components/add_data.js @@ -20,6 +20,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, @@ -35,22 +36,54 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -export function AddData({ apmUiEnabled, isNewKibanaInstance }) { +const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl }) => { const renderCards = () => { + const apmTitle = intl.formatMessage({ + id: 'kbn.home.addData.apm.nameTitle', defaultMessage: 'APM' + }); + const apmDescription = intl.formatMessage({ + id: 'kbn.home.addData.apm.nameDescription', + defaultMessage: 'APM automatically collects in-depth performance metrics and errors from inside your applications.' + }); + const loggingTitle = intl.formatMessage({ + id: 'kbn.home.addData.logging.nameTitle', defaultMessage: 'Logging' + }); + const loggingDescription = intl.formatMessage({ + id: 'kbn.home.addData.logging.nameDescription', + defaultMessage: 'Ingest logs from popular data sources and easily visualize in preconfigured dashboards.' + }); + const metricsTitle = intl.formatMessage({ + id: 'kbn.home.addData.metrics.nameTitle', defaultMessage: 'Metrics' + }); + const metricsDescription = intl.formatMessage({ + id: 'kbn.home.addData.metrics.nameDescription', + defaultMessage: 'Collect metrics from the operating system and services running on your servers.' + }); + const securityTitle = intl.formatMessage({ + id: 'kbn.home.addData.security.nameTitle', defaultMessage: 'Security Analytics' + }); + const securityDescription = intl.formatMessage({ + id: 'kbn.home.addData.security.nameDescription', + defaultMessage: 'Centralize security events for interactive investigation in ready-to-go visualizations.' + }); + const getApmCard = () => ( } - title="APM" - description="APM automatically collects in-depth performance metrics and errors from inside your applications." + title={apmTitle} + description={apmDescription} footer={ - Add APM + } /> @@ -66,14 +99,17 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { } - title="Logging" - description="Ingest logs from popular data sources and easily visualize in preconfigured dashboards." + title={loggingTitle} + description={loggingDescription} footer={ - Add log data + } /> @@ -83,14 +119,17 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { } - title="Metrics" - description="Collect metrics from the operating system and services running on your servers." + title={metricsTitle} + description={metricsDescription} footer={ - Add metric data + } /> @@ -100,14 +139,17 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { } - title="Security Analytics" - description="Centralize security events for interactive investigation in ready-to-go visualizations." + title={securityTitle} + description={securityDescription} footer={ - Add security events + } /> @@ -123,11 +165,19 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { -

Add Data to Kibana

+

+ +

- Use these solutions to quickly turn your data into pre-built dashboards and monitoring systems. +

@@ -143,26 +193,38 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { - Sample Data + - Load a data set and a Kibana dashboard + - Your Data + - Connect to your Elasticsearch index + @@ -172,9 +234,11 @@ export function AddData({ apmUiEnabled, isNewKibanaInstance }) { ); -} +}; -AddData.propTypes = { +AddDataUi.propTypes = { apmUiEnabled: PropTypes.bool.isRequired, isNewKibanaInstance: PropTypes.bool.isRequired, }; + +export const AddData = injectI18n(AddDataUi); diff --git a/src/core_plugins/kibana/public/home/components/add_data.test.js b/src/core_plugins/kibana/public/home/components/add_data.test.js index 3ed8dafe860a80..549990eb99ffa4 100644 --- a/src/core_plugins/kibana/public/home/components/add_data.test.js +++ b/src/core_plugins/kibana/public/home/components/add_data.test.js @@ -18,11 +18,11 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; import { AddData } from './add_data'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; test('render', () => { - const component = shallow(); @@ -30,7 +30,7 @@ test('render', () => { }); test('apmUiEnabled', () => { - const component = shallow(); @@ -38,7 +38,7 @@ test('apmUiEnabled', () => { }); test('isNewKibanaInstance', () => { - const component = shallow(); diff --git a/src/core_plugins/kibana/public/home/components/feature_directory.js b/src/core_plugins/kibana/public/home/components/feature_directory.js index ce7371fbff092f..e3ccd6774f4a7a 100644 --- a/src/core_plugins/kibana/public/home/components/feature_directory.js +++ b/src/core_plugins/kibana/public/home/components/feature_directory.js @@ -33,6 +33,9 @@ import { import { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + const ALL_TAB_ID = 'all'; const OTHERS_TAB_ID = 'others'; @@ -47,18 +50,18 @@ export class FeatureDirectory extends React.Component { this.tabs = [{ id: ALL_TAB_ID, - name: 'All', + name: i18n.translate('kbn.home.directory.tabs.allTitle', { defaultMessage: 'All' }), }, { id: FeatureCatalogueCategory.DATA, - name: 'Data Exploration & Visualization', + name: i18n.translate('kbn.home.directory.tabs.dataTitle', { defaultMessage: 'Data Exploration & Visualization' }), }, { id: FeatureCatalogueCategory.ADMIN, - name: 'Administrative', + name: i18n.translate('kbn.home.directory.tabs.administrativeTitle', { defaultMessage: 'Administrative' }), }]; if (props.directories.some(isOtherCategory)) { this.tabs.push({ id: OTHERS_TAB_ID, - name: 'Other', + name: i18n.translate('kbn.home.directory.tabs.otherTitle', { defaultMessage: 'Other' }), }); } @@ -117,7 +120,10 @@ export class FeatureDirectory extends React.Component {

- Directory +

diff --git a/src/core_plugins/kibana/public/home/components/home.js b/src/core_plugins/kibana/public/home/components/home.js index 5cf09eb239d2a0..4bcd66ca56a326 100644 --- a/src/core_plugins/kibana/public/home/components/home.js +++ b/src/core_plugins/kibana/public/home/components/home.js @@ -22,6 +22,7 @@ import PropTypes from 'prop-types'; import { Synopsis } from './synopsis'; import { AddData } from './add_data'; import { RecentlyAccessed, recentlyAccessedShape } from './recently_accessed'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, @@ -156,7 +157,12 @@ export class Home extends Component { -

Visualize and Explore Data

+

+ +

@@ -167,7 +173,12 @@ export class Home extends Component { -

Manage and Administer the Elastic Stack

+

+ +

@@ -182,11 +193,19 @@ export class Home extends Component { -

Didn’t find what you were looking for?

+

+ +

- View full directory of Kibana plugins +
diff --git a/src/core_plugins/kibana/public/home/components/home_app.js b/src/core_plugins/kibana/public/home/components/home_app.js index d277fd542f70d7..e27356b06196df 100644 --- a/src/core_plugins/kibana/public/home/components/home_app.js +++ b/src/core_plugins/kibana/public/home/components/home_app.js @@ -32,6 +32,7 @@ import { getTutorial } from '../load_tutorials'; import { replaceTemplateStrings } from './tutorial/replace_template_strings'; import chrome from 'ui/chrome'; import { recentlyAccessedShape } from './recently_accessed'; +import { I18nProvider } from '@kbn/i18n/react'; export function HomeApp({ addBasePath, @@ -73,39 +74,41 @@ export function HomeApp({ }; return ( - - - - - - + + + - - - - - - + + + + + + + + + ); } diff --git a/src/core_plugins/kibana/public/home/components/recently_accessed.js b/src/core_plugins/kibana/public/home/components/recently_accessed.js index b0dbfb888d0bf5..a14d494b0ef5e9 100644 --- a/src/core_plugins/kibana/public/home/components/recently_accessed.js +++ b/src/core_plugins/kibana/public/home/components/recently_accessed.js @@ -34,6 +34,8 @@ import { EuiTitle, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + export const NUM_LONG_LINKS = 5; export class RecentlyAccessed extends Component { @@ -197,7 +199,10 @@ export class RecentlyAccessed extends Component {

- Recently viewed +

diff --git a/src/core_plugins/kibana/public/home/components/recently_accessed.test.js b/src/core_plugins/kibana/public/home/components/recently_accessed.test.js index c412664be696e8..8986586a0cf1ea 100644 --- a/src/core_plugins/kibana/public/home/components/recently_accessed.test.js +++ b/src/core_plugins/kibana/public/home/components/recently_accessed.test.js @@ -18,9 +18,10 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { shallow } from 'enzyme'; import { RecentlyAccessed, NUM_LONG_LINKS } from './recently_accessed'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; const createRecentlyAccessed = (length) => { const recentlyAccessed = []; @@ -44,7 +45,7 @@ test('render', () => { describe('more popover', () => { test('should not be rendered when recently accessed list size is less than NUM_LONG_LINKS', () => { - const component = mount(); @@ -53,7 +54,7 @@ describe('more popover', () => { }); test('should not be rendered when recently accessed list size is NUM_LONG_LINKS', () => { - const component = mount(); @@ -63,7 +64,7 @@ describe('more popover', () => { describe('recently accessed list size exceeds NUM_LONG_LINKS', () => { test('should be rendered', () => { - const component = mount(); @@ -73,7 +74,7 @@ describe('more popover', () => { test('should only contain overflow recently accessed items when opened', () => { const numberOfRecentlyAccessed = NUM_LONG_LINKS + 2; - const component = mount(); diff --git a/src/core_plugins/kibana/public/home/components/sample_data_set_card.js b/src/core_plugins/kibana/public/home/components/sample_data_set_card.js index 4e8554e7324f86..74a08f84cc76c2 100644 --- a/src/core_plugins/kibana/public/home/components/sample_data_set_card.js +++ b/src/core_plugins/kibana/public/home/components/sample_data_set_card.js @@ -28,70 +28,50 @@ import { EuiToolTip, } from '@elastic/eui'; -import { - installSampleDataSet, - uninstallSampleDataSet -} from '../sample_data_sets'; - -export class SampleDataSetCard extends React.Component { - - constructor(props) { - super(props); - - this.state = { - isProcessingRequest: false, - }; - } - - startRequest = async () => { - const { - getConfig, - setConfig, - id, - name, - onRequestComplete, - defaultIndex, - clearIndexPatternsCache, - } = this.props; - - this.setState({ - isProcessingRequest: true, - }); +export const INSTALLED_STATUS = 'installed'; +export const UNINSTALLED_STATUS = 'not_installed'; - if (this.isInstalled()) { - await uninstallSampleDataSet(id, name, defaultIndex, getConfig, setConfig, clearIndexPatternsCache); - } else { - await installSampleDataSet(id, name, defaultIndex, getConfig, setConfig, clearIndexPatternsCache); - } - - onRequestComplete(); +import { FormattedMessage } from '@kbn/i18n/react'; - this.setState({ - isProcessingRequest: false, - }); - } +export class SampleDataSetCard extends React.Component { isInstalled = () => { - if (this.props.status === 'installed') { + if (this.props.status === INSTALLED_STATUS) { return true; } return false; } + install = () => { + this.props.onInstall(this.props.id); + } + + uninstall = () => { + this.props.onUninstall(this.props.id); + } + renderBtn = () => { switch (this.props.status) { - case 'installed': + case INSTALLED_STATUS: return ( - {this.state.isProcessingRequest ? 'Removing' : 'Remove'} + {this.props.isProcessing + ? + : } @@ -99,22 +79,34 @@ export class SampleDataSetCard extends React.Component { href={this.props.launchUrl} data-test-subj={`launchSampleDataSet${this.props.id}`} > - View data + ); - case 'not_installed': + case UNINSTALLED_STATUS: return ( - {this.state.isProcessingRequest ? 'Adding' : 'Add'} + {this.props.isProcessing + ? + : + } @@ -126,13 +118,24 @@ export class SampleDataSetCard extends React.Component { {`Unable to verify dataset status, error: ${this.props.statusMsg}`}

} + content={ +

+ +

+ } > - {'Add'} +
@@ -163,15 +166,13 @@ SampleDataSetCard.propTypes = { name: PropTypes.string.isRequired, launchUrl: PropTypes.string.isRequired, status: PropTypes.oneOf([ - 'installed', - 'not_installed', + INSTALLED_STATUS, + UNINSTALLED_STATUS, 'unknown', ]).isRequired, + isProcessing: PropTypes.bool.isRequired, statusMsg: PropTypes.string, - onRequestComplete: PropTypes.func.isRequired, - getConfig: PropTypes.func.isRequired, - setConfig: PropTypes.func.isRequired, - clearIndexPatternsCache: PropTypes.func.isRequired, - defaultIndex: PropTypes.string.isRequired, previewUrl: PropTypes.string.isRequired, + onInstall: PropTypes.func.isRequired, + onUninstall: PropTypes.func.isRequired, }; diff --git a/src/core_plugins/kibana/public/home/components/sample_data_set_cards.js b/src/core_plugins/kibana/public/home/components/sample_data_set_cards.js new file mode 100644 index 00000000000000..6d0c25498f3d3d --- /dev/null +++ b/src/core_plugins/kibana/public/home/components/sample_data_set_cards.js @@ -0,0 +1,223 @@ +/* + * 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 React from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexGrid, + EuiFlexItem, +} from '@elastic/eui'; + +import { + SampleDataSetCard, + INSTALLED_STATUS, + UNINSTALLED_STATUS, +} from './sample_data_set_card'; + +import { toastNotifications } from 'ui/notify'; + +import { + listSampleDataSets, + installSampleDataSet, + uninstallSampleDataSet +} from '../sample_data_sets'; + +import { i18n } from '@kbn/i18n'; + +export class SampleDataSetCards extends React.Component { + + constructor(props) { + super(props); + + this.state = { + sampleDataSets: [], + processingStatus: {}, + }; + } + + componentWillUnmount() { + this._isMounted = false; + } + + async componentDidMount() { + this._isMounted = true; + + this.loadSampleDataSets(); + } + + loadSampleDataSets = async () => { + let sampleDataSets; + try { + sampleDataSets = await listSampleDataSets(); + } catch (fetchError) { + toastNotifications.addDanger({ + title: i18n.translate('kbn.home.sampleDataSet.unableToLoadListErrorMessage', { + defaultMessage: 'Unable to load sample data sets list' } + ), + text: `${fetchError.message}`, + }); + sampleDataSets = []; + } + + if (!this._isMounted) { + return; + } + + this.setState({ + sampleDataSets: sampleDataSets + .sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }), + processingStatus: {}, + }); + } + + install = async (id) => { + const { + getConfig, + setConfig, + clearIndexPatternsCache, + } = this.props; + + const targetSampleDataSet = this.state.sampleDataSets.find((sampleDataSet) => { + return sampleDataSet.id === id; + }); + + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: true } + })); + + try { + await installSampleDataSet(id, targetSampleDataSet.defaultIndex, getConfig, setConfig, clearIndexPatternsCache); + } catch (fetchError) { + if (this._isMounted) { + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: false } + })); + } + toastNotifications.addDanger({ + title: i18n.translate('kbn.home.sampleDataSet.unableToInstallErrorMessage', { + defaultMessage: 'Unable to install sample data set: {name}', values: { name: targetSampleDataSet.name } } + ), + text: `${fetchError.message}`, + }); + return; + } + + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: false }, + sampleDataSets: prevState.sampleDataSets.map(sampleDataSet => { + if (sampleDataSet.id === id) { + sampleDataSet.status = INSTALLED_STATUS; + } + return sampleDataSet; + }), + })); + toastNotifications.addSuccess({ + title: i18n.translate('kbn.home.sampleDataSet.installedLabel', { + defaultMessage: '{name} installed', values: { name: targetSampleDataSet.name } } + ), + ['data-test-subj']: 'sampleDataSetInstallToast' + }); + } + + uninstall = async (id) => { + const { + getConfig, + setConfig, + clearIndexPatternsCache, + } = this.props; + + const targetSampleDataSet = this.state.sampleDataSets.find((sampleDataSet) => { + return sampleDataSet.id === id; + }); + + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: true } + })); + + try { + await uninstallSampleDataSet(id, targetSampleDataSet.defaultIndex, getConfig, setConfig, clearIndexPatternsCache); + } catch (fetchError) { + if (this._isMounted) { + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: false } + })); + } + toastNotifications.addDanger({ + title: i18n.translate('kbn.home.sampleDataSet.unableToUninstallErrorMessage', { + defaultMessage: 'Unable to uninstall sample data set: {name}', values: { name: targetSampleDataSet.name } } + ), + text: `${fetchError.message}`, + }); + return; + } + + this.setState((prevState) => ({ + processingStatus: { ...prevState.processingStatus, [id]: false }, + sampleDataSets: prevState.sampleDataSets.map(sampleDataSet => { + if (sampleDataSet.id === id) { + sampleDataSet.status = UNINSTALLED_STATUS; + } + return sampleDataSet; + }), + })); + toastNotifications.addSuccess({ + title: i18n.translate('kbn.home.sampleDataSet.uninstalledLabel', { + defaultMessage: '{name} uninstalled', values: { name: targetSampleDataSet.name } } + ), + ['data-test-subj']: 'sampleDataSetUninstallToast' + }); + } + + render() { + return ( + + { + this.state.sampleDataSets.map(sampleDataSet => { + return ( + + + + ); + }) + } + + ); + } +} + +SampleDataSetCards.propTypes = { + getConfig: PropTypes.func.isRequired, + setConfig: PropTypes.func.isRequired, + clearIndexPatternsCache: PropTypes.func.isRequired, + addBasePath: PropTypes.func.isRequired, +}; diff --git a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/footer.test.js.snap b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/footer.test.js.snap index 3520cd93cbb8e5..e72aadd0bcae71 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/footer.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/footer.test.js.snap @@ -23,7 +23,11 @@ exports[`render 1`] = ` grow={true} >

- When all steps are complete, you're ready to explore your data. +

diff --git a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/instruction_set.test.js.snap b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/instruction_set.test.js.snap index 0f8dc642b9dcf0..33da089084488f 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/instruction_set.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/instruction_set.test.js.snap @@ -55,8 +55,6 @@ exports[`render 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -70,8 +68,6 @@ exports[`render 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", @@ -137,8 +133,6 @@ exports[`statusCheckState checking status 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -152,14 +146,12 @@ exports[`statusCheckState checking status 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", }, Object { - "children": + "children": - , + , "key": "checkStatusStep", "status": "incomplete", "title": "custom title", @@ -262,8 +254,6 @@ exports[`statusCheckState failed status check - error 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -277,14 +267,12 @@ exports[`statusCheckState failed status check - error 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", }, Object { - "children": + "children": - , + , "key": "checkStatusStep", "status": "danger", "title": "custom title", @@ -392,8 +380,6 @@ exports[`statusCheckState failed status check - no data 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -407,14 +393,12 @@ exports[`statusCheckState failed status check - no data 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", }, Object { - "children": + "children": - , + , "key": "checkStatusStep", "status": "warning", "title": "custom title", @@ -522,8 +506,6 @@ exports[`statusCheckState initial state - no check has been attempted 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -537,14 +519,12 @@ exports[`statusCheckState initial state - no check has been attempted 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", }, Object { - "children": + "children": - , + , "key": "checkStatusStep", "status": "incomplete", "title": "custom title", @@ -647,8 +627,6 @@ exports[`statusCheckState successful status check 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 0, "title": "step 1", @@ -662,14 +640,12 @@ exports[`statusCheckState successful status check 1`] = ` } paramValues={Object {}} replaceTemplateStrings={[Function]} - textPost={undefined} - textPre={undefined} />, "key": 1, "title": "step 2", }, Object { - "children": + "children": - , + , "key": "checkStatusStep", "status": "complete", "title": "custom title", diff --git a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap index 48e1bc15b6ea61..9ada357a7380dc 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap @@ -57,7 +57,11 @@ exports[`props exportedFieldsUrl 1`] = ` target="_blank" type="button" > - View exported fields +
diff --git a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap index 802135f11e8092..f5ae01470c77e2 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap @@ -1,8 +1,222 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`bulkCreate should display error message when bulkCreate request fails 1`] = ` - + "children": - , + , "key": "installStep", "status": "incomplete", "title": "Load Kibana objects", @@ -199,7 +413,7 @@ exports[`bulkCreate should display error message when bulkCreate request fails 1 color="warning" data-test-subj="loadSavedObjects_failed" size="m" - title="Request failed, Error: simulated bulkRequest error" + title="Request failed, Error: {message}" >
- Request failed, Error: simulated bulkRequest error + Request failed, Error: {message}
@@ -221,12 +435,143 @@ exports[`bulkCreate should display error message when bulkCreate request fails 1
- + `; exports[`bulkCreate should display success message when bulkCreate is successful 1`] = ` - + "children": - , + , "key": "installStep", "status": "complete", "title": "Load Kibana objects", @@ -463,7 +808,7 @@ exports[`bulkCreate should display success message when bulkCreate is successful color="success" data-test-subj="loadSavedObjects_success" size="m" - title="1 saved objects successfully added" + title="{savedObjectsLength} saved objects successfully added" >
- 1 saved objects successfully added + {savedObjectsLength} saved objects successfully added
@@ -485,7 +830,7 @@ exports[`bulkCreate should display success message when bulkCreate is successful - + `; exports[`renders 1`] = ` @@ -495,7 +840,7 @@ exports[`renders 1`] = ` steps={ Array [ Object { - "children": + "children": - , + , "key": "installStep", "status": "incomplete", "title": "Load Kibana objects", diff --git a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/tutorial.test.js.snap b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/tutorial.test.js.snap index debe5319704b8c..84a98a3a7b7848 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/tutorial.test.js.snap +++ b/src/core_plugins/kibana/public/home/components/tutorial/__snapshots__/tutorial.test.js.snap @@ -48,7 +48,7 @@ exports[`isCloudEnabled is false should not render instruction toggle when ON_PR hasShadow={false} paddingSize="l" > - - - @@ -38,7 +40,10 @@ export function Footer({ url, label }) {

- {`When all steps are complete, you're ready to explore your data.`} +

diff --git a/src/core_plugins/kibana/public/home/components/tutorial/instruction.js b/src/core_plugins/kibana/public/home/components/tutorial/instruction.js index 07418f35ba883c..b8cf63c171dbb8 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/instruction.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/instruction.js @@ -30,6 +30,8 @@ import { EuiButton, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + export function Instruction({ commands, paramValues, textPost, textPre, replaceTemplateStrings }) { let pre; if (textPre) { @@ -65,7 +67,7 @@ export function Instruction({ commands, paramValues, textPost, textPre, replaceT size="s" onClick={copy} > - Copy snippet + )} diff --git a/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.js b/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.js index 9d5a4cbc5f0bf7..d3b5ea4744177d 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.js @@ -40,7 +40,9 @@ import { } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; -export class InstructionSet extends React.Component { +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; + +class InstructionSetUi extends React.Component { constructor(props) { super(props); @@ -93,12 +95,18 @@ export class InstructionSet extends React.Component { case StatusCheckStates.FETCHING: return null; // Don't show any message while fetching or if you haven't yet checked. case StatusCheckStates.HAS_DATA: - message = this.props.statusCheckConfig.success ? this.props.statusCheckConfig.success : 'Success'; + message = this.props.statusCheckConfig.success + ? this.props.statusCheckConfig.success + : this.props.intl.formatMessage({ id: 'kbn.home.tutorial.instractionSet.successLabel', + defaultMessage: 'Success' }); color = 'success'; break; case StatusCheckStates.ERROR: case StatusCheckStates.NO_DATA: - message = this.props.statusCheckConfig.error ? this.props.statusCheckConfig.error : 'No data found'; + message = this.props.statusCheckConfig.error + ? this.props.statusCheckConfig.error + : this.props.intl.formatMessage({ id: 'kbn.home.tutorial.instractionSet.noDataLabel', + defaultMessage: 'No data found' }); color = 'warning'; break; } @@ -145,7 +153,10 @@ export class InstructionSet extends React.Component { onClick={onStatusCheck} isLoading={statusCheckState === StatusCheckStates.FETCHING} > - {statusCheckConfig.btnLabel || 'Check status'} + {statusCheckConfig.btnLabel || } @@ -157,7 +168,9 @@ export class InstructionSet extends React.Component { ); return { - title: statusCheckConfig.title || 'Status Check', + title: statusCheckConfig.title || this.props.intl.formatMessage({ id: 'kbn.home.tutorial.instractionSet.statusCheckTitle', + defaultMessage: 'Status Check' + }), status: this.getStepStatus(statusCheckState), children: checkStatusStep, key: 'checkStatusStep' @@ -208,16 +221,22 @@ export class InstructionSet extends React.Component { 'fa-caret-right': !this.state.isParamFormVisible, 'fa-caret-down': this.state.isParamFormVisible }); + const ariaLabel = this.props.intl.formatMessage({ id: 'kbn.home.tutorial.instractionSet.toggleAriaLabel', + defaultMessage: 'toggle command parameters visibility' + }); paramsVisibilityToggle = (
- Customize your code snippets +
@@ -291,7 +310,7 @@ const statusCheckConfigShape = PropTypes.shape({ btnLabel: PropTypes.string, }); -InstructionSet.propTypes = { +InstructionSetUi.propTypes = { title: PropTypes.string.isRequired, instructionVariants: PropTypes.arrayOf(instructionVariantShape).isRequired, statusCheckConfig: statusCheckConfigShape, @@ -309,3 +328,5 @@ InstructionSet.propTypes = { setParameter: PropTypes.func, replaceTemplateStrings: PropTypes.func.isRequired, }; + +export const InstructionSet = injectI18n(InstructionSetUi); diff --git a/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.test.js b/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.test.js index f3ce7770dff821..84ed073f277441 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.test.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/instruction_set.test.js @@ -18,7 +18,7 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import { InstructionSet, @@ -52,7 +52,7 @@ const instructionVariants = [ ]; test('render', () => { - const component = shallow( {}} @@ -73,7 +73,7 @@ describe('statusCheckState', () => { }; test('initial state - no check has been attempted', () => { - const component = shallow( {}} @@ -87,7 +87,7 @@ describe('statusCheckState', () => { }); test('checking status', () => { - const component = shallow( {}} @@ -101,7 +101,7 @@ describe('statusCheckState', () => { }); test('failed status check - error', () => { - const component = shallow( {}} @@ -115,7 +115,7 @@ describe('statusCheckState', () => { }); test('failed status check - no data', () => { - const component = shallow( {}} @@ -129,7 +129,7 @@ describe('statusCheckState', () => { }); test('successful status check', () => { - const component = shallow( {}} diff --git a/src/core_plugins/kibana/public/home/components/tutorial/introduction.js b/src/core_plugins/kibana/public/home/components/tutorial/introduction.js index 1b3e06e35b2942..56bb11a5016783 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/introduction.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/introduction.js @@ -31,6 +31,8 @@ import { EuiBetaBadge, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + export function Introduction({ description, previewUrl, title, exportedFieldsUrl, iconType, isBeta }) { let img; if (previewUrl) { @@ -55,7 +57,7 @@ export function Introduction({ description, previewUrl, title, exportedFieldsUrl target="_blank" rel="noopener noreferrer" > - View exported fields + ); diff --git a/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.js b/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.js index fd4ac6578df3d1..69aaa716f3619e 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.js @@ -17,6 +17,10 @@ * under the License. */ +/* eslint-disable no-multi-str*/ + +import { injectI18n } from '@kbn/i18n/react'; + import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; @@ -30,14 +34,17 @@ import { EuiCallOut, } from '@elastic/eui'; -const DEFAULT_BUTTON_LABEL = 'Load Kibana objects'; +class SavedObjectsInstallerUi extends React.Component { + DEFAULT_BUTTON_LABEL = this.props.intl.formatMessage({ + id: 'kbn.home.tutorial.savedObject.defaultButtonLabel', + defaultMessage: 'Load Kibana objects' + }); -export class SavedObjectsInstaller extends React.Component { state = { isInstalling: false, isInstalled: false, overwrite: false, - buttonLabel: DEFAULT_BUTTON_LABEL, + buttonLabel: this.DEFAULT_BUTTON_LABEL, }; componentDidMount() { @@ -63,10 +70,12 @@ export class SavedObjectsInstaller extends React.Component { this.setState({ isInstalling: false, - installStatusMsg: `Request failed, Error: ${error.message}`, + installStatusMsg: this.props.intl.formatMessage( + { id: 'kbn.home.tutorial.savedObject.requestFailedErrorMessage', defaultMessage: 'Request failed, Error: {message}' }, + { message: error.message }), isInstalled: false, overwrite: false, - buttonLabel: DEFAULT_BUTTON_LABEL + buttonLabel: this.DEFAULT_BUTTON_LABEL }); return; } @@ -85,26 +94,37 @@ export class SavedObjectsInstaller extends React.Component { if (overwriteErrors.length > 0) { this.setState({ isInstalling: false, - installStatusMsg: `${overwriteErrors.length} of ${this.props.savedObjects.length} objects already exist. ` + - `Click 'Confirm overwrite' to import and overwrite existing objects. ` + - `Any changes to the objects will be lost.`, + installStatusMsg: this.props.intl.formatMessage( + { id: 'kbn.home.tutorial.savedObject.installStatusLabel', + defaultMessage: '{overwriteErrorsLength} of {savedObjectsLength} objects already exist. \ +Click \'Confirm overwrite\' to import and overwrite existing objects. Any changes to the objects will be lost.' }, + { overwriteErrorsLength: overwriteErrors.length, savedObjectsLength: this.props.savedObjects.length }), isInstalled: false, overwrite: true, - buttonLabel: 'Confirm overwrite' + buttonLabel: this.props.intl.formatMessage({ id: 'kbn.home.tutorial.savedObject.confirmButtonLabel', + defaultMessage: 'Confirm overwrite' }) }); return; } const hasErrors = errors.length > 0; const statusMsg = hasErrors - ? `Unable to add ${errors.length} of ${this.props.savedObjects.length} kibana objects, Error: ${errors[0].error.message}` - : `${this.props.savedObjects.length} saved objects successfully added`; + ? this.props.intl.formatMessage( + { id: 'kbn.home.tutorial.savedObject.unableToAddErrorMessage', + defaultMessage: 'Unable to add {errorsLength} of {savedObjectsLength} kibana objects, Error: ${errors[0].error.message}' + }, + { errorsLength: errors.length, savedObjectsLength: this.props.savedObjects.length }) + : this.props.intl.formatMessage( + { id: 'kbn.home.tutorial.savedObject.addedLabel', + defaultMessage: '{savedObjectsLength} saved objects successfully added' + }, + { savedObjectsLength: this.props.savedObjects.length }); this.setState({ isInstalling: false, installStatusMsg: statusMsg, isInstalled: !hasErrors, overwrite: false, - buttonLabel: DEFAULT_BUTTON_LABEL, + buttonLabel: this.DEFAULT_BUTTON_LABEL, }); } @@ -125,7 +145,8 @@ export class SavedObjectsInstaller extends React.Component { renderInstallStep = () => { const installMsg = this.props.installMsg ? this.props.installMsg - : 'Imports index pattern, visualizations and pre-defined dashboards.'; + : this.props.intl.formatMessage({ id: 'kbn.home.tutorial.savedObject.installLabel', + defaultMessage: 'Imports index pattern, visualizations and pre-defined dashboards.' }); const installStep = ( @@ -155,7 +176,7 @@ export class SavedObjectsInstaller extends React.Component { ); return { - title: 'Load Kibana objects', + title: this.props.intl.formatMessage({ id: 'kbn.home.tutorial.savedObject.loadTitle', defaultMessage: 'Load Kibana objects' }), status: this.state.isInstalled ? 'complete' : 'incomplete', children: installStep, key: 'installStep' @@ -177,8 +198,10 @@ const savedObjectShape = PropTypes.shape({ attributes: PropTypes.object.isRequired, }); -SavedObjectsInstaller.propTypes = { +SavedObjectsInstallerUi.propTypes = { bulkCreate: PropTypes.func.isRequired, savedObjects: PropTypes.arrayOf(savedObjectShape).isRequired, installMsg: PropTypes.string, }; + +export const SavedObjectsInstaller = injectI18n(SavedObjectsInstallerUi); diff --git a/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.test.js b/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.test.js index 1c28831679a246..38a83424421ec7 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.test.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/saved_objects_installer.test.js @@ -18,13 +18,13 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { SavedObjectsInstaller } from './saved_objects_installer'; test('renders', () => { - const component = shallow( {}} savedObjects={[]} />); @@ -43,7 +43,7 @@ describe('bulkCreate', () => { savedObjects: [savedObject] }); }; - const component = mount(); @@ -62,7 +62,7 @@ describe('bulkCreate', () => { const bulkCreateMock = () => { return Promise.reject(new Error('simulated bulkRequest error')); }; - const component = mount(); diff --git a/src/core_plugins/kibana/public/home/components/tutorial/tutorial.js b/src/core_plugins/kibana/public/home/components/tutorial/tutorial.js index f42dab9c16ff63..847265af794d81 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/tutorial.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/tutorial.js @@ -36,6 +36,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import * as StatusCheckStates from './status_check_states'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; const INSTRUCTIONS_TYPE = { ELASTIC_CLOUD: 'elasticCloud', @@ -43,7 +44,7 @@ const INSTRUCTIONS_TYPE = { ON_PREM_ELASTIC_CLOUD: 'onPremElasticCloud' }; -export class Tutorial extends React.Component { +class TutorialUi extends React.Component { constructor(props) { super(props); @@ -187,14 +188,18 @@ export class Tutorial extends React.Component { renderInstructionSetsToggle = () => { if (!this.props.isCloudEnabled && this.state.tutorial.onPremElasticCloud) { + const selfManagedLabel = this.props.intl.formatMessage({ id: 'kbn.home.tutorial.selfManagedButtonLabel', + defaultMessage: 'Self managed' }); + const cloudLabel = this.props.intl.formatMessage({ id: 'kbn.home.tutorial.elasticCloudButtonLabel', + defaultMessage: 'Elastic Cloud' }); const radioButtons = [ { id: INSTRUCTIONS_TYPE.ON_PREM, - label: 'Self managed', + label: selfManagedLabel, }, { id: INSTRUCTIONS_TYPE.ON_PREM_ELASTIC_CLOUD, - label: 'Elastic Cloud', + label: cloudLabel, } ]; return ( @@ -296,7 +301,11 @@ export class Tutorial extends React.Component {

- Unable to find tutorial {this.props.tutorialId} +

@@ -356,7 +365,7 @@ export class Tutorial extends React.Component { } } -Tutorial.propTypes = { +TutorialUi.propTypes = { addBasePath: PropTypes.func.isRequired, isCloudEnabled: PropTypes.bool.isRequired, getTutorial: PropTypes.func.isRequired, @@ -364,3 +373,5 @@ Tutorial.propTypes = { tutorialId: PropTypes.string.isRequired, bulkCreate: PropTypes.func.isRequired, }; + +export const Tutorial = injectI18n(TutorialUi); diff --git a/src/core_plugins/kibana/public/home/components/tutorial/tutorial.test.js b/src/core_plugins/kibana/public/home/components/tutorial/tutorial.test.js index 040fd671fc0279..cf6745cf9445a0 100644 --- a/src/core_plugins/kibana/public/home/components/tutorial/tutorial.test.js +++ b/src/core_plugins/kibana/public/home/components/tutorial/tutorial.test.js @@ -18,8 +18,8 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; +import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { Tutorial, @@ -64,9 +64,8 @@ const replaceTemplateStrings = (text) => { }; describe('isCloudEnabled is false', () => { - test('should render ON_PREM instructions with instruction toggle', () => { - const component = shallow( { const getBasicTutorial = () => { return loadBasicTutorialPromise; }; - const component = shallow( { }); test('should display ON_PREM_ELASTIC_CLOUD instructions when toggle is clicked', () => { - const component = mount( { }); test('should render ELASTIC_CLOUD instructions when isCloudEnabled is true', () => { - const component = shallow( { - const sampleDataSets = await listSampleDataSets(); - - if (!this._isMounted) { - return; - } - - this.setState({ - sampleDataSets: sampleDataSets.sort((a, b) => { - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }), - }); - } - onSelectedTabChanged = id => { this.setState({ selectedTabId: id, @@ -158,57 +144,43 @@ export class TutorialDirectory extends React.Component { )); } - renderTab = () => { + renderTabContent = () => { if (this.state.selectedTabId === SAMPLE_DATA_TAB_ID) { - return this.renderSampleDataSetsTab(); - } - - return this.renderTutorialsTab(); - } - - renderTutorialsTab = () => { - return this.state.tutorialCards - .filter((tutorial) => { - return this.state.selectedTabId === ALL_TAB_ID || this.state.selectedTabId === tutorial.category; - }) - .map((tutorial) => { - return ( - - - - ); - }); - }; - - renderSampleDataSetsTab = () => { - return this.state.sampleDataSets.map(sampleDataSet => { return ( - - - + ); - }); + } + + return ( + + { + this.state.tutorialCards + .filter((tutorial) => { + return this.state.selectedTabId === ALL_TAB_ID || this.state.selectedTabId === tutorial.category; + }) + .map((tutorial) => { + return ( + + + + ); + }) + } + + ); } render() { @@ -216,11 +188,19 @@ export class TutorialDirectory extends React.Component { - Home + + +

- Add Data to Kibana +

@@ -230,9 +210,7 @@ export class TutorialDirectory extends React.Component { {this.renderTabs()} - - { this.renderTab() } - + {this.renderTabContent()}
@@ -240,7 +218,7 @@ export class TutorialDirectory extends React.Component { } } -TutorialDirectory.propTypes = { +TutorialDirectoryUi.propTypes = { addBasePath: PropTypes.func.isRequired, openTab: PropTypes.string, isCloudEnabled: PropTypes.bool.isRequired, @@ -248,3 +226,5 @@ TutorialDirectory.propTypes = { setConfig: PropTypes.func.isRequired, clearIndexPatternsCache: PropTypes.func.isRequired, }; + +export const TutorialDirectory = injectI18n(TutorialDirectoryUi); diff --git a/src/core_plugins/kibana/public/home/components/welcome.js b/src/core_plugins/kibana/public/home/components/welcome.js index 237a6de55507ef..c95a00323c367e 100644 --- a/src/core_plugins/kibana/public/home/components/welcome.js +++ b/src/core_plugins/kibana/public/home/components/welcome.js @@ -37,6 +37,8 @@ import { EuiButtonEmpty, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + /** * Shows a full-screen welcome page that gives helpful quick links to beginners. */ @@ -67,10 +69,14 @@ export class Welcome extends React.Component { -

Welcome to Kibana

+

+ +

-

Your window into the Elastic Stack

+

+ +

@@ -81,9 +87,13 @@ export class Welcome extends React.Component { } + description={ + } footer={
- Try our sample data + - Explore on my own +
} diff --git a/src/core_plugins/kibana/public/home/load_tutorials.js b/src/core_plugins/kibana/public/home/load_tutorials.js index e74b083c25b642..d6b264154d4248 100644 --- a/src/core_plugins/kibana/public/home/load_tutorials.js +++ b/src/core_plugins/kibana/public/home/load_tutorials.js @@ -19,7 +19,8 @@ import _ from 'lodash'; import chrome from 'ui/chrome'; -import { notify } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; const baseUrl = chrome.addBasePath('/api/kibana/home/tutorials'); const headers = new Headers(); @@ -38,13 +39,20 @@ async function loadTutorials() { headers: headers, }); if (response.status >= 300) { - throw new Error(`Request failed with status code: ${response.status}`); + throw new Error(i18n.translate('kbn.home.loadTutorials.requestFailedErrorMessage', { + defaultMessage: 'Request failed with status code: {status}', values: { status: response.status } } + )); } tutorials = await response.json(); tutorialsLoaded = true; } catch(err) { - notify.error(`Unable to load tutorials, ${err}`); + toastNotifications.addDanger({ + title: i18n.translate('kbn.home.loadTutorials.unableToLoadErrorMessage', { + defaultMessage: 'Unable to load tutorials' } + ), + text: err.message, + }); } } diff --git a/src/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png b/src/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png index 31186a52f10f96..00ff0b6605d699 100644 Binary files a/src/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png and b/src/core_plugins/kibana/public/home/sample_data_resources/flights/dashboard.png differ diff --git a/src/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png b/src/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png new file mode 100644 index 00000000000000..0129d788219a58 Binary files /dev/null and b/src/core_plugins/kibana/public/home/sample_data_resources/logs/dashboard.png differ diff --git a/src/core_plugins/kibana/public/home/sample_data_sets.js b/src/core_plugins/kibana/public/home/sample_data_sets.js index 363986623bbb90..84823e1c01fca8 100644 --- a/src/core_plugins/kibana/public/home/sample_data_sets.js +++ b/src/core_plugins/kibana/public/home/sample_data_sets.js @@ -17,57 +17,16 @@ * under the License. */ -import chrome from 'ui/chrome'; -import { toastNotifications } from 'ui/notify'; +import { kfetch } from 'ui/kfetch'; -const sampleDataUrl = chrome.addBasePath('/api/sample_data'); -const headers = new Headers({ - Accept: 'application/json', - 'Content-Type': 'application/json', - 'kbn-xsrf': 'kibana', -}); +const sampleDataUrl = '/api/sample_data'; export async function listSampleDataSets() { - try { - const response = await fetch(sampleDataUrl, { - method: 'get', - credentials: 'include', - headers: headers, - }); - - if (response.status >= 300) { - throw new Error(`Request failed with status code: ${response.status}`); - } - - return await response.json(); - } catch (err) { - toastNotifications.addDanger({ - title: `Unable to load sample data sets list`, - text: `${err.message}`, - }); - return []; - } + return await kfetch({ method: 'GET', pathname: sampleDataUrl }); } -export async function installSampleDataSet(id, name, defaultIndex, getConfig, setConfig, clearIndexPatternsCache) { - try { - const response = await fetch(`${sampleDataUrl}/${id}`, { - method: 'post', - credentials: 'include', - headers: headers, - }); - - if (response.status >= 300) { - const body = await response.text(); - throw new Error(`Request failed with status code: ${response.status}, message: ${body}`); - } - } catch (err) { - toastNotifications.addDanger({ - title: `Unable to install sample data set: ${name}`, - text: `${err.message}`, - }); - return; - } +export async function installSampleDataSet(id, defaultIndex, getConfig, setConfig, clearIndexPatternsCache) { + await kfetch({ method: 'POST', pathname: `${sampleDataUrl}/${id}` }); const existingDefaultIndex = await getConfig('defaultIndex'); if (existingDefaultIndex === null) { @@ -75,31 +34,10 @@ export async function installSampleDataSet(id, name, defaultIndex, getConfig, se } clearIndexPatternsCache(); - - toastNotifications.addSuccess({ - title: `${name} installed`, - ['data-test-subj']: 'sampleDataSetInstallToast' - }); } -export async function uninstallSampleDataSet(id, name, defaultIndex, getConfig, setConfig, clearIndexPatternsCache) { - try { - const response = await fetch(`${sampleDataUrl}/${id}`, { - method: 'delete', - credentials: 'include', - headers: headers, - }); - if (response.status >= 300) { - const body = await response.text(); - throw new Error(`Request failed with status code: ${response.status}, message: ${body}`); - } - } catch (err) { - toastNotifications.addDanger({ - title: `Unable to uninstall sample data set`, - text: `${err.message}`, - }); - return; - } +export async function uninstallSampleDataSet(id, defaultIndex, getConfig, setConfig, clearIndexPatternsCache) { + await kfetch({ method: 'DELETE', pathname: `${sampleDataUrl}/${id}` }); const existingDefaultIndex = await getConfig('defaultIndex'); if (existingDefaultIndex && existingDefaultIndex === defaultIndex) { @@ -107,9 +45,4 @@ export async function uninstallSampleDataSet(id, name, defaultIndex, getConfig, } clearIndexPatternsCache(); - - toastNotifications.addSuccess({ - title: `${name} uninstalled`, - ['data-test-subj']: 'sampleDataSetUninstallToast' - }); } diff --git a/src/core_plugins/kibana/public/index.scss b/src/core_plugins/kibana/public/index.scss index 202284ae28355a..713bc1323d7ecc 100644 --- a/src/core_plugins/kibana/public/index.scss +++ b/src/core_plugins/kibana/public/index.scss @@ -1,4 +1,4 @@ -@import '../../../../src/ui/public/styles/styling_constants'; +@import 'ui/public/styles/styling_constants'; // Discover styles @import './discover/index'; diff --git a/src/core_plugins/kibana/public/kibana.js b/src/core_plugins/kibana/public/kibana.js index 522b6b70870257..7daafbf6411a94 100644 --- a/src/core_plugins/kibana/public/kibana.js +++ b/src/core_plugins/kibana/public/kibana.js @@ -35,7 +35,7 @@ import 'uiExports/savedObjectTypes'; import 'uiExports/fieldFormats'; import 'uiExports/fieldFormatEditors'; import 'uiExports/navbarExtensions'; -import 'uiExports/dashboardPanelActions'; +import 'uiExports/contextMenuActions'; import 'uiExports/managementSections'; import 'uiExports/devTools'; import 'uiExports/docViews'; @@ -43,6 +43,7 @@ import 'uiExports/embeddableFactories'; import 'uiExports/inspectorViews'; import 'uiExports/search'; import 'uiExports/autocompleteProviders'; +import 'uiExports/shareContextMenuExtensions'; import 'ui/autoload/all'; import './home'; @@ -59,7 +60,6 @@ import 'ui/agg_types'; import 'ui/timepicker'; import { showAppRedirectNotification } from 'ui/notify'; import 'leaflet'; -import { KibanaRootController } from './kibana_root_controller'; routes.enable(); @@ -68,6 +68,4 @@ routes redirectTo: `/${chrome.getInjected('kbnDefaultAppId', 'discover')}` }); -chrome.setRootController('kibana', KibanaRootController); - uiModules.get('kibana').run(showAppRedirectNotification); diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/__tests__/render.test.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/__tests__/render.test.js index 16da9009f5d5fb..9ef4a3e38ee9bb 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/__tests__/render.test.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/__tests__/render.test.js @@ -22,6 +22,12 @@ const unmountComponentAtNode = jest.fn(); jest.doMock('react-dom', () => ({ render, unmountComponentAtNode })); +// If we don't mock this, Jest fails with the error `TypeError: Cannot redefine property: prototype +// at Function.defineProperties`. +jest.mock('ui/index_patterns', () => ({ + INDEX_PATTERN_ILLEGAL_CHARACTERS: ['\\', '/', '?', '"', '<', '>', '|', ' '], +})); + jest.mock('ui/chrome', () => ({ getUiSettingsClient: () => ({ get: () => '', diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/__tests__/step_index_pattern.test.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/__tests__/step_index_pattern.test.js index 3b05ec4b71ec47..37df9762b54df8 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/__tests__/step_index_pattern.test.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/__tests__/step_index_pattern.test.js @@ -26,6 +26,12 @@ jest.mock('../../../lib/ensure_minimum_time', () => ({ ensureMinimumTime: async (promises) => Array.isArray(promises) ? await Promise.all(promises) : await promises })); +// If we don't mock this, Jest fails with the error `TypeError: Cannot redefine property: prototype +// at Function.defineProperties`. +jest.mock('ui/index_patterns', () => ({ + INDEX_PATTERN_ILLEGAL_CHARACTERS: ['\\', '/', '?', '"', '<', '>', '|', ' '], +})); + jest.mock('ui/chrome', () => ({ getUiSettingsClient: () => ({ get: () => '', diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__tests__/__snapshots__/indices_list.test.js.snap b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__tests__/__snapshots__/indices_list.test.js.snap index 943303f0bfdb2b..bdacb3b6c4734c 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__tests__/__snapshots__/indices_list.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__tests__/__snapshots__/indices_list.test.js.snap @@ -67,6 +67,7 @@ exports[`IndicesList should change pages 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} @@ -79,6 +80,7 @@ exports[`IndicesList should change pages 1`] = ` Array [ @@ -86,6 +88,7 @@ exports[`IndicesList should change pages 1`] = ` , @@ -93,6 +96,7 @@ exports[`IndicesList should change pages 1`] = ` , @@ -100,6 +104,7 @@ exports[`IndicesList should change pages 1`] = ` , @@ -171,6 +176,7 @@ exports[`IndicesList should change per page 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} @@ -183,6 +189,7 @@ exports[`IndicesList should change per page 1`] = ` Array [ @@ -190,6 +197,7 @@ exports[`IndicesList should change per page 1`] = ` , @@ -197,6 +205,7 @@ exports[`IndicesList should change per page 1`] = ` , @@ -204,6 +213,7 @@ exports[`IndicesList should change per page 1`] = ` , @@ -300,6 +310,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} @@ -312,6 +323,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` Array [ @@ -319,6 +331,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` , @@ -326,6 +339,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` , @@ -333,6 +347,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` , @@ -414,6 +429,7 @@ exports[`IndicesList should render normally 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} @@ -426,6 +442,7 @@ exports[`IndicesList should render normally 1`] = ` Array [ @@ -433,6 +450,7 @@ exports[`IndicesList should render normally 1`] = ` , @@ -440,6 +458,7 @@ exports[`IndicesList should render normally 1`] = ` , @@ -447,6 +466,7 @@ exports[`IndicesList should render normally 1`] = ` , @@ -608,6 +628,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} @@ -620,6 +641,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` Array [ @@ -627,6 +649,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` , @@ -634,6 +657,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` , @@ -641,6 +665,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` , diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.js index bf6468c3bf15ba..133154de526191 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/components/step_index_pattern/step_index_pattern.js @@ -19,10 +19,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { ILLEGAL_CHARACTERS, MAX_SEARCH_SIZE } from '../../constants'; +import { INDEX_PATTERN_ILLEGAL_CHARACTERS as ILLEGAL_CHARACTERS } from 'ui/index_patterns'; +import { MAX_SEARCH_SIZE } from '../../constants'; import { getIndices, - containsInvalidCharacters, + containsIllegalCharacters, getMatchedIndices, canAppendWildcard, ensureMinimumTime @@ -240,7 +241,7 @@ export class StepIndexPatternComponent extends Component { // This is an error scenario but do not report an error containsErrors = true; } - else if (!containsInvalidCharacters(query, ILLEGAL_CHARACTERS)) { + else if (containsIllegalCharacters(query, ILLEGAL_CHARACTERS)) { const errorMessage = intl.formatMessage( { id: 'kbn.management.createIndexPattern.step.invalidCharactersErrorMessage', diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/constants/index.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/constants/index.js index 79bdbaed0d7328..86246903b44409 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/constants/index.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/constants/index.js @@ -26,4 +26,3 @@ export const MAX_NUMBER_OF_MATCHING_INDICES = 100; export const MAX_SEARCH_SIZE = MAX_NUMBER_OF_MATCHING_INDICES + ESTIMATED_NUMBER_OF_SYSTEM_INDICES; export const PER_PAGE_INCREMENTS = [5, 10, 20, 50]; -export const ILLEGAL_CHARACTERS = ['\\', '/', '?', '"', '<', '>', '|', ' ']; diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/contains_invalid_characters.test.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/contains_invalid_characters.test.js index 2385f3baec6bc4..05c4aba2571bd0 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/contains_invalid_characters.test.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/contains_invalid_characters.test.js @@ -17,16 +17,16 @@ * under the License. */ -import { containsInvalidCharacters } from '../contains_invalid_characters'; +import { containsIllegalCharacters } from '../contains_illegal_characters'; -describe('containsInvalidCharacters', () => { - it('should fail with illegal characters', () => { - const valid = containsInvalidCharacters('abc', ['a']); - expect(valid).toBeFalsy(); +describe('containsIllegalCharacters', () => { + it('returns true with illegal characters', () => { + const isInvalid = containsIllegalCharacters('abc', ['a']); + expect(isInvalid).toBe(true); }); - it('should pass with no illegal characters', () => { - const valid = containsInvalidCharacters('abc', ['%']); - expect(valid).toBeTruthy(); + it('returns false with no illegal characters', () => { + const isInvalid = containsIllegalCharacters('abc', ['%']); + expect(isInvalid).toBe(false); }); }); diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/get_indices.test.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/get_indices.test.js index 150753fd34e366..f5dcccf6da50b2 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/get_indices.test.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/__tests__/get_indices.test.js @@ -80,7 +80,7 @@ describe('getIndices', () => { it('should throw exceptions', async () => { const es = { - search: () => { throw 'Fail'; } + search: () => { throw new Error('Fail'); } }; await expect(getIndices(es, 'kibana', 1)).rejects.toThrow(); diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_invalid_characters.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_illegal_characters.js similarity index 86% rename from src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_invalid_characters.js rename to src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_illegal_characters.js index 5dbe3d71110616..31485bb3daaa2b 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_invalid_characters.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/contains_illegal_characters.js @@ -17,6 +17,6 @@ * under the License. */ -export function containsInvalidCharacters(pattern, illegalCharacters) { - return !illegalCharacters.some(char => pattern.includes(char)); +export function containsIllegalCharacters(pattern, illegalCharacters) { + return illegalCharacters.some(char => pattern.includes(char)); } diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/get_indices.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/get_indices.js index 9a2a712d82c132..7638daf1c57204 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/get_indices.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/get_indices.js @@ -42,7 +42,7 @@ export async function getIndices(es, rawPattern, limit) { // We need to always provide a limit and not rely on the default if (!limit) { - throw '`getIndices()` was called without the required `limit` parameter.'; + throw new Error('`getIndices()` was called without the required `limit` parameter.'); } const params = { diff --git a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/index.js b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/index.js index 22efa498c84ab7..0930eb82514e10 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/index.js +++ b/src/core_plugins/kibana/public/management/sections/indices/create_index_pattern_wizard/lib/index.js @@ -25,6 +25,6 @@ export { getIndices } from './get_indices'; export { getMatchedIndices } from './get_matched_indices'; -export { containsInvalidCharacters } from './contains_invalid_characters'; +export { containsIllegalCharacters } from './contains_illegal_characters'; export { extractTimeFields } from './extract_time_fields'; diff --git a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.js b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.js index dc7b253d1370f3..80b1f9895b622a 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.js +++ b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/edit_index_pattern.js @@ -22,7 +22,7 @@ import './index_header'; import './create_edit_field'; import { KbnUrlProvider } from 'ui/url'; import { IndicesEditSectionsProvider } from './edit_sections'; -import { fatalError } from 'ui/notify'; +import { fatalError, toastNotifications } from 'ui/notify'; import uiRoutes from 'ui/routes'; import { uiModules } from 'ui/modules'; import template from './edit_index_pattern.html'; @@ -181,8 +181,7 @@ uiRoutes uiModules.get('apps/management') .controller('managementIndicesEdit', function ( - $scope, $location, $route, config, indexPatterns, Notifier, Private, AppState, docTitle, confirmModal) { - const notify = new Notifier(); + $scope, $location, $route, config, indexPatterns, Private, AppState, docTitle, confirmModal) { const $state = $scope.state = new AppState(); const { fieldWildcardMatcher } = Private(FieldWildcardProvider); @@ -292,7 +291,7 @@ uiModules.get('apps/management') const errorMessage = i18n.translate('kbn.management.editIndexPattern.notDateErrorMessage', { defaultMessage: 'That field is a {fieldType} not a date.', values: { fieldType: field.type } }); - notify.error(errorMessage); + toastNotifications.addDanger(errorMessage); return; } $scope.indexPattern.timeFieldName = field.name; diff --git a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/source_filters_table/__tests__/__snapshots__/source_filters_table.test.js.snap b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/source_filters_table/__tests__/__snapshots__/source_filters_table.test.js.snap index 0206f5c9774df3..3452fd16456e7a 100644 --- a/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/source_filters_table/__tests__/__snapshots__/source_filters_table.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/indices/edit_index_pattern/source_filters_table/__tests__/__snapshots__/source_filters_table.test.js.snap @@ -18,6 +18,12 @@ exports[`SourceFiltersTable should add a filter 1`] = ` "calls": Array [ Array [], ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], }, "sourceFilters": Array [ Object { @@ -106,6 +112,12 @@ exports[`SourceFiltersTable should remove a filter 1`] = ` "calls": Array [ Array [], ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], }, "sourceFilters": Array [ Object { @@ -308,6 +320,12 @@ exports[`SourceFiltersTable should update a filter 1`] = ` "calls": Array [ Array [], ], + "results": Array [ + Object { + "isThrow": false, + "value": undefined, + }, + ], }, "sourceFilters": Array [ Object { diff --git a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js index f99789b1932fe2..16e856fb610cc3 100644 --- a/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js +++ b/src/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/flyout.js @@ -324,6 +324,7 @@ export class Flyout extends Component { const options = this.state.indexPatterns.map(indexPattern => ({ text: indexPattern.get('title'), value: indexPattern.id, + ['data-test-subj']: `indexPatternOption-${indexPattern.get('title')}`, })); options.unshift({ @@ -333,7 +334,7 @@ export class Flyout extends Component { return ( this.onIndexChanged(id, e)} options={options} /> diff --git a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index c72d3e2f64c58b..85abe0571d1167 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -17,15 +17,7 @@ exports[`AdvancedSettings should render normally 1`] = ` component="div" grow={true} > - -

- Settings -

-
+ + `; @@ -331,15 +344,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` component="div" grow={true} > - -

- Settings -

-
+
+ `; diff --git a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js index 9f607a0e0577fe..7ce4341f59ed8e 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js +++ b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js @@ -25,7 +25,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiText, Query, } from '@elastic/eui'; @@ -36,6 +35,8 @@ import { Form } from './components/form'; import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; import './advanced_settings.less'; +import { registerDefaultComponents, PAGE_TITLE_COMPONENT, PAGE_FOOTER_COMPONENT } from './components/default_component_registry'; +import { getSettingsComponent } from './components/component_registry'; export class AdvancedSettings extends Component { static propTypes = { @@ -50,8 +51,11 @@ export class AdvancedSettings extends Component { this.init(config); this.state = { query: parsedQuery, + footerQueryMatched: false, filteredSettings: this.mapSettings(Query.execute(parsedQuery, this.settings)), }; + + registerDefaultComponents(); } init(config) { @@ -59,9 +63,9 @@ export class AdvancedSettings extends Component { this.groupedSettings = this.mapSettings(this.settings); this.categories = Object.keys(this.groupedSettings).sort((a, b) => { - if(a === DEFAULT_CATEGORY) return -1; - if(b === DEFAULT_CATEGORY) return 1; - if(a > b) return 1; + if (a === DEFAULT_CATEGORY) return -1; + if (b === DEFAULT_CATEGORY) return 1; + if (a > b) return 1; return a === b ? 0 : -1; }); @@ -126,20 +130,28 @@ export class AdvancedSettings extends Component { clearQuery = () => { this.setState({ query: Query.parse(''), + footerQueryMatched: false, filteredSettings: this.groupedSettings, }); } + onFooterQueryMatchChange = (matched) => { + this.setState({ + footerQueryMatched: matched + }); + } + render() { - const { filteredSettings, query } = this.state; + const { filteredSettings, query, footerQueryMatched } = this.state; + + const PageTitle = getSettingsComponent(PAGE_TITLE_COMPONENT); + const PageFooter = getSettingsComponent(PAGE_FOOTER_COMPONENT); return (
- -

Settings

-
+
- +
+
); } diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/__snapshots__/component_registry.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/__snapshots__/component_registry.test.js.snap new file mode 100644 index 00000000000000..070b3870570616 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/__snapshots__/component_registry.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getSettingsComponent should throw an error when requesting a component that does not exist 1`] = `"Component not found with id does not exist"`; + +exports[`registerSettingsComponent should disallow registering a component with a duplicate id 1`] = `"Component with id test2 is already registered."`; diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.js b/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.js new file mode 100644 index 00000000000000..fb901620abad9a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.js @@ -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. + */ + +const registry = {}; + +/** + * Attempts to register the provided component. + * If a component with that ID is already registered, then the registration fails. + * + * @param {*} id the id of the component to register + * @param {*} component the component + */ +export function tryRegisterSettingsComponent(id, component) { + if (id in registry) { + return false; + } + + registerSettingsComponent(id, component); + return true; +} + +/** + * Attempts to register the provided component, with the ability to optionally allow + * the component to override an existing one. + * + * If the intent is to override, then `allowOverride` must be set to true, otherwise an exception is thrown. + * + * @param {*} id the id of the component to register + * @param {*} component the component + * @param {*} allowOverride (default: false) - optional flag to allow this component to override a previously registered component + */ +export function registerSettingsComponent(id, component, allowOverride = false) { + if (!allowOverride && id in registry) { + throw new Error(`Component with id ${id} is already registered.`); + } + + // Setting a display name if one does not already exist. + // This enhances the snapshots, as well as the debugging experience. + if (!component.displayName) { + component.displayName = id; + } + + registry[id] = component; +} + +/** + * Retrieve a registered component by its ID. + * If the component does not exist, then an exception is thrown. + * + * @param {*} id the ID of the component to retrieve + */ +export function getSettingsComponent(id) { + if (!(id in registry)) { + throw new Error(`Component not found with id ${id}`); + } + return registry[id]; +} \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.test.js new file mode 100644 index 00000000000000..2c1c7ff17254bf --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/component_registry.test.js @@ -0,0 +1,89 @@ +/* + * 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 { tryRegisterSettingsComponent, registerSettingsComponent, getSettingsComponent } from './component_registry'; + + +describe('tryRegisterSettingsComponent', () => { + it('should allow a component to be registered', () => { + const component = {}; + expect(tryRegisterSettingsComponent('tryTest1', component)).toEqual(true); + }); + + it('should return false if the component is already registered, and not allow an override', () => { + const component = {}; + expect(tryRegisterSettingsComponent('tryTest2', component)).toEqual(true); + + const updatedComponent = { updated: 'yay' }; + expect(tryRegisterSettingsComponent('tryTest2', updatedComponent)).toEqual(false); + expect(getSettingsComponent('tryTest2')).toBe(component); + }); +}); + +describe('registerSettingsComponent', () => { + it('should allow a component to be registered', () => { + const component = {}; + registerSettingsComponent('test', component); + }); + + it('should disallow registering a component with a duplicate id', () => { + const component = {}; + registerSettingsComponent('test2', component); + expect(() => registerSettingsComponent('test2', 'some other component')).toThrowErrorMatchingSnapshot(); + }); + + it('should allow a component to be overriden', () => { + const component = {}; + registerSettingsComponent('test3', component); + + const anotherComponent = { 'anotherComponent': 'ok' }; + registerSettingsComponent('test3', anotherComponent, true); + + expect(getSettingsComponent('test3')).toBe(anotherComponent); + }); + + it('should set a displayName for the component if one does not exist', () => { + const component = {}; + registerSettingsComponent('display_name_component', component); + + expect(component.displayName).toEqual('display_name_component'); + }); + + it('should not set a displayName for the component if one already exists', () => { + const component = { + displayName: '' + }; + + registerSettingsComponent('another_display_name_component', component); + + expect(component.displayName).toEqual(''); + }); +}); + +describe('getSettingsComponent', () => { + it('should allow a component to be retrieved', () => { + const component = {}; + registerSettingsComponent('test4', component); + expect(getSettingsComponent('test4')).toBe(component); + }); + + it('should throw an error when requesting a component that does not exist', () => { + expect(() => getSettingsComponent('does not exist')).toThrowErrorMatchingSnapshot(); + }); +}); \ No newline at end of file diff --git a/src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js similarity index 62% rename from src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts rename to src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js index 72780e1882023d..221f8c2f82bf8c 100644 --- a/src/core/server/legacy_compat/__tests__/legacy_kbn_server.test.ts +++ b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js @@ -17,15 +17,14 @@ * under the License. */ -import { LegacyKbnServer } from '..'; +import { tryRegisterSettingsComponent } from './component_registry'; +import { PageTitle } from './page_title'; +import { PageFooter } from './page_footer'; -test('correctly returns `newPlatformProxyListener`.', () => { - const rawKbnServer = { - newPlatform: { - proxyListener: {}, - }, - }; +export const PAGE_TITLE_COMPONENT = 'advanced_settings_page_title'; +export const PAGE_FOOTER_COMPONENT = 'advanced_settings_page_footer'; - const legacyKbnServer = new LegacyKbnServer(rawKbnServer); - expect(legacyKbnServer.newPlatformProxyListener).toBe(rawKbnServer.newPlatform.proxyListener); -}); +export function registerDefaultComponents() { + tryRegisterSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle); + tryRegisterSettingsComponent(PAGE_FOOTER_COMPONENT, PageFooter); +} \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.test.js new file mode 100644 index 00000000000000..2c5b3c9b466137 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.test.js @@ -0,0 +1,43 @@ +/* + * 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 { registerDefaultComponents, PAGE_TITLE_COMPONENT } from './default_component_registry'; +import { getSettingsComponent, registerSettingsComponent } from './component_registry'; +import { PageTitle } from './page_title'; + +describe('default_component_registry', () => { + it('should register default components with the registry', () => { + registerDefaultComponents(); + expect(getSettingsComponent(PAGE_TITLE_COMPONENT)).toEqual(PageTitle); + }); + + it('should be able to call "registerDefaultComponents" several times without throwing', () => { + registerDefaultComponents(); + registerDefaultComponents(); + registerDefaultComponents(); + }); + + it('should not override components if they are already registered', () => { + const newComponent = {}; + registerSettingsComponent(PAGE_TITLE_COMPONENT, newComponent, true); + registerDefaultComponents(); + + expect(getSettingsComponent(PAGE_TITLE_COMPONENT)).toEqual(newComponent); + }); +}); \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap index 881e228173b31a..1c0861de51b3df 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap @@ -18,7 +18,7 @@ exports[`Field for array setting should render as read only with help text if ov +
- + @@ -34,15 +34,15 @@ exports[`Field for array setting should render as read only with help text if ov grow={true} size="xs" > - + Default: default_value - + - - + + } fullWidth={false} gutterSize="l" @@ -119,7 +119,7 @@ exports[`Field for array setting should render custom setting icon if it is cust +
- + } fullWidth={false} gutterSize="l" @@ -202,7 +202,7 @@ exports[`Field for array setting should render default value if there is no user +
- + } fullWidth={false} gutterSize="l" @@ -280,7 +280,7 @@ exports[`Field for array setting should render user value if there is user value +
- + @@ -296,15 +296,15 @@ exports[`Field for array setting should render user value if there is user value grow={true} size="xs" > - + Default: default_value - + - - + + } fullWidth={false} gutterSize="l" @@ -389,7 +389,7 @@ exports[`Field for boolean setting should render as read only with help text if +
- + @@ -405,15 +405,15 @@ exports[`Field for boolean setting should render as read only with help text if grow={true} size="xs" > - + Default: true - + - - + + } fullWidth={false} gutterSize="l" @@ -488,7 +488,7 @@ exports[`Field for boolean setting should render custom setting icon if it is cu +
- + } fullWidth={false} gutterSize="l" @@ -569,7 +569,7 @@ exports[`Field for boolean setting should render default value if there is no us +
- + } fullWidth={false} gutterSize="l" @@ -645,7 +645,7 @@ exports[`Field for boolean setting should render user value if there is user val +
- + @@ -661,15 +661,15 @@ exports[`Field for boolean setting should render user value if there is user val grow={true} size="xs" > - + Default: true - + - - + + } fullWidth={false} gutterSize="l" @@ -752,7 +752,7 @@ exports[`Field for image setting should render as read only with help text if ov +
- + @@ -768,15 +768,15 @@ exports[`Field for image setting should render as read only with help text if ov grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" @@ -850,7 +850,7 @@ exports[`Field for image setting should render custom setting icon if it is cust +
- + } fullWidth={false} gutterSize="l" @@ -932,7 +932,7 @@ exports[`Field for image setting should render default value if there is no user +
- + } fullWidth={false} gutterSize="l" @@ -1009,7 +1009,7 @@ exports[`Field for image setting should render user value if there is user value +
- + @@ -1025,15 +1025,15 @@ exports[`Field for image setting should render user value if there is user value grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" @@ -1126,7 +1126,7 @@ exports[`Field for json setting should render as read only with help text if ove +
- + @@ -1142,7 +1142,7 @@ exports[`Field for json setting should render as read only with help text if ove grow={true} size="xs" > - + Default: {} - + - - + + } fullWidth={false} gutterSize="l" @@ -1247,7 +1247,7 @@ exports[`Field for json setting should render custom setting icon if it is custo +
- + } fullWidth={false} gutterSize="l" @@ -1346,7 +1346,7 @@ exports[`Field for json setting should render default value if there is no user +
- + @@ -1362,7 +1362,7 @@ exports[`Field for json setting should render default value if there is no user grow={true} size="xs" > - + Default: {} - + - - + + } fullWidth={false} gutterSize="l" @@ -1475,7 +1475,7 @@ exports[`Field for json setting should render user value if there is user value +
- + @@ -1491,7 +1491,7 @@ exports[`Field for json setting should render user value if there is user value grow={true} size="xs" > - + Default: {} - + - - + + } fullWidth={false} gutterSize="l" @@ -1604,7 +1604,7 @@ exports[`Field for markdown setting should render as read only with help text if +
- + @@ -1620,15 +1620,15 @@ exports[`Field for markdown setting should render as read only with help text if grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" @@ -1721,7 +1721,7 @@ exports[`Field for markdown setting should render custom setting icon if it is c +
- + } fullWidth={false} gutterSize="l" @@ -1820,7 +1820,7 @@ exports[`Field for markdown setting should render default value if there is no u +
- + } fullWidth={false} gutterSize="l" @@ -1914,7 +1914,7 @@ exports[`Field for markdown setting should render user value if there is user va +
- + @@ -1930,15 +1930,15 @@ exports[`Field for markdown setting should render user value if there is user va grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" @@ -2039,7 +2039,7 @@ exports[`Field for number setting should render as read only with help text if o +
- + @@ -2055,15 +2055,15 @@ exports[`Field for number setting should render as read only with help text if o grow={true} size="xs" > - + Default: 5 - + - - + + } fullWidth={false} gutterSize="l" @@ -2140,7 +2140,7 @@ exports[`Field for number setting should render custom setting icon if it is cus +
- + } fullWidth={false} gutterSize="l" @@ -2223,7 +2223,7 @@ exports[`Field for number setting should render default value if there is no use +
- + } fullWidth={false} gutterSize="l" @@ -2301,7 +2301,7 @@ exports[`Field for number setting should render user value if there is user valu +
- + @@ -2317,15 +2317,15 @@ exports[`Field for number setting should render user value if there is user valu grow={true} size="xs" > - + Default: 5 - + - - + + } fullWidth={false} gutterSize="l" @@ -2410,7 +2410,7 @@ exports[`Field for select setting should render as read only with help text if o +
- + @@ -2426,15 +2426,15 @@ exports[`Field for select setting should render as read only with help text if o grow={true} size="xs" > - + Default: orange - + - - + + } fullWidth={false} gutterSize="l" @@ -2528,7 +2528,7 @@ exports[`Field for select setting should render custom setting icon if it is cus +
- + } fullWidth={false} gutterSize="l" @@ -2628,7 +2628,7 @@ exports[`Field for select setting should render default value if there is no use +
- + } fullWidth={false} gutterSize="l" @@ -2723,7 +2723,7 @@ exports[`Field for select setting should render user value if there is user valu +
- + @@ -2739,15 +2739,15 @@ exports[`Field for select setting should render user value if there is user valu grow={true} size="xs" > - + Default: orange - + - - + + } fullWidth={false} gutterSize="l" @@ -2849,7 +2849,7 @@ exports[`Field for string setting should render as read only with help text if o +
- + @@ -2865,15 +2865,15 @@ exports[`Field for string setting should render as read only with help text if o grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" @@ -2950,7 +2950,7 @@ exports[`Field for string setting should render custom setting icon if it is cus +
- + } fullWidth={false} gutterSize="l" @@ -3033,7 +3033,7 @@ exports[`Field for string setting should render default value if there is no use +
- + } fullWidth={false} gutterSize="l" @@ -3111,7 +3111,7 @@ exports[`Field for string setting should render user value if there is user valu +
- + @@ -3127,15 +3127,15 @@ exports[`Field for string setting should render user value if there is user valu grow={true} size="xs" > - + Default: null - + - - + + } fullWidth={false} gutterSize="l" diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 16ea544028c83b..c21456953c17af 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -87,7 +87,7 @@ export class Field extends PureComponent { getEditableValue(type, value, defVal) { const val = (value === null || value === undefined) ? defVal : value; - switch(type) { + switch (type) { case 'array': return val.join(', '); case 'boolean': @@ -102,10 +102,10 @@ export class Field extends PureComponent { } getDisplayedDefaultValue(type, defVal) { - if(defVal === undefined || defVal === null || defVal === '') { + if (defVal === undefined || defVal === null || defVal === '') { return 'null'; } - switch(type) { + switch (type) { case 'array': return defVal.join(', '); default: @@ -193,7 +193,7 @@ export class Field extends PureComponent { } onImageChange = async (files) => { - if(!files.length) { + if (!files.length) { this.clearError(); this.setState({ unsavedValue: null, @@ -212,18 +212,18 @@ export class Field extends PureComponent { changeImage: true, unsavedValue: base64Image, }); - } catch(err) { + } catch (err) { toastNotifications.addDanger('Image could not be saved'); this.cancelChangeImage(); } } getImageAsBase64(file) { - if(!file instanceof File) { + if (!file instanceof File) { return null; } - const reader = new FileReader(); + const reader = new FileReader(); reader.readAsDataURL(file); return new Promise((resolve, reject) => { @@ -245,7 +245,7 @@ export class Field extends PureComponent { cancelChangeImage = () => { const { savedValue } = this.state; - if(this.changeImageForm) { + if (this.changeImageForm) { this.changeImageForm.fileInput.value = null; this.changeImageForm.handleChange(); } @@ -268,14 +268,14 @@ export class Field extends PureComponent { const { name, defVal, type } = this.props.setting; const { changeImage, savedValue, unsavedValue, isJsonArray } = this.state; - if(savedValue === unsavedValue) { + if (savedValue === unsavedValue) { return; } let valueToSave = unsavedValue; let isSameValue = false; - switch(type) { + switch (type) { case 'array': valueToSave = valueToSave.split(',').map(val => val.trim()); isSameValue = valueToSave.join(',') === defVal.join(','); @@ -295,10 +295,10 @@ export class Field extends PureComponent { await this.props.save(name, valueToSave); } - if(changeImage) { + if (changeImage) { this.cancelChangeImage(); } - } catch(e) { + } catch (e) { toastNotifications.addDanger(`Unable to save ${name}`); } this.setLoading(false); @@ -311,7 +311,7 @@ export class Field extends PureComponent { await this.props.clear(name); this.cancelChangeImage(); this.clearError(); - } catch(e) { + } catch (e) { toastNotifications.addDanger(`Unable to reset ${name}`); } this.setLoading(false); @@ -321,7 +321,7 @@ export class Field extends PureComponent { const { loading, changeImage, unsavedValue } = this.state; const { name, value, type, options, isOverridden } = setting; - switch(type) { + switch (type) { case 'boolean': return ( ); case 'image': - if(!isDefaultValue(setting) && !changeImage) { + if (!isDefaultValue(setting) && !changeImage) { return ( {setting.name} @@ -438,7 +438,7 @@ export class Field extends PureComponent { const defaultLink = this.renderResetToDefaultLink(setting); const imageLink = this.renderChangeImageLink(setting); - if(defaultLink || imageLink) { + if (defaultLink || imageLink) { return ( {defaultLink} @@ -462,8 +462,12 @@ export class Field extends PureComponent { } renderDescription(setting) { - return ( - + let description; + + if (React.isValidElement(setting.description)) { + description = setting.description; + } else { + description = (
+ ); + } + + return ( + + {description} {this.renderDefaultValue(setting)} ); @@ -478,14 +488,14 @@ export class Field extends PureComponent { renderDefaultValue(setting) { const { type, defVal } = setting; - if(isDefaultValue(setting)) { + if (isDefaultValue(setting)) { return; } return ( - { type === 'json' ? ( + {type === 'json' ? ( Default: ) : ( - Default: {this.getDisplayedDefaultValue(type, defVal)} + Default: {this.getDisplayedDefaultValue(type, defVal)} - ) } + )} ); @@ -508,7 +518,7 @@ export class Field extends PureComponent { renderResetToDefaultLink(setting) { const { ariaName, name } = setting; - if(isDefaultValue(setting)) { + if (isDefaultValue(setting)) { return; } return ( @@ -528,7 +538,7 @@ export class Field extends PureComponent { renderChangeImageLink(setting) { const { changeImage } = this.state; const { type, value, ariaName, name } = setting; - if(type !== 'image' || !value || changeImage) { + if (type !== 'image' || !value || changeImage) { return; } return ( @@ -554,7 +564,7 @@ export class Field extends PureComponent { } return ( - + `; + exports[`Form should render no settings message when there are no settings 1`] = ` @@ -95,12 +96,23 @@ export class Form extends PureComponent { ); } + maybeRenderNoSettings(clearQuery) { + if (this.props.showNoResultsMessage) { + return ( + + No settings found (Clear search) + + ); + } + return null; + } + render() { const { settings, categories, categoryCounts, clearQuery } = this.props; const currentCategories = []; categories.forEach(category => { - if(settings[category] && settings[category].length) { + if (settings[category] && settings[category].length) { currentCategories.push(category); } }); @@ -112,11 +124,7 @@ export class Form extends PureComponent { return ( this.renderCategory(category, settings[category], categoryCounts[category]) // fix this ); - }) : ( - - No settings found (Clear search) - - ) + }) : this.maybeRenderNoSettings(clearQuery) } ); diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js index cd3b3f2db5fb3e..fddaae79ec44e2 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/form/form.test.js @@ -69,9 +69,9 @@ const categoryCounts = { dashboard: 1, 'x-pack': 10, }; -const save = () => {}; -const clear = () => {}; -const clearQuery = () => {}; +const save = () => { }; +const clear = () => { }; +const clearQuery = () => { }; describe('Form', () => { it('should render normally', async () => { @@ -83,6 +83,7 @@ describe('Form', () => { save={save} clear={clear} clearQuery={clearQuery} + showNoResultsMessage={true} /> ); @@ -98,6 +99,23 @@ describe('Form', () => { save={save} clear={clear} clearQuery={clearQuery} + showNoResultsMessage={true} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + it('should not render no settings message when instructed not to', async () => { + const component = shallow( + ); diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap new file mode 100644 index 00000000000000..eea1003c8eb95b --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/__snapshots__/page_footer.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageFooter should render normally 1`] = `""`; diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js new file mode 100644 index 00000000000000..2fae89ceb0380a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/index.js @@ -0,0 +1,20 @@ +/* + * 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 { PageFooter } from './page_footer'; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js new file mode 100644 index 00000000000000..e55fbbae3b5f83 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.js @@ -0,0 +1,20 @@ +/* + * 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 PageFooter = () => null; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js new file mode 100644 index 00000000000000..e4ac6af0a88fef --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_footer/page_footer.test.js @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { PageFooter } from './page_footer'; + +describe('PageFooter', () => { + it('should render normally', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_title/__snapshots__/page_title.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/__snapshots__/page_title.test.js.snap new file mode 100644 index 00000000000000..f93bd34d9312e3 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/__snapshots__/page_title.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageTitle should render normally 1`] = ` + +

+ Settings +

+
+`; diff --git a/src/functional_test_runner/__tests__/lib/index.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/index.js similarity index 95% rename from src/functional_test_runner/__tests__/lib/index.js rename to src/core_plugins/kibana/public/management/sections/settings/components/page_title/index.js index a92d22e2738bb1..f5553eb971ac34 100644 --- a/src/functional_test_runner/__tests__/lib/index.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/index.js @@ -17,4 +17,4 @@ * under the License. */ -export { startupKibana } from './kibana'; +export { PageTitle } from './page_title'; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.js new file mode 100644 index 00000000000000..d76cb6bc52856a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.js @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { + EuiText +} from '@elastic/eui'; + +export const PageTitle = () => { + return ( + +

Settings

+
+ ); +}; \ No newline at end of file diff --git a/src/ui/public/dashboard_panel_actions/index.ts b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.test.js similarity index 77% rename from src/ui/public/dashboard_panel_actions/index.ts rename to src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.test.js index ec931eb48b5ae2..0b3edd71764bb2 100644 --- a/src/ui/public/dashboard_panel_actions/index.ts +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_title/page_title.test.js @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ +import React from 'react'; +import { shallow } from 'enzyme'; -export { DashboardContextMenuPanel } from './dashboard_context_menu_panel'; -export { DashboardPanelAction } from './dashboard_panel_action'; -export { DashboardPanelActionsRegistryProvider } from './dashboard_panel_actions_registry'; +import { PageTitle } from './page_title'; + +describe('PageTitle', () => { + it('should render normally', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/src/core_plugins/kibana/public/selectors/types.ts b/src/core_plugins/kibana/public/selectors/types.ts index 8789f3ca1d5e5a..61ae8da5ed4bba 100644 --- a/src/core_plugins/kibana/public/selectors/types.ts +++ b/src/core_plugins/kibana/public/selectors/types.ts @@ -18,6 +18,7 @@ */ import { Action } from 'redux'; +import { ThunkAction } from 'redux-thunk'; import { DashboardState } from '../dashboard/selectors'; export interface CoreKibanaState { @@ -28,3 +29,10 @@ export interface KibanaAction extends Action { readonly type: T; readonly payload: P; } + +export type KibanaThunk< + R = Action | Promise | void, + S = CoreKibanaState, + E = any, + A extends Action = Action +> = ThunkAction; diff --git a/src/core_plugins/kibana/public/store.ts b/src/core_plugins/kibana/public/store.ts index 0ae75249d5b259..47458bc5249fca 100644 --- a/src/core_plugins/kibana/public/store.ts +++ b/src/core_plugins/kibana/public/store.ts @@ -23,11 +23,10 @@ import thunk from 'redux-thunk'; import { QueryLanguageType } from 'ui/embeddable/types'; import { DashboardViewMode } from './dashboard/dashboard_view_mode'; import { reducers } from './reducers'; -import { CoreKibanaState } from './selectors'; const enhancers = [applyMiddleware(thunk)]; -export const store = createStore( +export const store = createStore( reducers, { dashboard: { diff --git a/src/core_plugins/kibana/public/visualize/editor/editor.js b/src/core_plugins/kibana/public/visualize/editor/editor.js index 39e64c62cc2805..07eef274f47956 100644 --- a/src/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/core_plugins/kibana/public/visualize/editor/editor.js @@ -23,11 +23,10 @@ import './visualization_editor'; import 'ui/vis/editors/default/sidebar'; import 'ui/visualize'; import 'ui/collapsible_sidebar'; -import 'ui/share'; import 'ui/query_bar'; import chrome from 'ui/chrome'; import angular from 'angular'; -import { Notifier, toastNotifications } from 'ui/notify'; +import { toastNotifications } from 'ui/notify'; import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; import { DocTitleProvider } from 'ui/doc_title'; import { FilterBarQueryFilterProvider } from 'ui/filter_bar/query_filter'; @@ -43,6 +42,8 @@ import { migrateLegacyQuery } from 'ui/utils/migrateLegacyQuery'; import { recentlyAccessed } from 'ui/persisted_log'; import { timefilter } from 'ui/timefilter'; import { getVisualizeLoader } from '../../../../../ui/public/visualize/loader'; +import { showShareContextMenu, ShareContextMenuExtensionsRegistryProvider } from 'ui/share'; +import { getUnhashableStatesProvider } from 'ui/state_management/state_hashing'; uiRoutes .when(VisualizeConstants.CREATE_PATH, { @@ -115,10 +116,8 @@ function VisEditor( ) { const docTitle = Private(DocTitleProvider); const queryFilter = Private(FilterBarQueryFilterProvider); - - const notify = new Notifier({ - location: 'Visualization Editor' - }); + const getUnhashableStates = Private(getUnhashableStatesProvider); + const shareContextMenuExtensions = Private(ShareContextMenuExtensionsRegistryProvider); // Retrieve the resolved SavedVis instance. const savedVis = $route.current.locals.savedVis; @@ -140,6 +139,10 @@ function VisEditor( $scope.vis = vis; + const $appStatus = this.appStatus = { + dirty: !savedVis.id + }; + $scope.topNavMenu = [{ key: 'save', description: 'Save Visualization', @@ -156,8 +159,23 @@ function VisEditor( }, { key: 'share', description: 'Share Visualization', - template: require('plugins/kibana/visualize/editor/panels/share.html'), - testId: 'visualizeShareButton', + testId: 'shareTopNavButton', + run: (menuItem, navController, anchorElement) => { + const hasUnappliedChanges = vis.dirty; + const hasUnsavedChanges = $appStatus.dirty; + showShareContextMenu({ + anchorElement, + allowEmbed: true, + getUnhashableStates, + objectId: savedVis.id, + objectType: 'visualization', + shareContextMenuExtensions, + sharingData: { + title: savedVis.title, + }, + isDirty: hasUnappliedChanges || hasUnsavedChanges, + }); + } }, { key: 'inspect', description: 'Open Inspector for visualization', @@ -184,18 +202,6 @@ function VisEditor( let stateMonitor; - const $appStatus = this.appStatus = { - dirty: !savedVis.id - }; - - this.getSharingTitle = () => { - return savedVis.title; - }; - - this.getSharingType = () => { - return 'visualization'; - }; - if (savedVis.id) { docTitle.change(savedVis.title); } @@ -251,7 +257,7 @@ function VisEditor( $scope.isAddToDashMode = () => addToDashMode; $scope.timeRange = timefilter.getTime(); - $scope.opts = _.pick($scope, 'doSave', 'savedVis', 'shareData', 'isAddToDashMode'); + $scope.opts = _.pick($scope, 'doSave', 'savedVis', 'isAddToDashMode'); stateMonitor = stateMonitorFactory.create($state, stateDefaults); stateMonitor.ignoreProps([ 'vis.listeners' ]).onChange((status) => { @@ -377,7 +383,13 @@ function VisEditor( kbnUrl.change(`${VisualizeConstants.EDIT_PATH}/{{id}}`, { id: savedVis.id }); } } - }, notify.error); + }, (err) => { + toastNotifications.addDanger({ + title: `Error on saving '${savedVis.title}'`, + text: err.message, + 'data-test-subj': 'saveVisualizationError', + }); + }); }; $scope.unlink = function () { diff --git a/src/core_plugins/kibana/public/visualize/editor/panels/share.html b/src/core_plugins/kibana/public/visualize/editor/panels/share.html deleted file mode 100644 index 1eeaf5afa608e2..00000000000000 --- a/src/core_plugins/kibana/public/visualize/editor/panels/share.html +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/src/core_plugins/kibana/public/visualize/editor/styles/_editor.less b/src/core_plugins/kibana/public/visualize/editor/styles/_editor.less index 6991ae34d6cb19..2cfc573345885a 100644 --- a/src/core_plugins/kibana/public/visualize/editor/styles/_editor.less +++ b/src/core_plugins/kibana/public/visualize/editor/styles/_editor.less @@ -124,7 +124,7 @@ /* Without setting this to 0 you will run into a bug where the filter bar modal is hidden under a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ - > visualize { + > .visualize { height: 100%; flex: 1 1 auto; display: flex; @@ -419,7 +419,7 @@ a tilemap in an iframe: https://github.com/elastic/kibana/issues/16457 */ flex-basis: 100%; } - visualize { + .visualize { .flex-parent(); flex: 1 1 100%; } diff --git a/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js b/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js index dc0663d4aabe52..2aa3953a78bd91 100644 --- a/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js +++ b/src/core_plugins/kibana/server/lib/__tests__/manage_uuid.js @@ -19,7 +19,7 @@ import expect from 'expect.js'; import sinon from 'sinon'; -import * as kbnTestServer from '../../../../../test_utils/kbn_server.js'; +import { startTestServers } from '../../../../../test_utils/kbn_server'; import manageUuid from '../manage_uuid'; describe('core_plugins/kibana/server/lib', function () { @@ -27,24 +27,24 @@ describe('core_plugins/kibana/server/lib', function () { const testUuid = 'c4add484-0cba-4e05-86fe-4baa112d9e53'; let kbnServer; let config; + let servers; before(async function () { - this.timeout(60000); // sometimes waiting for server takes longer than 10 - - kbnServer = kbnTestServer.createServerWithCorePlugins(); - - await kbnServer.ready(); + servers = await startTestServers({ + adjustTimeout: (t) => { + this.timeout(t); + }, + }); + kbnServer = servers.kbnServer; }); - // clear uuid stuff from previous test runs + // Clear uuid stuff from previous test runs beforeEach(function () { kbnServer.server.log = sinon.stub(); config = kbnServer.server.config(); }); - after(async function () { - await kbnServer.close(); - }); + after(() => servers.stop()); it('ensure config uuid is validated as a guid', async function () { config.set('server.uuid', testUuid); diff --git a/src/core_plugins/kibana/server/routes/api/export/index.js b/src/core_plugins/kibana/server/routes/api/export/index.js index 832369c1f0f46f..34f45e9a6b2dad 100644 --- a/src/core_plugins/kibana/server/routes/api/export/index.js +++ b/src/core_plugins/kibana/server/routes/api/export/index.js @@ -45,7 +45,7 @@ export function exportApi(server) { reply(json) .header('Content-Disposition', `attachment; filename="${filename}"`) .header('Content-Type', 'application/json') - .header('Content-Length', json.length); + .header('Content-Length', Buffer.byteLength(json, 'utf8')); }) .catch(err => reply(Boom.boomify(err, { statusCode: 400 }))); } diff --git a/src/core_plugins/kibana/server/routes/api/home/register_tutorials.js b/src/core_plugins/kibana/server/routes/api/home/register_tutorials.js index c7e0909631eaec..89f1ff6213a14e 100644 --- a/src/core_plugins/kibana/server/routes/api/home/register_tutorials.js +++ b/src/core_plugins/kibana/server/routes/api/home/register_tutorials.js @@ -23,7 +23,7 @@ export function registerTutorials(server) { path: '/api/kibana/home/tutorials', method: ['GET'], handler: async function (req, reply) { - reply(server.getTutorials()); + reply(server.getTutorials(req)); } }); } diff --git a/src/core_plugins/kibana/server/tutorials/aerospike_metrics/index.js b/src/core_plugins/kibana/server/tutorials/aerospike_metrics/index.js index 8cfd4676065426..f9b656a888e1c4 100644 --- a/src/core_plugins/kibana/server/tutorials/aerospike_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/aerospike_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,26 @@ export function aerospikeMetricsSpecProvider() { const moduleName = 'aerospike'; return { id: 'aerospikeMetrics', - name: 'Aerospike metrics', + name: i18n.translate('kbn.server.tutorials.aerospikeMetrics.nameTitle', { + defaultMessage: 'Aerospike metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the Aerospike server.', - longDescription: 'The `aerospike` Metricbeat module fetches internal metrics from Aerospike.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-aerospike.html).', + shortDescription: i18n.translate('kbn.server.tutorials.aerospikeMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the Aerospike server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.aerospikeMetrics.longDescription', { + defaultMessage: 'The `aerospike` Metricbeat module fetches internal metrics from Aerospike. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-aerospike.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.aerospikeMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/apache_logs/index.js b/src/core_plugins/kibana/server/tutorials/apache_logs/index.js index 2918193f169ca5..f9c90abc54bf12 100644 --- a/src/core_plugins/kibana/server/tutorials/apache_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/apache_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function apacheLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'apacheLogs', - name: 'Apache logs', + name: i18n.translate('kbn.server.tutorials.apacheLogs.nameTitle', { + defaultMessage: 'Apache logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse access and error logs created by the Apache HTTP server.', - longDescription: 'The apache2 Filebeat module parses access and error logs created by the Apache 2 HTTP server.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-apache2.html).', + shortDescription: i18n.translate('kbn.server.tutorials.apacheLogs.shortDescription', { + defaultMessage: 'Collect and parse access and error logs created by the Apache HTTP server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.apacheLogs.longDescription', { + defaultMessage: 'The apache2 Filebeat module parses access and error logs created by the Apache 2 HTTP server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-apache2.html', + }, + }), euiIconType: 'logoApache', artifacts: { dashboards: [ { id: 'Filebeat-Apache2-Dashboard', - linkLabel: 'Apache2 logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.apacheLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'Apache2 logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/apache_metrics/index.js b/src/core_plugins/kibana/server/tutorials/apache_metrics/index.js index 82a6ba57f39177..9ea9c894d89777 100644 --- a/src/core_plugins/kibana/server/tutorials/apache_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/apache_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,17 +25,28 @@ export function apacheMetricsSpecProvider() { const moduleName = 'apache'; return { id: 'apacheMetrics', - name: 'Apache metrics', + name: i18n.translate('kbn.server.tutorials.apacheMetrics.nameTitle', { + defaultMessage: 'Apache metrics', + }), category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the Apache 2 HTTP server.', - longDescription: 'The `apache` Metricbeat module fetches internal metrics from the Apache 2 HTTP server.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-apache.html).', + shortDescription: i18n.translate('kbn.server.tutorials.apacheMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the Apache 2 HTTP server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.apacheMetrics.longDescription', { + defaultMessage: 'The `apache` Metricbeat module fetches internal metrics from the Apache 2 HTTP server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-apache.html', + }, + }), euiIconType: 'logoApache', artifacts: { dashboards: [ { id: 'Metricbeat-Apache-HTTPD-server-status', - linkLabel: 'Apache metrics dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.apacheMetrics.artifacts.dashboards.linkLabel', { + defaultMessage: 'Apache metrics dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/apm/apm_client_instructions.js b/src/core_plugins/kibana/server/tutorials/apm/apm_client_instructions.js index 21276a4dd25ab6..138bebdc579985 100644 --- a/src/core_plugins/kibana/server/tutorials/apm/apm_client_instructions.js +++ b/src/core_plugins/kibana/server/tutorials/apm/apm_client_instructions.js @@ -17,123 +17,198 @@ * under the License. */ -/* eslint-disable max-len */ +import { i18n } from '@kbn/i18n'; -export const NODE_CLIENT_INSTRUCTIONS = [ +export const createNodeClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: - 'Install the APM agent for Node.js as a dependency to your application.', + title: i18n.translate('kbn.server.tutorials.apm.nodeClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.nodeClient.install.textPre', { + defaultMessage: 'Install the APM agent for Node.js as a dependency to your application.', + }), commands: ['npm install elastic-apm-node --save'], }, { - title: 'Configure the agent', - textPre: - 'Agents are libraries that run inside of your application process.' + - ' APM services are created programmatically based on the `serviceName`.' + - ' This agent supports Express, Koa, hapi, and custom Node.js.', - commands: `// Add this to the VERY top of the first file loaded in your app + title: i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.textPre', { + defaultMessage: 'Agents are libraries that run inside of your application process. \ +APM services are created programmatically based on the `serviceName`. \ +This agent supports a vararity of frameworks but can also be used with your custom stack.', + }), + commands: `// ${i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.commands.addThisToTheFileTopComment', { + defaultMessage: 'Add this to the VERY top of the first file loaded in your app', + })} var apm = require('elastic-apm-node').start({curlyOpen} - // Set required service name (allowed characters: a-z, A-Z, 0-9, -, _, and space) + // ${i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.commands.setRequiredServiceNameComment', { + defaultMessage: 'Override service name from package.json', + })} + // ${i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.commands.allowedCharactersComment', { + defaultMessage: 'Allowed characters: a-z, A-Z, 0-9, -, _, and space', + })} serviceName: '', - // Use if APM Server requires a token + // ${i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.commands.useIfApmRequiresTokenComment', { + defaultMessage: 'Use if APM Server requires a token', + })} secretToken: '', - // Set custom APM Server URL (default: http://localhost:8200) + // ${i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.commands.setCustomApmServerUrlComment', { + defaultMessage: 'Set custom APM Server URL (default: {defaultApmServerUrl})', + values: { defaultApmServerUrl: 'http://localhost:8200' }, + })} serverUrl: '' {curlyClose})`.split('\n'), - textPost: `See [the documentation]({config.docs.base_url}guide/en/apm/agent/nodejs/1.x/index.html) for advanced usage, including how to use with [Babel/ES Modules]({config.docs.base_url}guide/en/apm/agent/nodejs/1.x/advanced-setup.html#es-modules).`, + textPost: i18n.translate('kbn.server.tutorials.apm.nodeClient.configure.textPost', { + defaultMessage: 'See [the documentation]({documentationLink}) for advanced usage, including how to use with \ +[Babel/ES Modules]({babelEsModulesLink}).', + values: { + documentationLink: '{config.docs.base_url}guide/en/apm/agent/nodejs/1.x/index.html', + babelEsModulesLink: '{config.docs.base_url}guide/en/apm/agent/nodejs/1.x/advanced-setup.html#es-modules', + }, + }), }, ]; -export const DJANGO_CLIENT_INSTRUCTIONS = [ +export const createDjangoClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: 'Install the APM agent for Python as a dependency.', + title: i18n.translate('kbn.server.tutorials.apm.djangoClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.djangoClient.install.textPre', { + defaultMessage: 'Install the APM agent for Python as a dependency.', + }), commands: ['$ pip install elastic-apm'], }, { - title: 'Configure the agent', - textPre: - 'Agents are libraries that run inside of your application process.' + - ' APM services are created programmatically based on the `SERVICE_NAME`.', - commands: `# Add the agent to the installed apps + title: i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.textPre', { + defaultMessage: 'Agents are libraries that run inside of your application process. \ +APM services are created programmatically based on the `SERVICE_NAME`.', + }), + commands: `# ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.addAgentComment', { + defaultMessage: 'Add the agent to the installed apps', + })} INSTALLED_APPS = ( 'elasticapm.contrib.django', # ... ) ELASTIC_APM = {curlyOpen} - # Set required service name. Allowed characters: - # a-z, A-Z, 0-9, -, _, and space + # ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.setRequiredServiceNameComment', { + defaultMessage: 'Set required service name. Allowed characters:', + })} + # ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.allowedCharactersComment', { + defaultMessage: 'a-z, A-Z, 0-9, -, _, and space', + })} 'SERVICE_NAME': '', - # Use if APM Server requires a token + # ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.useIfApmServerRequiresTokenComment', { + defaultMessage: 'Use if APM Server requires a token', + })} 'SECRET_TOKEN': '', - # Set custom APM Server URL (default: http://localhost:8200) + # ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.setCustomApmServerUrlComment', { + defaultMessage: 'Set custom APM Server URL (default: {defaultApmServerUrl})', + values: { defaultApmServerUrl: 'http://localhost:8200' }, + })} 'SERVER_URL': '', {curlyClose} -# To send performance metrics, add our tracing middleware: +# ${i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.commands.addTracingMiddlewareComment', { + defaultMessage: 'To send performance metrics, add our tracing middleware:', + })} MIDDLEWARE = ( 'elasticapm.contrib.django.middleware.TracingMiddleware', #... )`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/python/2.x/django-support.html) for advanced usage.', + textPost: i18n.translate('kbn.server.tutorials.apm.djangoClient.configure.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for advanced usage.', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/python/2.x/django-support.html' }, + }), }, ]; -export const FLASK_CLIENT_INSTRUCTIONS = [ +export const createFlaskClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: 'Install the APM agent for Python as a dependency.', + title: i18n.translate('kbn.server.tutorials.apm.flaskClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.flaskClient.install.textPre', { + defaultMessage: 'Install the APM agent for Python as a dependency.', + }), commands: ['$ pip install elastic-apm[flask]'], }, { - title: 'Configure the agent', - textPre: - 'Agents are libraries that run inside of your application process.' + - ' APM services are created programmatically based on the `SERVICE_NAME`.', - commands: `# initialize using environment variables + title: i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.textPre', { + defaultMessage: 'Agents are libraries that run inside of your application process. \ +APM services are created programmatically based on the `SERVICE_NAME`.', + }), + commands: `# ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.initializeUsingEnvironmentVariablesComment', { + defaultMessage: 'initialize using environment variables', + })} from elasticapm.contrib.flask import ElasticAPM app = Flask(__name__) apm = ElasticAPM(app) -# or configure to use ELASTIC_APM in your application's settings +# ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.configureElasticApmComment', { + defaultMessage: 'or configure to use ELASTIC_APM in your application\'s settings', + })} from elasticapm.contrib.flask import ElasticAPM app.config['ELASTIC_APM'] = {curlyOpen} - # Set required service name. Allowed characters: - # a-z, A-Z, 0-9, -, _, and space + # ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.setRequiredServiceNameComment', { + defaultMessage: 'Set required service name. Allowed characters:', + })} + # ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.allowedCharactersComment', { + defaultMessage: 'a-z, A-Z, 0-9, -, _, and space', + })} 'SERVICE_NAME': '', - # Use if APM Server requires a token + # ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.useIfApmServerRequiresTokenComment', { + defaultMessage: 'Use if APM Server requires a token', + })} 'SECRET_TOKEN': '', - # Set custom APM Server URL (default: http://localhost:8200) + # ${i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.commands.setCustomApmServerUrlComment', { + defaultMessage: 'Set custom APM Server URL (default: {defaultApmServerUrl})', + values: { defaultApmServerUrl: 'http://localhost:8200' }, + })} 'SERVER_URL': '', {curlyClose} apm = ElasticAPM(app)`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/python/2.x/flask-support.html) for advanced usage.', + textPost: i18n.translate('kbn.server.tutorials.apm.flaskClient.configure.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for advanced usage.', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/python/2.x/flask-support.html' }, + }), }, ]; -export const RAILS_CLIENT_INSTRUCTIONS = [ +export const createRailsClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: 'Add the agent to your Gemfile.', + title: i18n.translate('kbn.server.tutorials.apm.railsClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.railsClient.install.textPre', { + defaultMessage: 'Add the agent to your Gemfile.', + }), commands: [`gem 'elastic-apm'`], }, { - title: 'Configure the agent', - textPre: - 'APM is automatically started when your app boots. Configure the agent, by creating the config file `config/elastic_apm.yml`', + title: i18n.translate('kbn.server.tutorials.apm.railsClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.railsClient.configure.textPre', { + defaultMessage: 'APM is automatically started when your app boots. Configure the agent, by creating the config file {configFile}', + values: { configFile: '`config/elastic_apm.yml`' }, + }), commands: `# config/elastic_apm.yml: # Set service name - allowed characters: a-z, A-Z, 0-9, -, _ and space @@ -145,22 +220,30 @@ export const RAILS_CLIENT_INSTRUCTIONS = [ # Set custom APM Server URL (default: http://localhost:8200) # server_url: 'http://localhost:8200'`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/ruby/1.x/index.html) for configuration options and advanced usage.\n\n', + textPost: i18n.translate('kbn.server.tutorials.apm.railsClient.configure.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for configuration options and advanced usage.\n\n', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/ruby/1.x/index.html' }, + }), }, ]; -export const RACK_CLIENT_INSTRUCTIONS = [ +export const createRackClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: 'Add the agent to your Gemfile.', + title: i18n.translate('kbn.server.tutorials.apm.rackClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.rackClient.install.textPre', { + defaultMessage: 'Add the agent to your Gemfile.', + }), commands: [`gem 'elastic-apm'`], }, { - title: 'Configure the agent', - textPre: - 'For Rack or a compatible framework (e.g. Sinatra), include the middleware in your app and start the agent.', + title: i18n.translate('kbn.server.tutorials.apm.rackClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.rackClient.configure.textPre', { + defaultMessage: 'For Rack or a compatible framework (e.g. Sinatra), include the middleware in your app and start the agent.', + }), commands: `# config.ru require 'sinatra/base' @@ -171,8 +254,12 @@ export const RACK_CLIENT_INSTRUCTIONS = [ end ElasticAPM.start( - app: MySinatraApp, # required - config_file: '' # optional, defaults to config/elastic_apm.yml + app: MySinatraApp, # ${i18n.translate('kbn.server.tutorials.apm.rackClient.configure.commands.requiredComment', { + defaultMessage: 'required', + })} + config_file: '' # ${i18n.translate('kbn.server.tutorials.apm.rackClient.configure.commands.optionalComment', { + defaultMessage: 'optional, defaults to config/elastic_apm.yml', + })} ) run MySinatraApp @@ -180,91 +267,146 @@ export const RACK_CLIENT_INSTRUCTIONS = [ at_exit {curlyOpen} ElasticAPM.stop {curlyClose}`.split('\n'), }, { - title: 'Create config file', - textPre: 'Create a config file `config/elastic_apm.yml`:', + title: i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.title', { + defaultMessage: 'Create config file', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.textPre', { + defaultMessage: 'Create a config file {configFile}:', + values: { configFile: '`config/elastic_apm.yml`' }, + }), commands: `# config/elastic_apm.yml: -# Set service name - allowed characters: a-z, A-Z, 0-9, -, _ and space -# Defaults to the name of your Rack app's class. +# ${i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.commands.setServiceNameComment', { + defaultMessage: 'Set service name - allowed characters: a-z, A-Z, 0-9, -, _ and space', + })} +# ${i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.commands.defaultsToTheNameOfRackAppClassComment', { + defaultMessage: 'Defaults to the name of your Rack app\'s class.', + })} # service_name: 'my-service' -# Use if APM Server requires a token +# ${i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.commands.useIfApmServerRequiresTokenComment', { + defaultMessage: 'Use if APM Server requires a token', + })} # secret_token: '' -# Set custom APM Server URL (default: http://localhost:8200) +# ${i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.commands.setCustomApmServerComment', { + defaultMessage: 'Set custom APM Server URL (default: {defaultServerUrl})', + values: { defaultServerUrl: 'http://localhost:8200' }, + })} # server_url: 'http://localhost:8200'`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/ruby/1.x/index.html) for configuration options and advanced usage.\n\n', + textPost: i18n.translate('kbn.server.tutorials.apm.rackClient.createConfig.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for configuration options and advanced usage.\n\n', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/ruby/1.x/index.html' }, + }), }, ]; -export const JS_CLIENT_INSTRUCTIONS = [ +export const createJsClientInstructions = () => [ { - title: 'Enable Real User Monitoring support in the APM server', - textPre: - 'Please refer to [the documentation]({config.docs.base_url}guide/en/apm/server/{config.docs.version}/rum.html).', + title: i18n.translate('kbn.server.tutorials.apm.jsClient.enableRealUserMonitoring.title', { + defaultMessage: 'Enable Real User Monitoring support in the APM server', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.jsClient.enableRealUserMonitoring.textPre', { + defaultMessage: 'Please refer to [the documentation]({documentationLink}).', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/server/{config.docs.version}/rum.html' }, + }), }, { - title: 'Install the APM agent', - textPre: - 'Install the APM agent for JavaScript as a dependency to your application:', + title: i18n.translate('kbn.server.tutorials.apm.jsClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.jsClient.install.textPre', { + defaultMessage: 'Install the APM agent for JavaScript as a dependency to your application:', + }), commands: [`npm install elastic-apm-js-base --save`], }, { - title: 'Configure the agent', - textPre: 'Agents are libraries that run inside of your application.', + title: i18n.translate('kbn.server.tutorials.apm.jsClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.jsClient.configure.textPre', { + defaultMessage: 'Agents are libraries that run inside of your application.', + }), commands: `import {curlyOpen} init as initApm {curlyClose} from 'elastic-apm-js-base' var apm = initApm({curlyOpen} - // Set required service name (allowed characters: a-z, A-Z, 0-9, -, _, and space) + // ${i18n.translate('kbn.server.tutorials.apm.jsClient.configure.commands.setRequiredServiceNameComment', { + defaultMessage: 'Set required service name (allowed characters: a-z, A-Z, 0-9, -, _, and space)', + })} serviceName: '', - // Set custom APM Server URL (default: http://localhost:8200) + // ${i18n.translate('kbn.server.tutorials.apm.jsClient.configure.commands.setCustomApmServerUrlComment', { + defaultMessage: 'Set custom APM Server URL (default: {defaultApmServerUrl})', + values: { defaultApmServerUrl: 'http://localhost:8200' }, + })} serverUrl: '', - // Set service version (required for sourcemap feature) + // ${i18n.translate('kbn.server.tutorials.apm.jsClient.configure.commands.setServiceVersionComment', { + defaultMessage: 'Set service version (required for sourcemap feature)', + })} serviceVersion: '' {curlyClose})`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/js-base/current/index.html) for advanced usage.', + textPost: i18n.translate('kbn.server.tutorials.apm.jsClient.configure.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for advanced usage.', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/js-base/current/index.html' }, + }), }, ]; -export const GO_CLIENT_INSTRUCTIONS = [ +export const createGoClientInstructions = () => [ { - title: 'Install the APM agent', - textPre: 'Install the APM agent packages for Go.', + title: i18n.translate('kbn.server.tutorials.apm.goClient.install.title', { + defaultMessage: 'Install the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.goClient.install.textPre', { + defaultMessage: 'Install the APM agent packages for Go.', + }), commands: ['go get github.com/elastic/apm-agent-go'], }, { - title: 'Configure the agent', - textPre: - 'Agents are libraries that run inside of your application process.' + - ' APM services are created programmatically based on the executable ' + - ' file name, or the `ELASTIC_APM_SERVICE_NAME` environment variable.', - commands: `# Initialize using environment variables: - -# Set the service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space. -# If ELASTIC_APM_SERVICE_NAME is not specified, the executable name will be used. + title: i18n.translate('kbn.server.tutorials.apm.goClient.configure.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.goClient.configure.textPre', { + defaultMessage: 'Agents are libraries that run inside of your application process. \ +APM services are created programmatically based on the executable \ +file name, or the `ELASTIC_APM_SERVICE_NAME` environment variable.', + }), + commands: `# ${i18n.translate('kbn.server.tutorials.apm.goClient.configure.commands.initializeUsingEnvironmentVariablesComment', { + defaultMessage: 'Initialize using environment variables:', + })} + +# ${i18n.translate('kbn.server.tutorials.apm.goClient.configure.commands.setServiceNameComment', { + defaultMessage: 'Set the service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space.', + })} +# ${i18n.translate('kbn.server.tutorials.apm.goClient.configure.commands.usedExecutableNameComment', { + defaultMessage: 'If ELASTIC_APM_SERVICE_NAME is not specified, the executable name will be used.', + })} export ELASTIC_APM_SERVICE_NAME= -# Set the APM Server URL. If unspecified, the agent will effectively be disabled. +# ${i18n.translate('kbn.server.tutorials.apm.goClient.configure.commands.setAmpServerUrlComment', { + defaultMessage: 'Set the APM Server URL. If unspecified, the agent will effectively be disabled.', + })} export ELASTIC_APM_SERVER_URL= -# Set if APM Server requires a token. +# ${i18n.translate('kbn.server.tutorials.apm.goClient.configure.commands.setIfAmpServerRequiresTokenComment', { + defaultMessage: 'Set if APM Server requires a token.', + })} export ELASTIC_APM_SECRET_TOKEN= `.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/go/current/configuration.html) for advanced configuration.', + textPost: i18n.translate('kbn.server.tutorials.apm.goClient.configure.textPost', { + defaultMessage: 'See the [documentation]({documenationLink}) for advanced configuration.', + values: { documenationLink: '{config.docs.base_url}guide/en/apm/agent/go/current/configuration.html' }, + }), }, { - title: 'Instrument your application', - textPre: - 'Instrument your Go application by using one of the provided instrumentation modules or ' + - 'by using the tracer API directly.', + title: i18n.translate('kbn.server.tutorials.apm.goClient.instrument.title', { + defaultMessage: 'Instrument your application', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.goClient.instrument.textPre', { + defaultMessage: 'Instrument your Go application by using one of the provided instrumentation modules or \ +by using the tracer API directly.', + }), commands: ` import ( "net/http" @@ -278,35 +420,46 @@ func main() {curlyOpen} http.ListenAndServe(":8080", apmhttp.Wrap(mux)) {curlyClose} `.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/go/current/instrumenting-source.html) for a detailed ' + - 'guide to instrumenting Go source code.\n\n' + - '**Warning: The Go agent is currently in Beta and not meant for production use.**', + textPost: i18n.translate('kbn.server.tutorials.apm.goClient.instrument.textPost', { + defaultMessage: 'See the [documentation]({documentationLink}) for a detailed \ +guide to instrumenting Go source code.\n\n\ +**Warning: The Go agent is currently in Beta and not meant for production use.**', + values: { documentationLink: '{config.docs.base_url}guide/en/apm/agent/go/current/instrumenting-source.html' }, + }), }, ]; -export const JAVA_CLIENT_INSTRUCTIONS = [ +export const createJavaClientInstructions = () => [ { - title: 'Download the APM agent', - textPre: 'Download the agent jar from [Maven Central](http://search.maven.org/#search%7Cga%7C1%7Ca%3Aelastic-apm-agent). ' + - 'Do **not** add the agent as a dependency to your application.' + title: i18n.translate('kbn.server.tutorials.apm.javaClient.download.title', { + defaultMessage: 'Download the APM agent', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.javaClient.download.textPre', { + defaultMessage: 'Download the agent jar from [Maven Central]({mavenCentralLink}). \ +Do **not** add the agent as a dependency to your application.', + values: { mavenCentralLink: 'http://search.maven.org/#search%7Cga%7C1%7Ca%3Aelastic-apm-agent' }, + }), }, { - title: 'Start your application with the javaagent flag', - textPre: 'Add the `-javaagent` flag and configure the agent with system properties.\n' + - '\n' + - ' * Set required service name (allowed characters: a-z, A-Z, 0-9, -, _, and space)\n' + - ' * Set custom APM Server URL (default: http://localhost:8200)\n' + - ' * Set the base package of your application', + title: i18n.translate('kbn.server.tutorials.apm.javaClient.startApplication.title', { + defaultMessage: 'Start your application with the javaagent flag', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.javaClient.startApplication.textPre', { + defaultMessage: 'Add the `-javaagent` flag and configure the agent with system properties.\n\n \ +* Set required service name (allowed characters: a-z, A-Z, 0-9, -, _, and space)\n \ +* Set custom APM Server URL (default: {customApmServerUrl})\n \ +* Set the base package of your application', + values: { customApmServerUrl: 'http://localhost:8200' }, + }), commands: `java -javaagent:/path/to/elastic-apm-agent-.jar \\ -Delastic.apm.service_name=my-application \\ - -Delastic.apm.server_url=http://localhost:8200 \\ - -Delastic.apm.application_packages=org.example \\ + -Delastic.apm.server_url=http://localhost:8200 \\ + -Delastic.apm.application_packages=org.example \\ -jar my-application.jar`.split('\n'), - textPost: - 'See the [documentation]' + - '({config.docs.base_url}guide/en/apm/agent/java/current/index.html) for configuration options and advanced usage.\n\n' + - '**Warning: The Java agent is currently in Beta and not meant for production use.**', + textPost: i18n.translate('kbn.server.tutorials.apm.javaClient.startApplication.textPost', { + defaultMessage: 'See the [documentation]({documenationLink}) for configuration options and advanced \ +usage.\n\n**Warning: The Java agent is currently in Beta and not meant for production use.**', + values: { documenationLink: '{config.docs.base_url}guide/en/apm/agent/java/current/index.html' }, + }), }, ]; diff --git a/src/core_plugins/kibana/server/tutorials/apm/apm_server_instructions.js b/src/core_plugins/kibana/server/tutorials/apm/apm_server_instructions.js index a85b6709713d0f..2826aca5194db9 100644 --- a/src/core_plugins/kibana/server/tutorials/apm/apm_server_instructions.js +++ b/src/core_plugins/kibana/server/tutorials/apm/apm_server_instructions.js @@ -17,84 +17,118 @@ * under the License. */ -export const EDIT_CONFIG = { - title: 'Edit the configuration', - textPre: - `If you're using an X-Pack secured version of Elastic Stack, you must specify` + - ' credentials in the `apm-server.yml` config file.', +import { i18n } from '@kbn/i18n'; + +export const createEditConfig = () => ({ + title: i18n.translate('kbn.server.tutorials.apm.editConfig.title', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.editConfig.textPre', { + defaultMessage: 'If you\'re using an X-Pack secured version of Elastic Stack, you must specify \ +credentials in the `apm-server.yml` config file.', + }), commands: [ 'output.elasticsearch:', ' hosts: [""]', ' username: ', ' password: ', ], -}; +}); + +const createStartServer = () => ({ + title: i18n.translate('kbn.server.tutorials.apm.startServer.title', { + defaultMessage: 'Start APM Server', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.startServer.textPre', { + defaultMessage: 'The server processes and stores application performance metrics in Elasticsearch.', + }), +}); -const START_SERVER = { - title: 'Start APM Server', - textPre: - 'The server processes and stores application performance metrics in Elasticsearch.', -}; +export function createStartServerUnix() { + const START_SERVER = createStartServer(); -export const START_SERVER_UNIX = { - title: START_SERVER.title, - textPre: START_SERVER.textPre, - commands: ['./apm-server -e'], -}; + return { + title: START_SERVER.title, + textPre: START_SERVER.textPre, + commands: ['./apm-server -e'], + }; +} -const DOWNLOAD_SERVER_TITLE = 'Download and unpack APM Server'; +const createDownloadServerTitle = () => i18n.translate('kbn.server.tutorials.apm.downloadServer.title', { + defaultMessage: 'Download and unpack APM Server', +}); -export const DOWNLOAD_SERVER_OSX = { - title: DOWNLOAD_SERVER_TITLE, +export const createDownloadServerOsx = () => ({ + title: createDownloadServerTitle(), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/apm-server/apm-server-{config.kibana.version}-darwin-x86_64.tar.gz', 'tar xzvf apm-server-{config.kibana.version}-darwin-x86_64.tar.gz', 'cd apm-server-{config.kibana.version}-darwin-x86_64/', ], -}; +}); -export const DOWNLOAD_SERVER_DEB = { - title: DOWNLOAD_SERVER_TITLE, +export const createDownloadServerDeb = () => ({ + title: createDownloadServerTitle(), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/apm-server/apm-server-{config.kibana.version}-amd64.deb', 'sudo dpkg -i apm-server-{config.kibana.version}-amd64.deb', ], - textPost: - 'Looking for the 32-bit packages? See the [Download page]({config.docs.base_url}downloads/apm/apm-server).', -}; + textPost: i18n.translate('kbn.server.tutorials.apm.downloadServerTitle', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({downloadPageLink}).', + values: { downloadPageLink: '{config.docs.base_url}downloads/apm/apm-server' }, + }), +}); -export const DOWNLOAD_SERVER_RPM = { - title: DOWNLOAD_SERVER_TITLE, +export const createDownloadServerRpm = () => ({ + title: createDownloadServerTitle(), commands: [ 'curl -L -O https://artifacts.elastic.co/downloads/apm-server/apm-server-{config.kibana.version}-x86_64.rpm', 'sudo rpm -vi apm-server-{config.kibana.version}-x86_64.rpm', ], - textPost: - 'Looking for the 32-bit packages? See the [Download page]({config.docs.base_url}downloads/apm/apm-server).', -}; + textPost: i18n.translate('kbn.server.tutorials.apm.downloadServerRpm', { + defaultMessage: 'Looking for the 32-bit packages? See the [Download page]({downloadPageLink}).', + values: { downloadPageLink: '{config.docs.base_url}downloads/apm/apm-server' }, + }), +}); -export const WINDOWS_SERVER_INSTRUCTIONS = [ - { - title: DOWNLOAD_SERVER_TITLE, - textPre: - '1. Download the APM Server Windows zip file from the [Download page](https://www.elastic.co/downloads/apm/apm-server).\n' + - '2. Extract the contents of the zip file into `C:\\Program Files`.\n' + - '3. Rename the `apm-server-{config.kibana.version}-windows` directory to `APM-Server`.\n' + - '4. Open a PowerShell prompt as an Administrator (right-click the PowerShell icon and select' + - ' **Run As Administrator**). If you are running Windows XP, you might need to download and install PowerShell.\n' + - '5. From the PowerShell prompt, run the following commands to install APM Server as a Windows service:', - commands: [ - `PS > cd 'C:\\Program Files\\APM-Server'`, - `PS C:\\Program Files\\APM-Server> .\\install-service-apm-server.ps1`, - ], - textPost: - 'Note: If script execution is disabled on your system, you need to set the execution policy for the current session' + - ' to allow the script to run. For example: `PowerShell.exe -ExecutionPolicy UnRestricted -File .\\install-service-apm-server.ps1`.', - }, - EDIT_CONFIG, - { - title: START_SERVER.title, - textPre: START_SERVER.textPre, - commands: ['apm-server.exe -e'], - }, -]; +export function createWindowsServerInstructions() { + const START_SERVER = createStartServer(); + + return [ + { + title: createDownloadServerTitle(), + textPre: i18n.translate('kbn.server.tutorials.apm.windowsServerInstructions.textPre', { + defaultMessage: '1. Download the APM Server Windows zip file from the \ +[Download page]({downloadPageLink}).\n2. Extract the contents of \ +the zip file into {zipFileExtractFolder}.\n3. Rename the {apmServerDirectory} \ +directory to `APM-Server`.\n4. Open a PowerShell prompt as an Administrator \ +(right-click the PowerShell icon and select \ +**Run As Administrator**). If you are running Windows XP, you might need to download and install \ +PowerShell.\n5. From the PowerShell prompt, run the following commands to install APM Server as a Windows service:', + values: { + downloadPageLink: 'https://www.elastic.co/downloads/apm/apm-server', + zipFileExtractFolder: '`C:\\Program Files`', + apmServerDirectory: '`apm-server-{config.kibana.version}-windows`', + } + }), + commands: [ + `PS > cd 'C:\\Program Files\\APM-Server'`, + `PS C:\\Program Files\\APM-Server> .\\install-service-apm-server.ps1`, + ], + textPost: i18n.translate('kbn.server.tutorials.apm.windowsServerInstructions.textPost', { + defaultMessage: 'Note: If script execution is disabled on your system, \ +you need to set the execution policy for the current session \ +to allow the script to run. For example: {command}.', + values: { + command: '`PowerShell.exe -ExecutionPolicy UnRestricted -File .\\install-service-apm-server.ps1`' + } + }), + }, + createEditConfig(), + { + title: START_SERVER.title, + textPre: START_SERVER.textPre, + commands: ['apm-server.exe -e'], + }, + ]; +} diff --git a/src/core_plugins/kibana/server/tutorials/apm/elastic_cloud.js b/src/core_plugins/kibana/server/tutorials/apm/elastic_cloud.js index a7bf9fa012fd6c..0a18d06d3fc819 100644 --- a/src/core_plugins/kibana/server/tutorials/apm/elastic_cloud.js +++ b/src/core_plugins/kibana/server/tutorials/apm/elastic_cloud.js @@ -17,64 +17,74 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { INSTRUCTION_VARIANT } from '../../../common/tutorials/instruction_variant'; import { - NODE_CLIENT_INSTRUCTIONS, - DJANGO_CLIENT_INSTRUCTIONS, - FLASK_CLIENT_INSTRUCTIONS, - RAILS_CLIENT_INSTRUCTIONS, - RACK_CLIENT_INSTRUCTIONS, - JS_CLIENT_INSTRUCTIONS, - GO_CLIENT_INSTRUCTIONS, - JAVA_CLIENT_INSTRUCTIONS, + createNodeClientInstructions, + createDjangoClientInstructions, + createFlaskClientInstructions, + createRailsClientInstructions, + createRackClientInstructions, + createJsClientInstructions, + createGoClientInstructions, + createJavaClientInstructions, } from './apm_client_instructions'; -const SERVER_URL_INSTRUCTION = { - title: 'APM Server endpoint', - textPre: - `Retrieve the APM Server URL from the Deployments section on the Elastic Cloud dashboard. - You will also need the APM Server secret token, which was generated on deployment.`, -}; +const createServerUrlInstruction = () => ({ + title: i18n.translate('kbn.server.tutorials.apm.serverUrlInstruction.title', { + defaultMessage: 'APM Server endpoint', + }), + textPre: i18n.translate('kbn.server.tutorials.apm.serverUrlInstruction.textPre', { + defaultMessage: 'Retrieve the APM Server URL from the Deployments section on the Elastic Cloud dashboard. \ +You will also need the APM Server secret token, which was generated on deployment.', + }), +}); -export const ELASTIC_CLOUD_INSTRUCTIONS = { - instructionSets: [ - { - title: 'APM Agents', - instructionVariants: [ - { - id: INSTRUCTION_VARIANT.NODE, - instructions: [SERVER_URL_INSTRUCTION, ...NODE_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.DJANGO, - instructions: [SERVER_URL_INSTRUCTION, ...DJANGO_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.FLASK, - instructions: [SERVER_URL_INSTRUCTION, ...FLASK_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.RAILS, - instructions: [SERVER_URL_INSTRUCTION, ...RAILS_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.RACK, - instructions: [SERVER_URL_INSTRUCTION, ...RACK_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.JS, - instructions: [SERVER_URL_INSTRUCTION, ...JS_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.GO, - instructions: [SERVER_URL_INSTRUCTION, ...GO_CLIENT_INSTRUCTIONS], - }, - { - id: INSTRUCTION_VARIANT.JAVA, - instructions: [SERVER_URL_INSTRUCTION, ...JAVA_CLIENT_INSTRUCTIONS], - }, - ], - }, - ], -}; +export function createElasticCloudInstructions() { + const SERVER_URL_INSTRUCTION = createServerUrlInstruction(); + + return { + instructionSets: [ + { + title: i18n.translate('kbn.server.tutorials.apm.elasticCloudInstructions.title', { + defaultMessage: 'APM Agents', + }), + instructionVariants: [ + { + id: INSTRUCTION_VARIANT.NODE, + instructions: [SERVER_URL_INSTRUCTION, ...createNodeClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.DJANGO, + instructions: [SERVER_URL_INSTRUCTION, ...createDjangoClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.FLASK, + instructions: [SERVER_URL_INSTRUCTION, ...createFlaskClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.RAILS, + instructions: [SERVER_URL_INSTRUCTION, ...createRailsClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.RACK, + instructions: [SERVER_URL_INSTRUCTION, ...createRackClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.JS, + instructions: [SERVER_URL_INSTRUCTION, ...createJsClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.GO, + instructions: [SERVER_URL_INSTRUCTION, ...createGoClientInstructions()], + }, + { + id: INSTRUCTION_VARIANT.JAVA, + instructions: [SERVER_URL_INSTRUCTION, ...createJavaClientInstructions()], + }, + ], + }, + ], + }; +} diff --git a/src/core_plugins/kibana/server/tutorials/apm/index.js b/src/core_plugins/kibana/server/tutorials/apm/index.js index 107b1ad8d1da8d..512805a4231dd0 100644 --- a/src/core_plugins/kibana/server/tutorials/apm/index.js +++ b/src/core_plugins/kibana/server/tutorials/apm/index.js @@ -17,12 +17,15 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions } from './on_prem'; -import { ELASTIC_CLOUD_INSTRUCTIONS } from './elastic_cloud'; +import { createElasticCloudInstructions } from './elastic_cloud'; import { getSavedObjects } from './saved_objects/get_saved_objects'; -const apmIntro = 'Collect in-depth performance metrics and errors from inside your applications.'; +const apmIntro = i18n.translate('kbn.server.tutorials.apm.introduction', { + defaultMessage: 'Collect in-depth performance metrics and errors from inside your applications.', +}); function isEnabled(config) { const ENABLED_KEY = 'xpack.apm.ui.enabled'; @@ -41,34 +44,46 @@ export function apmSpecProvider(server) { dashboards: [ { id: '8d3ed660-7828-11e7-8c47-65b845b5cfb3', - linkLabel: 'APM dashboard', - isOverview: true - } - ] + linkLabel: i18n.translate('kbn.server.tutorials.apm.specProvider.artifacts.dashboards.linkLabel', { + defaultMessage: 'APM dashboard', + }), + isOverview: true, + }, + ], }; + if (isEnabled(config)) { artifacts.application = { path: '/app/apm', - label: 'Launch APM' + label: i18n.translate('kbn.server.tutorials.apm.specProvider.artifacts.application.label', { + defaultMessage: 'Launch APM', + }), }; } return { id: 'apm', - name: 'APM', + name: i18n.translate('kbn.server.tutorials.apm.specProvider.name', { + defaultMessage: 'APM', + }), category: TUTORIAL_CATEGORY.OTHER, shortDescription: apmIntro, - longDescription: 'Application Performance Monitoring (APM) collects in-depth' + - ' performance metrics and errors from inside your application.' + - ' It allows you to monitor the performance of thousands of applications in real time.' + - ' [Learn more]({config.docs.base_url}guide/en/apm/get-started/{config.docs.version}/index.html).', + longDescription: i18n.translate('kbn.server.tutorials.apm.specProvider.longDescription', { + defaultMessage: 'Application Performance Monitoring (APM) collects in-depth \ +performance metrics and errors from inside your application. \ +It allows you to monitor the performance of thousands of applications in real time. \ +[Learn more]({learnMoreLink}).', + values: { learnMoreLink: '{config.docs.base_url}guide/en/apm/get-started/{config.docs.version}/index.html' }, + }), euiIconType: 'apmApp', artifacts: artifacts, onPrem: onPremInstructions(apmIndexPattern), - elasticCloud: ELASTIC_CLOUD_INSTRUCTIONS, + elasticCloud: createElasticCloudInstructions(), previewImagePath: '/plugins/kibana/home/tutorial_resources/apm/apm.png', savedObjects: getSavedObjects(apmIndexPattern), - savedObjectsInstallMsg: 'Load index pattern, visualizations, and pre-defined dashboards.' + - ' An index pattern is required for some features in the APM UI.', + savedObjectsInstallMsg: i18n.translate('kbn.server.tutorials.apm.specProvider.savedObjectsInstallMsg', { + defaultMessage: 'Load index pattern, visualizations, and pre-defined dashboards. \ +An index pattern is required for some features in the APM UI.', + }), }; } diff --git a/src/core_plugins/kibana/server/tutorials/apm/on_prem.js b/src/core_plugins/kibana/server/tutorials/apm/on_prem.js index 3fcca371fbe0ed..a75ce27384b21c 100644 --- a/src/core_plugins/kibana/server/tutorials/apm/on_prem.js +++ b/src/core_plugins/kibana/server/tutorials/apm/on_prem.js @@ -17,69 +17,71 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { INSTRUCTION_VARIANT } from '../../../common/tutorials/instruction_variant'; import { - WINDOWS_SERVER_INSTRUCTIONS, - EDIT_CONFIG, - START_SERVER_UNIX, - DOWNLOAD_SERVER_RPM, - DOWNLOAD_SERVER_DEB, - DOWNLOAD_SERVER_OSX, + createWindowsServerInstructions, + createEditConfig, + createStartServerUnix, + createDownloadServerRpm, + createDownloadServerDeb, + createDownloadServerOsx, } from './apm_server_instructions'; import { - NODE_CLIENT_INSTRUCTIONS, - DJANGO_CLIENT_INSTRUCTIONS, - FLASK_CLIENT_INSTRUCTIONS, - RAILS_CLIENT_INSTRUCTIONS, - RACK_CLIENT_INSTRUCTIONS, - JS_CLIENT_INSTRUCTIONS, - GO_CLIENT_INSTRUCTIONS, - JAVA_CLIENT_INSTRUCTIONS, + createNodeClientInstructions, + createDjangoClientInstructions, + createFlaskClientInstructions, + createRailsClientInstructions, + createRackClientInstructions, + createJsClientInstructions, + createGoClientInstructions, + createJavaClientInstructions, } from './apm_client_instructions'; export function onPremInstructions(apmIndexPattern) { + const EDIT_CONFIG = createEditConfig(); + const START_SERVER_UNIX = createStartServerUnix(); return { instructionSets: [ { - title: 'APM Server', + title: i18n.translate('kbn.server.tutorials.apm.apmServer.title', { + defaultMessage: 'APM Server', + }), instructionVariants: [ { id: INSTRUCTION_VARIANT.OSX, - instructions: [ - DOWNLOAD_SERVER_OSX, - EDIT_CONFIG, - START_SERVER_UNIX, - ], + instructions: [createDownloadServerOsx(), EDIT_CONFIG, START_SERVER_UNIX], }, { id: INSTRUCTION_VARIANT.DEB, - instructions: [ - DOWNLOAD_SERVER_DEB, - EDIT_CONFIG, - START_SERVER_UNIX, - ], + instructions: [createDownloadServerDeb(), EDIT_CONFIG, START_SERVER_UNIX], }, { id: INSTRUCTION_VARIANT.RPM, - instructions: [ - DOWNLOAD_SERVER_RPM, - EDIT_CONFIG, - START_SERVER_UNIX, - ], + instructions: [createDownloadServerRpm(), EDIT_CONFIG, START_SERVER_UNIX], }, { id: INSTRUCTION_VARIANT.WINDOWS, - instructions: WINDOWS_SERVER_INSTRUCTIONS, + instructions: createWindowsServerInstructions(), }, ], statusCheck: { - title: 'APM Server status', - text: - 'Make sure APM Server is running before you start implementing the APM agents.', - btnLabel: 'Check APM Server status', - success: 'You have correctly setup APM-Server', - error: 'APM-Server has still not connected to Elasticsearch', + title: i18n.translate('kbn.server.tutorials.apm.apmServer.statusCheck.title', { + defaultMessage: 'APM Server status', + }), + text: i18n.translate('kbn.server.tutorials.apm.apmServer.statusCheck.text', { + defaultMessage: 'Make sure APM Server is running before you start implementing the APM agents.', + }), + btnLabel: i18n.translate('kbn.server.tutorials.apm.apmServer.statusCheck.btnLabel', { + defaultMessage: 'Check APM Server status', + }), + success: i18n.translate('kbn.server.tutorials.apm.apmServer.statusCheck.successMessage', { + defaultMessage: 'You have correctly setup APM-Server', + }), + error: i18n.translate('kbn.server.tutorials.apm.apmServer.statusCheck.errorMessage', { + defaultMessage: 'APM-Server has still not connected to Elasticsearch', + }), esHitsCheck: { index: apmIndexPattern, query: { @@ -95,48 +97,59 @@ export function onPremInstructions(apmIndexPattern) { }, }, { - title: 'APM Agents', + title: i18n.translate('kbn.server.tutorials.apm.apmAgents.title', { + defaultMessage: 'APM Agents', + }), instructionVariants: [ { id: INSTRUCTION_VARIANT.NODE, - instructions: NODE_CLIENT_INSTRUCTIONS, + instructions: createNodeClientInstructions(), }, { id: INSTRUCTION_VARIANT.DJANGO, - instructions: DJANGO_CLIENT_INSTRUCTIONS, + instructions: createDjangoClientInstructions(), }, { id: INSTRUCTION_VARIANT.FLASK, - instructions: FLASK_CLIENT_INSTRUCTIONS, + instructions: createFlaskClientInstructions(), }, { id: INSTRUCTION_VARIANT.RAILS, - instructions: RAILS_CLIENT_INSTRUCTIONS, + instructions: createRailsClientInstructions(), }, { id: INSTRUCTION_VARIANT.RACK, - instructions: RACK_CLIENT_INSTRUCTIONS, + instructions: createRackClientInstructions(), }, { id: INSTRUCTION_VARIANT.JS, - instructions: JS_CLIENT_INSTRUCTIONS, + instructions: createJsClientInstructions(), }, { id: INSTRUCTION_VARIANT.GO, - instructions: GO_CLIENT_INSTRUCTIONS, + instructions: createGoClientInstructions(), }, { id: INSTRUCTION_VARIANT.JAVA, - instructions: JAVA_CLIENT_INSTRUCTIONS, + instructions: createJavaClientInstructions(), }, ], statusCheck: { - title: 'Agent status', - text: - 'Make sure your application is running and the agents are sending data.', - btnLabel: 'Check agent status', - success: 'Data successfully received from one or more agents', - error: `No data has been received from agents yet`, + title: i18n.translate('kbn.server.tutorials.apm.apmAgents.statusCheck.title', { + defaultMessage: 'Agent status', + }), + text: i18n.translate('kbn.server.tutorials.apm.apmAgents.statusCheck.text', { + defaultMessage: 'Make sure your application is running and the agents are sending data.', + }), + btnLabel: i18n.translate('kbn.server.tutorials.apm.apmAgents.statusCheck.btnLabel', { + defaultMessage: 'Check agent status', + }), + success: i18n.translate('kbn.server.tutorials.apm.apmAgents.statusCheck.successMessage', { + defaultMessage: 'Data successfully received from one or more agents', + }), + error: i18n.translate('kbn.server.tutorials.apm.apmAgents.statusCheck.errorMessage', { + defaultMessage: 'No data has been received from agents yet', + }), esHitsCheck: { index: apmIndexPattern, query: { diff --git a/src/core_plugins/kibana/server/tutorials/ceph_metrics/index.js b/src/core_plugins/kibana/server/tutorials/ceph_metrics/index.js index 65668fd8d21445..aebcc62fc3211c 100644 --- a/src/core_plugins/kibana/server/tutorials/ceph_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/ceph_metrics/index.js @@ -34,7 +34,6 @@ export function cephMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from the Ceph server.', }), longDescription: i18n.translate('kbn.server.tutorials.cephMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `ceph` Metricbeat module fetches internal metrics from Ceph. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/couchbase_metrics/index.js b/src/core_plugins/kibana/server/tutorials/couchbase_metrics/index.js index e8d52498eb31c6..c9cc9610e08604 100644 --- a/src/core_plugins/kibana/server/tutorials/couchbase_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/couchbase_metrics/index.js @@ -34,7 +34,6 @@ export function couchbaseMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from Couchbase.', }), longDescription: i18n.translate('kbn.server.tutorials.couchbaseMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `couchbase` Metricbeat module fetches internal metrics from Couchbase. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/docker_metrics/index.js b/src/core_plugins/kibana/server/tutorials/docker_metrics/index.js index 3d727874215015..6429390a48e554 100644 --- a/src/core_plugins/kibana/server/tutorials/docker_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/docker_metrics/index.js @@ -33,7 +33,6 @@ export function dockerMetricsSpecProvider() { defaultMessage: 'Fetch metrics about your Docker containers.', }), longDescription: i18n.translate('kbn.server.tutorials.dockerMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `docker` Metricbeat module fetches metrics from the Docker server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/dropwizard_metrics/index.js b/src/core_plugins/kibana/server/tutorials/dropwizard_metrics/index.js index 96c4daf3255f06..2222d9468808d0 100644 --- a/src/core_plugins/kibana/server/tutorials/dropwizard_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/dropwizard_metrics/index.js @@ -34,7 +34,6 @@ export function dropwizardMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from Dropwizard Java application.', }), longDescription: i18n.translate('kbn.server.tutorials.dropwizardMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `dropwizard` Metricbeat module fetches internal metrics from Dropwizard Java Application. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/elasticsearch_logs/index.js b/src/core_plugins/kibana/server/tutorials/elasticsearch_logs/index.js index c073968ce291d6..4dfcdbfbde5d94 100644 --- a/src/core_plugins/kibana/server/tutorials/elasticsearch_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/elasticsearch_logs/index.js @@ -37,7 +37,6 @@ export function elasticsearchLogsSpecProvider() { defaultMessage: 'Collect and parse logs created by Elasticsearch.', }), longDescription: i18n.translate('kbn.server.tutorials.elasticsearchLogs.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `elasticsearch` Filebeat module parses logs created by Elasticsearch. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/elasticsearch_metrics/index.js b/src/core_plugins/kibana/server/tutorials/elasticsearch_metrics/index.js index ef592a0a3839e6..abbbe3f1429c9b 100644 --- a/src/core_plugins/kibana/server/tutorials/elasticsearch_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/elasticsearch_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,16 +25,27 @@ export function elasticsearchMetricsSpecProvider() { const moduleName = 'elasticsearch'; return { id: 'elasticsearchMetrics', - name: 'Elasticsearch metrics', + name: i18n.translate('kbn.server.tutorials.elasticsearchMetrics.nameTitle', { + defaultMessage: 'Elasticsearch metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from Elasticsearch.', - longDescription: 'The `elasticsearch` Metricbeat module fetches internal metrics from Elasticsearch.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-elasticsearch.html).', + shortDescription: i18n.translate('kbn.server.tutorials.elasticsearchMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from Elasticsearch.', + }), + longDescription: i18n.translate('kbn.server.tutorials.elasticsearchMetrics.longDescription', { + defaultMessage: 'The `elasticsearch` Metricbeat module fetches internal metrics from Elasticsearch. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-elasticsearch.html', + }, + }), euiIconType: 'logoElasticsearch', artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.elasticsearchMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/etcd_metrics/index.js b/src/core_plugins/kibana/server/tutorials/etcd_metrics/index.js index de6505d1ee1b23..2b704f57ab2081 100644 --- a/src/core_plugins/kibana/server/tutorials/etcd_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/etcd_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,26 @@ export function etcdMetricsSpecProvider() { const moduleName = 'etcd'; return { id: 'etcdMetrics', - name: 'Etcd metrics', + name: i18n.translate('kbn.server.tutorials.etcdMetrics.nameTitle', { + defaultMessage: 'Etcd metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the Etcd server.', - longDescription: 'The `etcd` Metricbeat module fetches internal metrics from Etcd.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-etcd.html).', + shortDescription: i18n.translate('kbn.server.tutorials.etcdMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the Etcd server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.etcdMetrics.longDescription', { + defaultMessage: 'The `etcd` Metricbeat module fetches internal metrics from Etcd. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-etcd.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.etcdMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/golang_metrics/index.js b/src/core_plugins/kibana/server/tutorials/golang_metrics/index.js index e81b246c5074e8..2714de14f494dd 100644 --- a/src/core_plugins/kibana/server/tutorials/golang_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/golang_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,17 +25,29 @@ export function golangMetricsSpecProvider() { const moduleName = 'golang'; return { id: moduleName + 'Metrics', - name: 'Golang metrics', + name: i18n.translate('kbn.server.tutorials.golangMetrics.nameTitle', { + defaultMessage: 'Golang metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from a Golang app.', - longDescription: 'The `' + moduleName + '` Metricbeat module fetches internal metrics from a Golang app.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-' + moduleName + '.html).', + shortDescription: i18n.translate('kbn.server.tutorials.golangMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from a Golang app.', + }), + longDescription: i18n.translate('kbn.server.tutorials.golangMetrics.longDescription', { + defaultMessage: 'The `{moduleName}` Metricbeat module fetches internal metrics from a Golang app. \ +[Learn more]({learnMoreLink}).', + values: { + moduleName, + learnMoreLink: `{config.docs.beats.metricbeat}/metricbeat-module-${moduleName}.html`, + }, + }), artifacts: { dashboards: [ { id: 'f2dc7320-f519-11e6-a3c9-9d1f7c42b045', - linkLabel: 'Golang metrics dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.golangMetrics.artifacts.dashboards.linkLabel', { + defaultMessage: 'Golang metrics dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/haproxy_metrics/index.js b/src/core_plugins/kibana/server/tutorials/haproxy_metrics/index.js index 704f7da17a4fe8..f2d119b75e9165 100644 --- a/src/core_plugins/kibana/server/tutorials/haproxy_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/haproxy_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,26 @@ export function haproxyMetricsSpecProvider() { const moduleName = 'haproxy'; return { id: 'haproxyMetrics', - name: 'HAProxy metrics', + name: i18n.translate('kbn.server.tutorials.haproxyMetrics.nameTitle', { + defaultMessage: 'HAProxy metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the HAProxy server.', - longDescription: 'The `haproxy` Metricbeat module fetches internal metrics from HAProxy.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-haproxy.html).', + shortDescription: i18n.translate('kbn.server.tutorials.haproxyMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the HAProxy server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.haproxyMetrics.longDescription', { + defaultMessage: 'The `haproxy` Metricbeat module fetches internal metrics from HAProxy. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-haproxy.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.haproxyMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/iis_logs/index.js b/src/core_plugins/kibana/server/tutorials/iis_logs/index.js index 9cb12280566a9e..54ee34caa1765a 100644 --- a/src/core_plugins/kibana/server/tutorials/iis_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/iis_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function iisLogsSpecProvider() { const platforms = ['WINDOWS']; return { id: 'iisLogs', - name: 'IIS logs', + name: i18n.translate('kbn.server.tutorials.iisLogs.nameTitle', { + defaultMessage: 'IIS logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse access and error logs created by the IIS HTTP server.', - longDescription: 'The `iis` Filebeat module parses access and error logs created by the IIS HTTP server.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-iis.html).', + shortDescription: i18n.translate('kbn.server.tutorials.iisLogs.shortDescription', { + defaultMessage: 'Collect and parse access and error logs created by the IIS HTTP server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.iisLogs.longDescription', { + defaultMessage: 'The `iis` Filebeat module parses access and error logs created by the IIS HTTP server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-iis.html', + }, + }), //euiIconType: 'logoIIS', artifacts: { dashboards: [ { id: '4278ad30-fe16-11e7-a3b0-d13028918f9f', - linkLabel: 'IIS logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.iisLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'IIS logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/kafka_logs/index.js b/src/core_plugins/kibana/server/tutorials/kafka_logs/index.js index e69536abecfbfa..4ac90a5e85e4af 100644 --- a/src/core_plugins/kibana/server/tutorials/kafka_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/kafka_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function kafkaLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'kafkaLogs', - name: 'Kafka logs', + name: i18n.translate('kbn.server.tutorials.kafkaLogs.nameTitle', { + defaultMessage: 'Kafka logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse logs created by Kafka.', - longDescription: 'The `kafka` Filebeat module parses logs created by Kafka.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-kafka.html).', + shortDescription: i18n.translate('kbn.server.tutorials.kafkaLogs.shortDescription', { + defaultMessage: 'Collect and parse logs created by Kafka.', + }), + longDescription: i18n.translate('kbn.server.tutorials.kafkaLogs.longDescription', { + defaultMessage: 'The `kafka` Filebeat module parses logs created by Kafka. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-kafka.html', + }, + }), //euiIconType: 'logoKafka', artifacts: { dashboards: [ { id: '943caca0-87ee-11e7-ad9c-db80de0bf8d3', - linkLabel: 'Kafka logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.kafkaLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'Kafka logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/kafka_metrics/index.js b/src/core_plugins/kibana/server/tutorials/kafka_metrics/index.js index c27aa8e58ccfeb..c5f0403a937732 100644 --- a/src/core_plugins/kibana/server/tutorials/kafka_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/kafka_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,26 @@ export function kafkaMetricsSpecProvider() { const moduleName = 'kafka'; return { id: 'kafkaMetrics', - name: 'Kafka metrics', + name: i18n.translate('kbn.server.tutorials.kafkaMetrics.nameTitle', { + defaultMessage: 'Kafka metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the Kafka server.', - longDescription: 'The `kafka` Metricbeat module fetches internal metrics from Kafka.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-kafka.html).', + shortDescription: i18n.translate('kbn.server.tutorials.kafkaMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the Kafka server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.kafkaMetrics.longDescription', { + defaultMessage: 'The `kafka` Metricbeat module fetches internal metrics from Kafka. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-kafka.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.kafkaMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/kibana_metrics/index.js b/src/core_plugins/kibana/server/tutorials/kibana_metrics/index.js index a2f44d7d0cd5f0..5e2a734372f1c5 100644 --- a/src/core_plugins/kibana/server/tutorials/kibana_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/kibana_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,16 +25,27 @@ export function kibanaMetricsSpecProvider() { const moduleName = 'kibana'; return { id: 'kibanaMetrics', - name: 'Kibana metrics', + name: i18n.translate('kbn.server.tutorials.kibanaMetrics.nameTitle', { + defaultMessage: 'Kibana metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from Kibana.', - longDescription: 'The `kibana` Metricbeat module fetches internal metrics from Kibana.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-kibana.html).', + shortDescription: i18n.translate('kbn.server.tutorials.kibanaMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from Kibana.', + }), + longDescription: i18n.translate('kbn.server.tutorials.kibanaMetrics.longDescription', { + defaultMessage: 'The `kibana` Metricbeat module fetches internal metrics from Kibana. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-kibana.html', + }, + }), euiIconType: 'logoKibana', artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.kibanaMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/kubernetes_metrics/index.js b/src/core_plugins/kibana/server/tutorials/kubernetes_metrics/index.js index e4ab0d7fc24da2..c4b483be10ec72 100644 --- a/src/core_plugins/kibana/server/tutorials/kubernetes_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/kubernetes_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,17 +25,28 @@ export function kubernetesMetricsSpecProvider() { const moduleName = 'kubernetes'; return { id: 'kubernetesMetrics', - name: 'Kubernetes metrics', + name: i18n.translate('kbn.server.tutorials.kubernetesMetrics.nameTitle', { + defaultMessage: 'Kubernetes metrics', + }), category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch metrics from your Kubernetes installation.', - longDescription: 'The `kubernetes` Metricbeat module fetches metrics from the Kubernetes APIs.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-kubernetes.html).', + shortDescription: i18n.translate('kbn.server.tutorials.kubernetesMetrics.shortDescription', { + defaultMessage: 'Fetch metrics from your Kubernetes installation.', + }), + longDescription: i18n.translate('kbn.server.tutorials.kubernetesMetrics.longDescription', { + defaultMessage: 'The `kubernetes` Metricbeat module fetches metrics from the Kubernetes APIs. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-kubernetes.html', + }, + }), euiIconType: 'logoKubernetes', artifacts: { dashboards: [ { id: 'AV4RGUqo5NkDleZmzKuZ', - linkLabel: 'Kubernetes metrics dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.kubernetesMetrics.artifacts.dashboards.linkLabel', { + defaultMessage: 'Kubernetes metrics dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/logstash_logs/index.js b/src/core_plugins/kibana/server/tutorials/logstash_logs/index.js index 8d3458d575f8d5..ca11ee94700845 100644 --- a/src/core_plugins/kibana/server/tutorials/logstash_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/logstash_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function logstashLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'logstashLogs', - name: 'Logstash logs', + name: i18n.translate('kbn.server.tutorials.logstashLogs.nameTitle', { + defaultMessage: 'Logstash logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse debug and slow logs created by Logstash itself.', - longDescription: 'The `logstash` Filebeat module parses debug and slow logs created by Logstash itself.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-logstash.html).', + shortDescription: i18n.translate('kbn.server.tutorials.logstashLogs.shortDescription', { + defaultMessage: 'Collect and parse debug and slow logs created by Logstash itself.', + }), + longDescription: i18n.translate('kbn.server.tutorials.logstashLogs.longDescription', { + defaultMessage: 'The `logstash` Filebeat module parses debug and slow logs created by Logstash itself. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-logstash.html', + }, + }), euiIconType: 'logoLogstash', artifacts: { dashboards: [ { id: 'Filebeat-Logstash-Log-Dashboard', - linkLabel: 'Logstash logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.logstashLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'Logstash logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/logstash_metrics/index.js b/src/core_plugins/kibana/server/tutorials/logstash_metrics/index.js index 90196d24ae9d05..8689cad2fc966c 100644 --- a/src/core_plugins/kibana/server/tutorials/logstash_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/logstash_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,16 +25,28 @@ export function logstashMetricsSpecProvider() { const moduleName = 'logstash'; return { id: moduleName + 'Metrics', - name: 'Logstash metrics', + name: i18n.translate('kbn.server.tutorials.logstashMetrics.nameTitle', { + defaultMessage: 'Logstash metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch interal metrics from a Logstash server.', - longDescription: 'The `' + moduleName + '` Metricbeat module fetches internal metrics from a Logstash server.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-' + moduleName + '.html).', + shortDescription: i18n.translate('kbn.server.tutorials.logstashMetrics.shortDescription', { + defaultMessage: 'Fetch interal metrics from a Logstash server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.logstashMetrics.longDescription', { + defaultMessage: 'The `{moduleName}` Metricbeat module fetches internal metrics from a Logstash server. \ +[Learn more]({learnMoreLink}).', + values: { + moduleName, + learnMoreLink: `{config.docs.beats.metricbeat}/metricbeat-module-${moduleName}.html`, + }, + }), euiIconType: 'logoLogstash', artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.logstashMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/memcached_metrics/index.js b/src/core_plugins/kibana/server/tutorials/memcached_metrics/index.js index 54937ca25891f6..28021ffebdbf74 100644 --- a/src/core_plugins/kibana/server/tutorials/memcached_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/memcached_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,26 @@ export function memcachedMetricsSpecProvider() { const moduleName = 'memcached'; return { id: 'memcachedMetrics', - name: 'Memcached metrics', + name: i18n.translate('kbn.server.tutorials.memcachedMetrics.nameTitle', { + defaultMessage: 'Memcached metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the Memcached server.', - longDescription: 'The `memcached` Metricbeat module fetches internal metrics from Memcached.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-memcached.html).', + shortDescription: i18n.translate('kbn.server.tutorials.memcachedMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the Memcached server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.memcachedMetrics.longDescription', { + defaultMessage: 'The `memcached` Metricbeat module fetches internal metrics from Memcached. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-memcached.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.memcachedMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/mongodb_metrics/index.js b/src/core_plugins/kibana/server/tutorials/mongodb_metrics/index.js index 6f188926ff2e66..e3db5dd55a4f28 100644 --- a/src/core_plugins/kibana/server/tutorials/mongodb_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/mongodb_metrics/index.js @@ -33,7 +33,6 @@ export function mongodbMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from MongoDB.', }), longDescription: i18n.translate('kbn.server.tutorials.mongodbMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `mongodb` Metricbeat module fetches internal metrics from the MongoDB server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/munin_metrics/index.js b/src/core_plugins/kibana/server/tutorials/munin_metrics/index.js index 0bc8699d79945d..ab21dc2b03bd42 100644 --- a/src/core_plugins/kibana/server/tutorials/munin_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/munin_metrics/index.js @@ -34,7 +34,6 @@ export function muninMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from the Munin server.', }), longDescription: i18n.translate('kbn.server.tutorials.muninMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `munin` Metricbeat module fetches internal metrics from Munin. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/mysql_logs/index.js b/src/core_plugins/kibana/server/tutorials/mysql_logs/index.js index 6caab605d93845..bddc0d9659f49f 100644 --- a/src/core_plugins/kibana/server/tutorials/mysql_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/mysql_logs/index.js @@ -36,7 +36,6 @@ export function mysqlLogsSpecProvider() { defaultMessage: 'Collect and parse error and slow logs created by MySQL.', }), longDescription: i18n.translate('kbn.server.tutorials.mysqlLogs.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `mysql` Filebeat module parses error and slow logs created by MySQL. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/mysql_metrics/index.js b/src/core_plugins/kibana/server/tutorials/mysql_metrics/index.js index f1897bbdf4d882..37bd452a7fad80 100644 --- a/src/core_plugins/kibana/server/tutorials/mysql_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/mysql_metrics/index.js @@ -33,7 +33,6 @@ export function mysqlMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from MySQL.', }), longDescription: i18n.translate('kbn.server.tutorials.mysqlMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `mysql` Metricbeat module fetches internal metrics from the MySQL server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/netflow/common_instructions.js b/src/core_plugins/kibana/server/tutorials/netflow/common_instructions.js index 2a7364c0529729..194e315d92ed7f 100644 --- a/src/core_plugins/kibana/server/tutorials/netflow/common_instructions.js +++ b/src/core_plugins/kibana/server/tutorials/netflow/common_instructions.js @@ -17,129 +17,280 @@ * under the License. */ -export const COMMON_NETFLOW_INSTRUCTIONS = { - CONFIG: { - ON_PREM: { - OSX: [ - { - title: 'Edit the configuration', - textPre: 'Modify `config/logstash.yml` to set the configuration parameters:', - commands: [ - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data.' +import { i18n } from '@kbn/i18n'; - } - ], - WINDOWS: [ - { - title: 'Edit the configuration', - textPre: 'Modify `config\\logstash.yml` to set the configuration parameters:', - commands: [ - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data.' - } - ] +export function createCommonNetflowInstructions() { + return { + CONFIG: { + ON_PREM: { + OSX: [ + { + title: i18n.translate('kbn.server.tutorials.netflow.common.config.onPrem.osxTitle', { + defaultMessage: 'Edit the configuration', + }), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPrem.osxTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config/logstash.yml`', + }, + } + ), + commands: ['modules:', ' - name: netflow', ' var.input.udp.port: '], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPrem.osxTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data.', + values: { + udpPort: '``', + }, + } + ), + }, + ], + WINDOWS: [ + { + title: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPrem.windowsTitle', + { + defaultMessage: 'Edit the configuration', + } + ), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPrem.windowsTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config\\logstash.yml`', + }, + } + ), + commands: ['modules:', ' - name: netflow', ' var.input.udp.port: '], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPrem.windowsTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data.', + values: { + udpPort: '``', + }, + } + ), + }, + ], + }, + ON_PREM_ELASTIC_CLOUD: { + OSX: [ + { + title: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.osxTitle', + { + defaultMessage: 'Edit the configuration', + } + ), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.osxTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config/logstash.yml`', + }, + } + ), + commands: [ + 'modules:', + ' - name: netflow', + ' var.input.udp.port: ', + ' var.elasticsearch.hosts: [ "" ]', + ' var.elasticsearch.username: elastic', + ' var.elasticsearch.password: ', + ], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.osxTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data, \ + {esUrl} is the URL of Elasticsearch running on Elastic Cloud, and \ + {password} is the password of the {elastic} user.', + values: { + elastic: '`elastic`', + esUrl: '``', + password: '``', + udpPort: '``', + }, + } + ), + }, + ], + WINDOWS: [ + { + title: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.windowsTitle', + { + defaultMessage: 'Edit the configuration', + } + ), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.windowsTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config\\logstash.yml`', + }, + } + ), + commands: [ + 'modules:', + ' - name: netflow', + ' var.input.udp.port: ', + ' var.elasticsearch.hosts: [ "" ]', + ' var.elasticsearch.username: elastic', + ' var.elasticsearch.password: ', + ], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.onPremElasticCloud.windowsTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data, \ + {esUrl} is the URL of Elasticsearch running on Elastic Cloud, and \ + {password} is the password of the {elastic} user.', + values: { + elastic: '`elastic`', + esUrl: '``', + password: '``', + udpPort: '``', + }, + } + ), + }, + ], + }, + ELASTIC_CLOUD: { + OSX: [ + { + title: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.osxTitle', + { + defaultMessage: 'Edit the configuration', + } + ), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.osxTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config/logstash.yml`', + }, + } + ), + commands: [ + 'cloud.id: "{config.cloud.id}"', + 'cloud.auth: "elastic:"', + ' ', + 'modules:', + ' - name: netflow', + ' var.input.udp.port: ', + ], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.osxTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data and \ + {password} is the password of the {elastic} user.', + values: { + elastic: '`elastic`', + password: '``', + udpPort: '``', + }, + } + ), + }, + ], + WINDOWS: [ + { + title: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.windowsTitle', + { + defaultMessage: 'Edit the configuration', + } + ), + textPre: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.windowsTextPre', + { + defaultMessage: 'Modify {logstashConfigPath} to set the configuration parameters:', + values: { + logstashConfigPath: '`config\\logstash.yml`', + }, + } + ), + commands: [ + 'cloud.id: "{config.cloud.id}"', + 'cloud.auth: "elastic:"', + ' ', + 'modules:', + ' - name: netflow', + ' var.input.udp.port: ', + ], + textPost: i18n.translate( + 'kbn.server.tutorials.netflow.common.config.elasticCloud.windowsTextPost', + { + defaultMessage: + 'Where {udpPort} is the UDP port on which Logstash will receive Netflow data and \ + {password} is the password of the {elastic} user.', + values: { + elastic: '`elastic`', + password: '``', + udpPort: '``', + }, + } + ), + }, + ], + }, }, - ON_PREM_ELASTIC_CLOUD: { + SETUP: { OSX: [ { - title: 'Edit the configuration', - textPre: 'Modify `config/logstash.yml` to set the configuration parameters:', - commands: [ - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ' var.elasticsearch.hosts: [ "" ]', - ' var.elasticsearch.username: elastic', - ' var.elasticsearch.password: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data, ' - + '`` is the URL of Elasticsearch running on Elastic Cloud, and ' - + '`` is the password of the `elastic` user.' - } + title: i18n.translate('kbn.server.tutorials.netflow.common.setup.osxTitle', { + defaultMessage: 'Run the Netflow module', + }), + textPre: i18n.translate('kbn.server.tutorials.netflow.common.setup.osxTextPre', { + defaultMessage: 'Run:', + }), + commands: ['./bin/logstash --modules netflow --setup'], + textPost: i18n.translate('kbn.server.tutorials.netflow.common.setup.osxTextPost', { + defaultMessage: + 'The {setupOption} option creates a {netflowPrefix} index pattern in Elasticsearch and imports \ + Kibana dashboards and visualizations. Omit this option for subsequent runs to avoid overwriting existing dashboards.', + values: { + setupOption: '`--setup`', + netflowPrefix: '`netflow-*`', + }, + }), + }, ], WINDOWS: [ { - title: 'Edit the configuration', - textPre: 'Modify `config\\logstash.yml` to set the configuration parameters:', - commands: [ - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ' var.elasticsearch.hosts: [ "" ]', - ' var.elasticsearch.username: elastic', - ' var.elasticsearch.password: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data, ' - + '`` is the URL of Elasticsearch running on Elastic Cloud, and ' - + '`` is the password of the `elastic` user.' - - } - ] - }, - ELASTIC_CLOUD: { - OSX: [ - { - title: 'Edit the configuration', - textPre: 'Modify `config/logstash.yml` to set the configuration parameters:', - commands: [ - 'cloud.id: "{config.cloud.id}"', - 'cloud.auth: "elastic:"', - ' ', - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data and ' - + '`` is the password of the `elastic` user.' - } + title: i18n.translate('kbn.server.tutorials.netflow.common.setup.windowsTitle', { + defaultMessage: 'Run the Netflow module', + }), + textPre: i18n.translate('kbn.server.tutorials.netflow.common.setup.windowsTextPre', { + defaultMessage: 'Run:', + }), + commands: ['bin\\logstash --modules netflow --setup'], + textPost: i18n.translate('kbn.server.tutorials.netflow.common.setup.windowsTextPost', { + defaultMessage: + 'The {setupOption} option creates a {netflowPrefix} index pattern in Elasticsearch and imports \ + Kibana dashboards and visualizations. Omit this option for subsequent runs to avoid overwriting existing dashboards.', + values: { + setupOption: '`--setup`', + netflowPrefix: '`netflow-*`', + }, + }), + }, ], - WINDOWS: [ - { - title: 'Edit the configuration', - textPre: 'Modify `config\\logstash.yml` to set the configuration parameters:', - commands: [ - 'cloud.id: "{config.cloud.id}"', - 'cloud.auth: "elastic:"', - ' ', - 'modules:', - ' - name: netflow', - ' var.input.udp.port: ', - ], - textPost: 'Where `` is the UDP port on which Logstash will receive Netflow data and ' - + '`` is the password of the `elastic` user.' - } - ] - } - }, - SETUP: { - OSX: [ - { - title: 'Run the Netflow module', - textPre: 'Run:', - commands: [ - './bin/logstash --modules netflow --setup', - ], - textPost: 'The `--setup` option creates a `netflow-*` index pattern in Elasticsearch and imports' + - ' Kibana dashboards and visualizations. Omit this option for subsequent runs to avoid overwriting existing dashboards.' - } - ], - WINDOWS: [ - { - title: 'Run the Netflow module', - textPre: 'Run:', - commands: [ - 'bin\\logstash --modules netflow --setup', - ], - textPost: 'The `--setup` option creates a `netflow-*` index pattern in Elasticsearch and imports' + - ' Kibana dashboards and visualizations. Omit this option for subsequent runs to avoid overwriting existing dashboards.' - } - ] - } -}; + }, + }; +} diff --git a/src/core_plugins/kibana/server/tutorials/netflow/elastic_cloud.js b/src/core_plugins/kibana/server/tutorials/netflow/elastic_cloud.js index 21e396bb9db280..6ba76c2b5fa9c2 100644 --- a/src/core_plugins/kibana/server/tutorials/netflow/elastic_cloud.js +++ b/src/core_plugins/kibana/server/tutorials/netflow/elastic_cloud.js @@ -17,33 +17,42 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + import { INSTRUCTION_VARIANT } from '../../../common/tutorials/instruction_variant'; -import { LOGSTASH_INSTRUCTIONS } from '../../../common/tutorials/logstash_instructions'; -import { COMMON_NETFLOW_INSTRUCTIONS } from './common_instructions'; +import { createLogstashInstructions } from '../../../common/tutorials/logstash_instructions'; +import { createCommonNetflowInstructions } from './common_instructions'; // TODO: compare with onPremElasticCloud and onPrem scenarios and extract out common bits -export const ELASTIC_CLOUD_INSTRUCTIONS = { - instructionSets: [ - { - title: 'Getting Started', - instructionVariants: [ - { - id: INSTRUCTION_VARIANT.OSX, - instructions: [ - ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ELASTIC_CLOUD.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX - ] - }, - { - id: INSTRUCTION_VARIANT.WINDOWS, - instructions: [ - ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ELASTIC_CLOUD.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS - ] - } - ] - } - ] -}; +export function createElasticCloudInstructions() { + const COMMON_NETFLOW_INSTRUCTIONS = createCommonNetflowInstructions(); + const LOGSTASH_INSTRUCTIONS = createLogstashInstructions(); + + return { + instructionSets: [ + { + title: i18n.translate('kbn.server.tutorials.netflow.elasticCloudInstructions.title', { + defaultMessage: 'Getting Started', + }), + instructionVariants: [ + { + id: INSTRUCTION_VARIANT.OSX, + instructions: [ + ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ELASTIC_CLOUD.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX, + ], + }, + { + id: INSTRUCTION_VARIANT.WINDOWS, + instructions: [ + ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ELASTIC_CLOUD.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS, + ], + }, + ], + }, + ], + }; +} diff --git a/src/core_plugins/kibana/server/tutorials/netflow/index.js b/src/core_plugins/kibana/server/tutorials/netflow/index.js index e634282acc5650..058e0b5e09152d 100644 --- a/src/core_plugins/kibana/server/tutorials/netflow/index.js +++ b/src/core_plugins/kibana/server/tutorials/netflow/index.js @@ -17,25 +17,34 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; -import { ON_PREM_INSTRUCTIONS } from './on_prem'; -import { ELASTIC_CLOUD_INSTRUCTIONS } from './elastic_cloud'; -import { ON_PREM_ELASTIC_CLOUD_INSTRUCTIONS } from './on_prem_elastic_cloud'; +import { createOnPremInstructions } from './on_prem'; +import { createElasticCloudInstructions } from './elastic_cloud'; +import { createOnPremElasticCloudInstructions } from './on_prem_elastic_cloud'; export function netflowSpecProvider() { return { id: 'netflow', name: 'Netflow', category: TUTORIAL_CATEGORY.SECURITY, - shortDescription: 'Collect Netflow records sent by a Netflow exporter.', - longDescription: 'The Logstash Netflow module collects and parses network flow data, ' + - ' indexes the events into Elasticsearch, and installs a suite of Kibana dashboards.' + - ' This module support Netflow Version 5 and 9.' + - ' [Learn more]({config.docs.logstash}/netflow-module.html).', + shortDescription: i18n.translate('kbn.server.tutorials.netflow.tutorialShortDescription', { + defaultMessage: 'Collect Netflow records sent by a Netflow exporter.', + }), + longDescription: i18n.translate('kbn.server.tutorials.netflow.tutorialLongDescription', { + defaultMessage: + 'The Logstash Netflow module collects and parses network flow data, \ +indexes the events into Elasticsearch, and installs a suite of Kibana dashboards. \ +This module support Netflow Version 5 and 9. [Learn more]({linkUrl}).', + values: { + linkUrl: '{config.docs.logstash}/netflow-module.html', + }, + }), completionTimeMinutes: 10, //previewImagePath: 'kibana-apache.png', TODO - onPrem: ON_PREM_INSTRUCTIONS, - elasticCloud: ELASTIC_CLOUD_INSTRUCTIONS, - onPremElasticCloud: ON_PREM_ELASTIC_CLOUD_INSTRUCTIONS + onPrem: createOnPremInstructions(), + elasticCloud: createElasticCloudInstructions(), + onPremElasticCloud: createOnPremElasticCloudInstructions(), }; } diff --git a/src/core_plugins/kibana/server/tutorials/netflow/on_prem.js b/src/core_plugins/kibana/server/tutorials/netflow/on_prem.js index 5b0ecbbaac2c70..cc060611361bdc 100644 --- a/src/core_plugins/kibana/server/tutorials/netflow/on_prem.js +++ b/src/core_plugins/kibana/server/tutorials/netflow/on_prem.js @@ -17,33 +17,42 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + import { INSTRUCTION_VARIANT } from '../../../common/tutorials/instruction_variant'; -import { LOGSTASH_INSTRUCTIONS } from '../../../common/tutorials/logstash_instructions'; -import { COMMON_NETFLOW_INSTRUCTIONS } from './common_instructions'; +import { createLogstashInstructions } from '../../../common/tutorials/logstash_instructions'; +import { createCommonNetflowInstructions } from './common_instructions'; // TODO: compare with onPremElasticCloud and elasticCloud scenarios and extract out common bits -export const ON_PREM_INSTRUCTIONS = { - instructionSets: [ - { - title: 'Getting Started', - instructionVariants: [ - { - id: INSTRUCTION_VARIANT.OSX, - instructions: [ - ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX - ] - }, - { - id: INSTRUCTION_VARIANT.WINDOWS, - instructions: [ - ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS - ] - } - ] - } - ] -}; +export function createOnPremInstructions() { + const COMMON_NETFLOW_INSTRUCTIONS = createCommonNetflowInstructions(); + const LOGSTASH_INSTRUCTIONS = createLogstashInstructions(); + + return { + instructionSets: [ + { + title: i18n.translate('kbn.server.tutorials.netflow.onPremInstructions.title', { + defaultMessage: 'Getting Started', + }), + instructionVariants: [ + { + id: INSTRUCTION_VARIANT.OSX, + instructions: [ + ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX, + ], + }, + { + id: INSTRUCTION_VARIANT.WINDOWS, + instructions: [ + ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS, + ], + }, + ], + }, + ], + }; +} diff --git a/src/core_plugins/kibana/server/tutorials/netflow/on_prem_elastic_cloud.js b/src/core_plugins/kibana/server/tutorials/netflow/on_prem_elastic_cloud.js index 0e5cb74064f804..49674621b70be8 100644 --- a/src/core_plugins/kibana/server/tutorials/netflow/on_prem_elastic_cloud.js +++ b/src/core_plugins/kibana/server/tutorials/netflow/on_prem_elastic_cloud.js @@ -17,41 +17,52 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + import { INSTRUCTION_VARIANT } from '../../../common/tutorials/instruction_variant'; -import { LOGSTASH_INSTRUCTIONS } from '../../../common/tutorials/logstash_instructions'; +import { createLogstashInstructions } from '../../../common/tutorials/logstash_instructions'; import { - TRYCLOUD_OPTION1, - TRYCLOUD_OPTION2 + createTrycloudOption1, + createTrycloudOption2, } from '../../../common/tutorials/onprem_cloud_instructions'; -import { COMMON_NETFLOW_INSTRUCTIONS } from './common_instructions'; +import { createCommonNetflowInstructions } from './common_instructions'; // TODO: compare with onPrem and elasticCloud scenarios and extract out common bits -export const ON_PREM_ELASTIC_CLOUD_INSTRUCTIONS = { - instructionSets: [ - { - title: 'Getting Started', - instructionVariants: [ - { - id: INSTRUCTION_VARIANT.OSX, - instructions: [ - TRYCLOUD_OPTION1, - TRYCLOUD_OPTION2, - ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM_ELASTIC_CLOUD.OSX, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX - ] - }, - { - id: INSTRUCTION_VARIANT.WINDOWS, - instructions: [ - TRYCLOUD_OPTION1, - TRYCLOUD_OPTION2, - ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM_ELASTIC_CLOUD.WINDOWS, - ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS - ] - } - ] - } - ] -}; +export function createOnPremElasticCloudInstructions() { + const COMMON_NETFLOW_INSTRUCTIONS = createCommonNetflowInstructions(); + const TRYCLOUD_OPTION1 = createTrycloudOption1(); + const TRYCLOUD_OPTION2 = createTrycloudOption2(); + const LOGSTASH_INSTRUCTIONS = createLogstashInstructions(); + + return { + instructionSets: [ + { + title: i18n.translate('kbn.server.tutorials.netflow.onPremElasticCloudInstructions.title', { + defaultMessage: 'Getting Started', + }), + instructionVariants: [ + { + id: INSTRUCTION_VARIANT.OSX, + instructions: [ + TRYCLOUD_OPTION1, + TRYCLOUD_OPTION2, + ...LOGSTASH_INSTRUCTIONS.INSTALL.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM_ELASTIC_CLOUD.OSX, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.OSX, + ], + }, + { + id: INSTRUCTION_VARIANT.WINDOWS, + instructions: [ + TRYCLOUD_OPTION1, + TRYCLOUD_OPTION2, + ...LOGSTASH_INSTRUCTIONS.INSTALL.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.CONFIG.ON_PREM_ELASTIC_CLOUD.WINDOWS, + ...COMMON_NETFLOW_INSTRUCTIONS.SETUP.WINDOWS, + ], + }, + ], + }, + ], + }; +} diff --git a/src/core_plugins/kibana/server/tutorials/nginx_logs/index.js b/src/core_plugins/kibana/server/tutorials/nginx_logs/index.js index 82bd676d49a28d..d540a3f9f2daaf 100644 --- a/src/core_plugins/kibana/server/tutorials/nginx_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/nginx_logs/index.js @@ -36,7 +36,6 @@ export function nginxLogsSpecProvider() { defaultMessage: 'Collect and parse access and error logs created by the Nginx HTTP server.', }), longDescription: i18n.translate('kbn.server.tutorials.nginxLogs.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `nginx` Filebeat module parses access and error logs created by the Nginx HTTP server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/nginx_metrics/index.js b/src/core_plugins/kibana/server/tutorials/nginx_metrics/index.js index c521f138cfc354..38c54bf8ae2d62 100644 --- a/src/core_plugins/kibana/server/tutorials/nginx_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/nginx_metrics/index.js @@ -33,7 +33,6 @@ export function nginxMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from the Nginx HTTP server.', }), longDescription: i18n.translate('kbn.server.tutorials.nginxMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `nginx` Metricbeat module fetches internal metrics from the Nginx HTTP server. \ The module scrapes the server status data from the web page generated by the \ {statusModuleLink}, \ diff --git a/src/core_plugins/kibana/server/tutorials/osquery_logs/index.js b/src/core_plugins/kibana/server/tutorials/osquery_logs/index.js index a0e1a84e5722e5..fab1fb78285ac6 100644 --- a/src/core_plugins/kibana/server/tutorials/osquery_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/osquery_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function osqueryLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'osqueryLogs', - name: 'Osquery logs', + name: i18n.translate('kbn.server.tutorials.osqueryLogs.nameTitle', { + defaultMessage: 'Osquery logs', + }), category: TUTORIAL_CATEGORY.SECURITY, - shortDescription: 'Collect the result logs created by osqueryd.', - longDescription: 'The `osquery` Filebeat module collects the JSON result logs collected by `osqueryd`.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-osquery.html).', + shortDescription: i18n.translate('kbn.server.tutorials.osqueryLogs.shortDescription', { + defaultMessage: 'Collect the result logs created by osqueryd.', + }), + longDescription: i18n.translate('kbn.server.tutorials.osqueryLogs.longDescription', { + defaultMessage: 'The `osquery` Filebeat module collects the JSON result logs collected by `osqueryd`. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-osquery.html', + }, + }), //euiIconType: 'logoOsquery', artifacts: { dashboards: [ { id: '69f5ae20-eb02-11e7-8f04-51231daa5b05', - linkLabel: 'Osquery logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.osqueryLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'Osquery logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/php_fpm_metrics/index.js b/src/core_plugins/kibana/server/tutorials/php_fpm_metrics/index.js index bf0d27740ecc73..f024bcb62f852d 100644 --- a/src/core_plugins/kibana/server/tutorials/php_fpm_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/php_fpm_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,12 +25,21 @@ export function phpfpmMetricsSpecProvider() { const moduleName = 'php_fpm'; return { id: 'phpfpmMetrics', - name: 'PHP-FPM metrics', + name: i18n.translate('kbn.server.tutorials.phpFpmMetrics.nameTitle', { + defaultMessage: 'PHP-FPM metrics', + }), category: TUTORIAL_CATEGORY.METRICS, isBeta: true, - shortDescription: 'Fetch internal metrics from PHP-FPM.', - longDescription: 'The `php_fpm` Metricbeat module fetches internal metrics from the PHP-FPM server.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-php_fpm.html).', + shortDescription: i18n.translate('kbn.server.tutorials.phpFpmMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from PHP-FPM.', + }), + longDescription: i18n.translate('kbn.server.tutorials.phpFpmMetrics.longDescription', { + defaultMessage: 'The `php_fpm` Metricbeat module fetches internal metrics from the PHP-FPM server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-php_fpm.html', + }, + }), //euiIconType: 'logoPHPFPM', artifacts: { dashboards: [ diff --git a/src/core_plugins/kibana/server/tutorials/postgresql_logs/index.js b/src/core_plugins/kibana/server/tutorials/postgresql_logs/index.js index fa17700fc9b347..ff4c09d8ba2f0a 100644 --- a/src/core_plugins/kibana/server/tutorials/postgresql_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/postgresql_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,17 +28,28 @@ export function postgresqlLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'postgresqlLogs', - name: 'PostgreSQL logs', + name: i18n.translate('kbn.server.tutorials.postgresqlLogs.nameTitle', { + defaultMessage: 'PostgreSQL logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse error and slow logs created by PostgreSQL.', - longDescription: 'The `postgresql` Filebeat module parses error and slow logs created by PostgreSQL.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-postgresql.html).', + shortDescription: i18n.translate('kbn.server.tutorials.postgresqlLogs.shortDescription', { + defaultMessage: 'Collect and parse error and slow logs created by PostgreSQL.', + }), + longDescription: i18n.translate('kbn.server.tutorials.postgresqlLogs.longDescription', { + defaultMessage: 'The `postgresql` Filebeat module parses error and slow logs created by PostgreSQL. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-postgresql.html', + }, + }), //euiIconType: 'logoPostgreSQL', artifacts: { dashboards: [ { id: '158be870-87f4-11e7-ad9c-db80de0bf8d3', - linkLabel: 'PostgreSQL logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.postgresqlLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'PostgreSQL logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/postgresql_metrics/index.js b/src/core_plugins/kibana/server/tutorials/postgresql_metrics/index.js index be827e6c4891c8..c646700c3d011b 100644 --- a/src/core_plugins/kibana/server/tutorials/postgresql_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/postgresql_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,12 +25,21 @@ export function postgresqlMetricsSpecProvider() { const moduleName = 'postgresql'; return { id: 'postgresqlMetrics', - name: 'PostgreSQL metrics', + name: i18n.translate('kbn.server.tutorials.postgresqlMetrics.nameTitle', { + defaultMessage: 'PostgreSQL metrics', + }), category: TUTORIAL_CATEGORY.METRICS, isBeta: true, - shortDescription: 'Fetch internal metrics from PostgreSQL.', - longDescription: 'The `postgresql` Metricbeat module fetches internal metrics from the PostgreSQL server.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-postgresql.html).', + shortDescription: i18n.translate('kbn.server.tutorials.postgresqlMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from PostgreSQL.', + }), + longDescription: i18n.translate('kbn.server.tutorials.postgresqlMetrics.longDescription', { + defaultMessage: 'The `postgresql` Metricbeat module fetches internal metrics from the PostgreSQL server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-postgresql.html', + }, + }), //euiIconType: 'logoPostgreSQL', artifacts: { dashboards: [ diff --git a/src/core_plugins/kibana/server/tutorials/prometheus_metrics/index.js b/src/core_plugins/kibana/server/tutorials/prometheus_metrics/index.js index 2da1fa47053b13..5bfad2d708706b 100644 --- a/src/core_plugins/kibana/server/tutorials/prometheus_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/prometheus_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,15 +25,27 @@ export function prometheusMetricsSpecProvider() { const moduleName = 'prometheus'; return { id: moduleName + 'Metrics', - name: 'Prometheus metrics', + name: i18n.translate('kbn.server.tutorials.prometheusMetrics.nameTitle', { + defaultMessage: 'Prometheus metrics', + }), isBeta: true, category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch metrics from a Prometheus exporter.', - longDescription: 'The `' + moduleName + '` Metricbeat module fetches metrics from Prometheus endpoint.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-' + moduleName + '.html).', + shortDescription: i18n.translate('kbn.server.tutorials.prometheusMetrics.shortDescription', { + defaultMessage: 'Fetch metrics from a Prometheus exporter.', + }), + longDescription: i18n.translate('kbn.server.tutorials.prometheusMetrics.longDescription', { + defaultMessage: 'The `{moduleName}` Metricbeat module fetches metrics from Prometheus endpoint. \ +[Learn more]({learnMoreLink}).', + values: { + moduleName, + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-' + moduleName + '.html', + }, + }), artifacts: { application: { - label: 'Discover', + label: i18n.translate('kbn.server.tutorials.prometheusMetrics.artifacts.application.label', { + defaultMessage: 'Discover', + }), path: '/app/kibana#/discover' }, dashboards: [], diff --git a/src/core_plugins/kibana/server/tutorials/rabbitmq_metrics/index.js b/src/core_plugins/kibana/server/tutorials/rabbitmq_metrics/index.js index 52abb5facda571..0b343d3dd4abc9 100644 --- a/src/core_plugins/kibana/server/tutorials/rabbitmq_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/rabbitmq_metrics/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/metricbeat_instructions'; @@ -24,18 +25,29 @@ export function rabbitmqMetricsSpecProvider() { const moduleName = 'rabbitmq'; return { id: 'rabbitmqMetrics', - name: 'RabbitMQ metrics', + name: i18n.translate('kbn.server.tutorials.rabbitmqMetrics.nameTitle', { + defaultMessage: 'RabbitMQ metrics', + }), category: TUTORIAL_CATEGORY.METRICS, - shortDescription: 'Fetch internal metrics from the RabbitMQ server.', - longDescription: 'The `rabbitmq` Metricbeat module fetches internal metrics from the RabbitMQ server.' + - ' [Learn more]({config.docs.beats.metricbeat}/metricbeat-module-rabbitmq.html).', + shortDescription: i18n.translate('kbn.server.tutorials.rabbitmqMetrics.shortDescription', { + defaultMessage: 'Fetch internal metrics from the RabbitMQ server.', + }), + longDescription: i18n.translate('kbn.server.tutorials.rabbitmqMetrics.longDescription', { + defaultMessage: 'The `rabbitmq` Metricbeat module fetches internal metrics from the RabbitMQ server. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.metricbeat}/metricbeat-module-rabbitmq.html', + }, + }), //euiIconType: 'logoRabbitMQ', isBeta: true, artifacts: { dashboards: [ { id: 'AV4YobKIge1VCbKU_qVo', - linkLabel: 'RabbitMQ metrics dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.rabbitmqMetrics.artifacts.dashboards.linkLabel', { + defaultMessage: 'RabbitMQ metrics dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/redis_logs/index.js b/src/core_plugins/kibana/server/tutorials/redis_logs/index.js index edd94e28a0e67b..397181820d0609 100644 --- a/src/core_plugins/kibana/server/tutorials/redis_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/redis_logs/index.js @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; import { onPremInstructions, cloudInstructions, onPremCloudInstructions } from '../../../common/tutorials/filebeat_instructions'; @@ -27,23 +28,34 @@ export function redisLogsSpecProvider() { const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; return { id: 'redisLogs', - name: 'Redis logs', + name: i18n.translate('kbn.server.tutorials.redisLogs.nameTitle', { + defaultMessage: 'Redis logs', + }), category: TUTORIAL_CATEGORY.LOGGING, - shortDescription: 'Collect and parse error and slow logs created by Redis.', - longDescription: 'The `redis` Filebeat module parses error and slow logs created by Redis.' + - ' For Redis to write error logs, make sure the `logfile` option, from the' + - ' Redis configuration file, is set to `redis-server.log`.' + - ' The slow logs are read directly from Redis via the `SLOWLOG` command.' + - ' For Redis to record slow logs, make sure the `slowlog-log-slower-than`' + - ' option is set.' + - ' Note that the `slowlog` fileset is experimental.' + - ' [Learn more]({config.docs.beats.filebeat}/filebeat-module-redis.html).', + shortDescription: i18n.translate('kbn.server.tutorials.redisLogs.shortDescription', { + defaultMessage: 'Collect and parse error and slow logs created by Redis.', + }), + longDescription: i18n.translate('kbn.server.tutorials.redisLogs.longDescription', { + defaultMessage: 'The `redis` Filebeat module parses error and slow logs created by Redis. \ +For Redis to write error logs, make sure the `logfile` option, from the \ +Redis configuration file, is set to `redis-server.log`. \ +The slow logs are read directly from Redis via the `SLOWLOG` command. \ +For Redis to record slow logs, make sure the `slowlog-log-slower-than` \ +option is set. \ +Note that the `slowlog` fileset is experimental. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-redis.html', + }, + }), euiIconType: 'logoRedis', artifacts: { dashboards: [ { id: '7fea2930-478e-11e7-b1f0-cb29bac6bf8b', - linkLabel: 'Redis logs dashboard', + linkLabel: i18n.translate('kbn.server.tutorials.redisLogs.artifacts.dashboards.linkLabel', { + defaultMessage: 'Redis logs dashboard', + }), isOverview: true } ], diff --git a/src/core_plugins/kibana/server/tutorials/redis_metrics/index.js b/src/core_plugins/kibana/server/tutorials/redis_metrics/index.js index 4db10f551bfe69..e1d451a17fb327 100644 --- a/src/core_plugins/kibana/server/tutorials/redis_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/redis_metrics/index.js @@ -33,7 +33,6 @@ export function redisMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from Redis.', }), longDescription: i18n.translate('kbn.server.tutorials.redisMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `redis` Metricbeat module fetches internal metrics from the Redis server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/system_logs/index.js b/src/core_plugins/kibana/server/tutorials/system_logs/index.js index 70629983ee8e16..74117526ac77c2 100644 --- a/src/core_plugins/kibana/server/tutorials/system_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/system_logs/index.js @@ -36,7 +36,6 @@ export function systemLogsSpecProvider() { defaultMessage: 'Collect and parse logs written by the local Syslog server.', }), longDescription: i18n.translate('kbn.server.tutorials.systemLogs.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `system` Filebeat module collects and parses logs created by the system logging service of common \ Unix/Linux based distributions. This module is not available on Windows. \ [Learn more]({learnMoreLink}).', diff --git a/src/core_plugins/kibana/server/tutorials/system_metrics/index.js b/src/core_plugins/kibana/server/tutorials/system_metrics/index.js index d16667ea3b66e1..61669025d2f1ff 100644 --- a/src/core_plugins/kibana/server/tutorials/system_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/system_metrics/index.js @@ -33,7 +33,6 @@ export function systemMetricsSpecProvider() { defaultMessage: 'Collect CPU, memory, network, and disk statistics from the host.', }), longDescription: i18n.translate('kbn.server.tutorials.systemMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `system` Metricbeat module collects CPU, memory, network, and disk statistics from the host. \ It collects system wide statistics and statistics per process and filesystem. \ [Learn more]({learnMoreLink}).', diff --git a/src/core_plugins/kibana/server/tutorials/traefik_logs/index.js b/src/core_plugins/kibana/server/tutorials/traefik_logs/index.js index 50a5dc714933fa..7c7cdafd4fa258 100644 --- a/src/core_plugins/kibana/server/tutorials/traefik_logs/index.js +++ b/src/core_plugins/kibana/server/tutorials/traefik_logs/index.js @@ -36,7 +36,6 @@ export function traefikLogsSpecProvider() { defaultMessage: 'Collect and parse access logs created by the Traefik Proxy.', }), longDescription: i18n.translate('kbn.server.tutorials.traefikLogs.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `traefik` Filebeat module parses access logs created by Traefik. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/uwsgi_metrics/index.js b/src/core_plugins/kibana/server/tutorials/uwsgi_metrics/index.js index cd799851fa76ec..3769c3fa6f014f 100644 --- a/src/core_plugins/kibana/server/tutorials/uwsgi_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/uwsgi_metrics/index.js @@ -33,7 +33,6 @@ export function uwsgiMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from the uWSGI server.', }), longDescription: i18n.translate('kbn.server.tutorials.uwsgiMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `uwsgi` Metricbeat module fetches internal metrics from the uWSGI server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/vsphere_metrics/index.js b/src/core_plugins/kibana/server/tutorials/vsphere_metrics/index.js index d92f7de737ed6a..7ef4e68768b22d 100644 --- a/src/core_plugins/kibana/server/tutorials/vsphere_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/vsphere_metrics/index.js @@ -33,7 +33,6 @@ export function vSphereMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from vSphere.', }), longDescription: i18n.translate('kbn.server.tutorials.vsphereMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `vsphere` Metricbeat module fetches internal metrics from a vSphere cluster. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/windows_metrics/index.js b/src/core_plugins/kibana/server/tutorials/windows_metrics/index.js index a7a1dd0c88c3da..16543ef745b921 100644 --- a/src/core_plugins/kibana/server/tutorials/windows_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/windows_metrics/index.js @@ -34,7 +34,6 @@ export function windowsMetricsSpecProvider() { defaultMessage: 'Fetch internal metrics from Windows.', }), longDescription: i18n.translate('kbn.server.tutorials.windowsMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `windows` Metricbeat module fetches internal metrics from Windows. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/server/tutorials/zookeeper_metrics/index.js b/src/core_plugins/kibana/server/tutorials/zookeeper_metrics/index.js index f86c56680454aa..1a5b78df196bf8 100644 --- a/src/core_plugins/kibana/server/tutorials/zookeeper_metrics/index.js +++ b/src/core_plugins/kibana/server/tutorials/zookeeper_metrics/index.js @@ -34,7 +34,6 @@ export function zookeeperMetricsSpecProvider() { defaultMessage: 'Fetch interal metrics from a Zookeeper server.', }), longDescription: i18n.translate('kbn.server.tutorials.zookeeperMetrics.longDescription', { - // eslint-disable-next-line no-multi-str defaultMessage: 'The `{moduleName}` Metricbeat module fetches internal metrics from a Zookeeper server. \ [Learn more]({learnMoreLink}).', values: { diff --git a/src/core_plugins/kibana/ui_setting_defaults.js b/src/core_plugins/kibana/ui_setting_defaults.js index acfb03db0e3927..8b2dcc2d2b4a6a 100644 --- a/src/core_plugins/kibana/ui_setting_defaults.js +++ b/src/core_plugins/kibana/ui_setting_defaults.js @@ -186,6 +186,15 @@ export function getUiSettingDefaults() { used when courier:setRequestPreference is set to "custom".`, category: ['search'], }, + 'courier:maxConcurrentShardRequests': { + name: 'Max Concurrent Shard Requests', + value: 0, + type: 'number', + description: `Controls the max_concurrent_shard_requests + setting used for _msearch requests sent by Kibana. Set to 0 to disable this config and use the Elasticsearch default.`, + category: ['search'], + }, 'fields:popularLimit': { name: 'Popular fields limit', value: 10, diff --git a/src/core_plugins/metric_vis/public/__tests__/metric_vis.js b/src/core_plugins/metric_vis/public/__tests__/metric_vis.js index 10b16f0652c5bd..5ec32b480ad3ff 100644 --- a/src/core_plugins/metric_vis/public/__tests__/metric_vis.js +++ b/src/core_plugins/metric_vis/public/__tests__/metric_vis.js @@ -25,7 +25,7 @@ import { VisProvider } from 'ui/vis'; import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; import MetricVisProvider from '../metric_vis'; -describe('metric_vis', () => { +describe('metric vis', () => { let setup = null; let vis; @@ -62,10 +62,8 @@ describe('metric_vis', () => { const ip = '235.195.237.208'; render({ - tables: [{ - columns: [{ title: 'ip', aggConfig: vis.aggs[0] }], - rows: [[ ip ]] - }] + columns: [{ id: 'col-0', title: 'ip', aggConfig: vis.aggs[0] }], + rows: [{ 'col-0': ip }] }); const $link = $(el) diff --git a/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js b/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js index 7ce5cb6ab18244..ec3aa063d939a1 100644 --- a/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js +++ b/src/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js @@ -20,7 +20,7 @@ import expect from 'expect.js'; import { MetricVisComponent } from '../metric_vis_controller'; -describe('metric vis', function () { +describe('metric vis controller', function () { const vis = { params: { @@ -53,10 +53,8 @@ describe('metric vis', function () { it('should set the metric label and value', function () { const metrics = metricController._processTableGroups({ - tables: [{ - columns: [{ title: 'Count', aggConfig: { ...aggConfig, makeLabel: () => 'Count' } }], - rows: [[ 4301021 ]] - }] + columns: [{ id: 'col-0', title: 'Count', aggConfig: { ...aggConfig, makeLabel: () => 'Count' } }], + rows: [{ 'col-0': 4301021 }] }); expect(metrics.length).to.be(1); @@ -66,13 +64,11 @@ describe('metric vis', function () { it('should support multi-value metrics', function () { const metrics = metricController._processTableGroups({ - tables: [{ - columns: [ - { aggConfig: { ...aggConfig, makeLabel: () => '1st percentile of bytes' } }, - { aggConfig: { ...aggConfig, makeLabel: () => '99th percentile of bytes' } } - ], - rows: [[ 182, 445842.4634666484 ]] - }] + columns: [ + { id: 'col-0', aggConfig: { ...aggConfig, makeLabel: () => '1st percentile of bytes' } }, + { id: 'col-1', aggConfig: { ...aggConfig, makeLabel: () => '99th percentile of bytes' } } + ], + rows: [{ 'col-0': 182, 'col-1': 445842.4634666484 }] }); expect(metrics.length).to.be(2); diff --git a/src/core_plugins/metric_vis/public/metric_vis.js b/src/core_plugins/metric_vis/public/metric_vis.js index 4d25bad420da26..c4cbd0c11716ef 100644 --- a/src/core_plugins/metric_vis/public/metric_vis.js +++ b/src/core_plugins/metric_vis/public/metric_vis.js @@ -100,6 +100,7 @@ function MetricVisProvider(Private) { } ]) }, + responseHandler: 'tabify', }); } diff --git a/src/core_plugins/metric_vis/public/metric_vis_controller.js b/src/core_plugins/metric_vis/public/metric_vis_controller.js index fa9adb7885ced3..abd6d63cc8ec4d 100644 --- a/src/core_plugins/metric_vis/public/metric_vis_controller.js +++ b/src/core_plugins/metric_vis/public/metric_vis_controller.js @@ -89,7 +89,7 @@ export class MetricVisComponent extends Component { return fieldFormatter(value); } - _processTableGroups(tableGroups) { + _processTableGroups(table) { const config = this.props.vis.params.metric; const isPercentageMode = config.percentageMode; const min = config.colorsRange[0].from; @@ -98,56 +98,55 @@ export class MetricVisComponent extends Component { const labels = this._getLabels(); const metrics = []; - tableGroups.tables.forEach((table, tableIndex) => { - let bucketAgg; - let rowHeaderIndex; + let bucketAgg; + let bucketColumnId; + let rowHeaderIndex; - table.columns.forEach((column, columnIndex) => { - const aggConfig = column.aggConfig; + table.columns.forEach((column, columnIndex) => { + const aggConfig = column.aggConfig; - if (aggConfig && aggConfig.type.type === 'buckets') { - bucketAgg = aggConfig; - // Store the current index, so we later know in which position in the - // row array, the bucket agg key will be, so we can create filters on it. - rowHeaderIndex = columnIndex; - return; - } + if (aggConfig && aggConfig.type.type === 'buckets') { + bucketAgg = aggConfig; + // Store the current index, so we later know in which position in the + // row array, the bucket agg key will be, so we can create filters on it. + rowHeaderIndex = columnIndex; + bucketColumnId = column.id; + return; + } - table.rows.forEach((row, rowIndex) => { + table.rows.forEach((row, rowIndex) => { - let title = column.title; - let value = row[columnIndex]; - const color = this._getColor(value, labels, colors); + let title = column.name; + let value = row[column.id]; + const color = this._getColor(value, labels, colors); - if (isPercentageMode) { - const percentage = Math.round(100 * (value - min) / (max - min)); - value = `${percentage}%`; - } + if (isPercentageMode) { + const percentage = Math.round(100 * (value - min) / (max - min)); + value = `${percentage}%`; + } - if (aggConfig) { - if (!isPercentageMode) value = this._getFormattedValue(aggConfig.fieldFormatter('html'), value); - if (bucketAgg) { - const bucketValue = bucketAgg.fieldFormatter('text')(row[0]); - title = `${bucketValue} - ${aggConfig.makeLabel()}`; - } else { - title = aggConfig.makeLabel(); - } + if (aggConfig) { + if (!isPercentageMode) value = this._getFormattedValue(aggConfig.fieldFormatter('html'), value); + if (bucketAgg) { + const bucketValue = bucketAgg.fieldFormatter('text')(row[bucketColumnId]); + title = `${bucketValue} - ${aggConfig.makeLabel()}`; + } else { + title = aggConfig.makeLabel(); } + } - const shouldColor = config.colorsRange.length > 1; - - metrics.push({ - label: title, - value: value, - color: shouldColor && config.style.labelColor ? color : null, - bgColor: shouldColor && config.style.bgColor ? color : null, - lightText: shouldColor && config.style.bgColor && this._needsLightText(color), - filterKey: rowHeaderIndex !== undefined ? row[rowHeaderIndex] : null, - tableIndex: tableIndex, - rowIndex: rowIndex, - columnIndex: rowHeaderIndex, - bucketAgg: bucketAgg, - }); + const shouldColor = config.colorsRange.length > 1; + + metrics.push({ + label: title, + value: value, + color: shouldColor && config.style.labelColor ? color : null, + bgColor: shouldColor && config.style.bgColor ? color : null, + lightText: shouldColor && config.style.bgColor && this._needsLightText(color), + filterKey: bucketColumnId !== undefined ? row[bucketColumnId] : null, + rowIndex: rowIndex, + columnIndex: rowHeaderIndex, + bucketAgg: bucketAgg, }); }); }); @@ -159,7 +158,7 @@ export class MetricVisComponent extends Component { if (!metric.filterKey || !metric.bucketAgg) { return; } - const table = this.props.visData.tables[metric.tableIndex]; + const table = this.props.visData; this.props.vis.API.events.addFilter(table, metric.columnIndex, metric.rowIndex); }; diff --git a/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js b/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js index 7f129d3e061a1d..bb0e205e87393d 100644 --- a/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js +++ b/src/core_plugins/metrics/public/visualizations/components/gauge_vis.js @@ -85,7 +85,8 @@ class GaugeVis extends Component { position: 'relative', display: 'flex', rowDirection: 'column', - flex: '1 0 auto' + flex: '1 0 auto', + overflow: 'hidden', // Fixes IE scrollbars issue }, svg: { position: 'absolute', diff --git a/src/core_plugins/region_map/public/__tests__/region_map_visualization.js b/src/core_plugins/region_map/public/__tests__/region_map_visualization.js index b0acdb85fddcff..32b9c22839ff7d 100644 --- a/src/core_plugins/region_map/public/__tests__/region_map_visualization.js +++ b/src/core_plugins/region_map/public/__tests__/region_map_visualization.js @@ -36,9 +36,9 @@ import afterdatachangeandresizePng from './afterdatachangeandresize.png'; import aftercolorchangePng from './aftercolorchange.png'; import changestartupPng from './changestartup.png'; -const manifestUrl = 'https://staging-dot-catalogue-dot-elastic-layer.appspot.com/v1/manifest'; -const tmsManifestUrl = `"https://tiles-maps-stage.elastic.co/v2/manifest`; -const vectorManifestUrl = `"https://staging-dot-elastic-layer.appspot.com/v1/manifest`; +const manifestUrl = 'https://catalogue-staging.maps.elastic.co/v2/manifest'; +const tmsManifestUrl = `https://tiles-maps-stage.elastic.co/v2/manifest`; +const vectorManifestUrl = `https://vector-staging.maps.elastic.co/v2/manifest`; const manifest = { 'services': [{ 'id': 'tiles_v2', @@ -108,22 +108,26 @@ describe('RegionMapsVisualizationTests', function () { const _makeJsonAjaxCallOld = ChoroplethLayer.prototype._makeJsonAjaxCall; const dummyTableGroup = { - tables: [ - { - columns: [{ - 'aggConfig': { - 'id': '2', - 'enabled': true, - 'type': 'terms', - 'schema': 'segment', - 'params': { 'field': 'geo.dest', 'size': 5, 'order': 'desc', 'orderBy': '1' } - }, 'title': 'geo.dest: Descending' - }, { - 'aggConfig': { 'id': '1', 'enabled': true, 'type': 'count', 'schema': 'metric', 'params': {} }, - 'title': 'Count' - }], - rows: [['CN', 26], ['IN', 17], ['US', 6], ['DE', 4], ['BR', 3]] - } + columns: [{ + 'id': 'col-0', + 'aggConfig': { + 'id': '2', + 'enabled': true, + 'type': 'terms', + 'schema': 'segment', + 'params': { 'field': 'geo.dest', 'size': 5, 'order': 'desc', 'orderBy': '1' } + }, 'title': 'geo.dest: Descending' + }, { + 'id': 'col-1', + 'aggConfig': { 'id': '1', 'enabled': true, 'type': 'count', 'schema': 'metric', 'params': {} }, + 'title': 'Count' + }], + rows: [ + { 'col-0': 'CN', 'col-1': 26 }, + { 'col-0': 'IN', 'col-1': 17 }, + { 'col-0': 'US', 'col-1': 6 }, + { 'col-0': 'DE', 'col-1': 4 }, + { 'col-0': 'BR', 'col-1': 3 } ] }; @@ -185,7 +189,7 @@ describe('RegionMapsVisualizationTests', function () { 'attribution': '

Made with NaturalEarth | Elastic Maps Service

', 'name': 'World Countries', 'format': 'geojson', - 'url': 'https://staging-dot-elastic-layer.appspot.com/blob/5715999101812736?elastic_tile_service_tos=agree&my_app_version=7.0.0-alpha1', + 'url': 'https://vector-staging.maps.elastic.co/blob/5715999101812736?elastic_tile_service_tos=agree&my_app_version=7.0.0-alpha1', 'fields': [{ 'name': 'iso2', 'description': 'Two letter abbreviation' }, { 'name': 'iso3', 'description': 'Three letter abbreviation' @@ -293,7 +297,7 @@ describe('RegionMapsVisualizationTests', function () { }); const newTableGroup = _.cloneDeep(dummyTableGroup); - newTableGroup.tables[0].rows.pop();//remove one shape + newTableGroup.rows.pop();//remove one shape await regionMapsVisualization.render(newTableGroup, { resize: false, @@ -306,7 +310,7 @@ describe('RegionMapsVisualizationTests', function () { const anotherTableGroup = _.cloneDeep(newTableGroup); - anotherTableGroup.tables[0].rows.pop();//remove one shape + anotherTableGroup.rows.pop();//remove one shape domNode.style.width = '412px'; domNode.style.height = '112px'; await regionMapsVisualization.render(anotherTableGroup, { @@ -336,7 +340,7 @@ describe('RegionMapsVisualizationTests', function () { }); const newTableGroup = _.cloneDeep(dummyTableGroup); - newTableGroup.tables[0].rows.pop();//remove one shape + newTableGroup.rows.pop();//remove one shape vis.params.colorSchema = 'Blues'; await regionMapsVisualization.render(newTableGroup, { resize: false, diff --git a/src/core_plugins/region_map/public/region_map_visualization.js b/src/core_plugins/region_map/public/region_map_visualization.js index 881bfd4ea8dc63..fd2be9aef49dc3 100644 --- a/src/core_plugins/region_map/public/region_map_visualization.js +++ b/src/core_plugins/region_map/public/region_map_visualization.js @@ -46,14 +46,17 @@ export function RegionMapsVisualizationProvider(Private, config) { } } - async _updateData(tableGroup) { - this._chartData = tableGroup; + async _updateData(table) { + this._chartData = table; let results; - if (!tableGroup || !tableGroup.tables || !tableGroup.tables.length || tableGroup.tables[0].columns.length !== 2) { + if (!table || !table.rows.length || table.columns.length !== 2) { results = []; } else { - const buckets = tableGroup.tables[0].rows; - results = buckets.map(([term, value]) => { + const termColumn = table.columns[0].id; + const valueColumn = table.columns[1].id; + results = table.rows.map(row => { + const term = row[termColumn]; + const value = row[valueColumn]; return { term: term, value: value }; }); } @@ -150,8 +153,8 @@ export function RegionMapsVisualizationProvider(Private, config) { return; } - const rowIndex = this._chartData.tables[0].rows.findIndex(row => row[0] === event); - this._vis.API.events.addFilter(this._chartData.tables[0], 0, rowIndex, event); + const rowIndex = this._chartData.rows.findIndex(row => row[0] === event); + this._vis.API.events.addFilter(this._chartData, 0, rowIndex, event); }); this._choroplethLayer.on('styleChanged', (event) => { diff --git a/src/core_plugins/status_page/public/index.scss b/src/core_plugins/status_page/public/index.scss index bef40abe1a8cfc..8bab6ef41e1589 100644 --- a/src/core_plugins/status_page/public/index.scss +++ b/src/core_plugins/status_page/public/index.scss @@ -1,4 +1,4 @@ -@import '../../../ui/public/styles/styling_constants'; +@import 'ui/public/styles/styling_constants'; // SASSTODO: Remove when K7 applies background color to body .stsPage { diff --git a/src/core_plugins/status_page/public/lib/load_status.js b/src/core_plugins/status_page/public/lib/load_status.js index d21fe0c9e9b5a0..9cb1046005ea00 100644 --- a/src/core_plugins/status_page/public/lib/load_status.js +++ b/src/core_plugins/status_page/public/lib/load_status.js @@ -20,7 +20,7 @@ import _ from 'lodash'; import chrome from 'ui/chrome'; -import { notify } from 'ui/notify'; +import { toastNotifications } from 'ui/notify'; import { i18n } from '@kbn/i18n'; // Module-level error returned by notify.error @@ -130,7 +130,7 @@ async function loadStatus(fetchFn = fetchData) { }, ); - errorNotif = notify.error(serverIsDownErrorMessage); + errorNotif = toastNotifications.addDanger(serverIsDownErrorMessage); return e; } @@ -144,7 +144,7 @@ async function loadStatus(fetchFn = fetchData) { }, ); - errorNotif = notify.error(serverStatusCodeErrorMessage); + errorNotif = toastNotifications.addDanger(serverStatusCodeErrorMessage); return; } diff --git a/src/core_plugins/table_vis/public/__tests__/_table_vis_controller.js b/src/core_plugins/table_vis/public/__tests__/_table_vis_controller.js index d13075ed2c0433..acdb2d1b6aa08b 100644 --- a/src/core_plugins/table_vis/public/__tests__/_table_vis_controller.js +++ b/src/core_plugins/table_vis/public/__tests__/_table_vis_controller.js @@ -21,7 +21,7 @@ import $ from 'jquery'; import _ from 'lodash'; import expect from 'expect.js'; import ngMock from 'ng_mock'; -import { tabifyAggResponse } from 'ui/agg_response/tabify/tabify'; +import { LegacyResponseHandlerProvider } from 'ui/vis/response_handlers/legacy'; import { VisProvider } from 'ui/vis'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { AppStateProvider } from 'ui/state_management/app_state'; @@ -35,6 +35,7 @@ describe('Table Vis Controller', function () { let Vis; let fixtures; let AppState; + let tableAggResponse; beforeEach(ngMock.module('kibana', 'kibana/table_vis')); beforeEach(ngMock.inject(function ($injector) { @@ -44,6 +45,7 @@ describe('Table Vis Controller', function () { fixtures = require('fixtures/fake_hierarchical_data'); AppState = Private(AppStateProvider); Vis = Private(VisProvider); + tableAggResponse = Private(LegacyResponseHandlerProvider).handler; })); function OneRangeVis(params) { @@ -99,16 +101,14 @@ describe('Table Vis Controller', function () { $rootScope.$apply(); } - it('exposes #tableGroups and #hasSomeRows when a response is attached to scope', function () { + it('exposes #tableGroups and #hasSomeRows when a response is attached to scope', async function () { const vis = new OneRangeVis(); initController(vis); expect(!$scope.tableGroups).to.be.ok(); expect(!$scope.hasSomeRows).to.be.ok(); - attachEsResponseToScope(tabifyAggResponse(vis.getAggConfig(), fixtures.oneRangeBucket, { - isHierarchical: vis.isHierarchical() - })); + attachEsResponseToScope(await tableAggResponse(vis, fixtures.oneRangeBucket)); expect($scope.hasSomeRows).to.be(true); expect($scope.tableGroups).to.have.property('tables'); @@ -117,20 +117,18 @@ describe('Table Vis Controller', function () { expect($scope.tableGroups.tables[0].rows).to.have.length(2); }); - it('clears #tableGroups and #hasSomeRows when the response is removed', function () { + it('clears #tableGroups and #hasSomeRows when the response is removed', async function () { const vis = new OneRangeVis(); initController(vis); - attachEsResponseToScope(tabifyAggResponse(vis.getAggConfig(), fixtures.oneRangeBucket, { - isHierarchical: vis.isHierarchical() - })); + attachEsResponseToScope(await tableAggResponse(vis, fixtures.oneRangeBucket)); removeEsResponseFromScope(); expect(!$scope.hasSomeRows).to.be.ok(); expect(!$scope.tableGroups).to.be.ok(); }); - it('sets the sort on the scope when it is passed as a vis param', function () { + it('sets the sort on the scope when it is passed as a vis param', async function () { const sortObj = { columnIndex: 1, direction: 'asc' @@ -142,15 +140,13 @@ describe('Table Vis Controller', function () { const resp = _.cloneDeep(fixtures.oneRangeBucket); resp.aggregations.agg_2.buckets = {}; - attachEsResponseToScope(tabifyAggResponse(vis.getAggConfig(), resp, { - isHierarchical: vis.isHierarchical() - })); + attachEsResponseToScope(await tableAggResponse(vis, resp)); expect($scope.sort.columnIndex).to.equal(sortObj.columnIndex); expect($scope.sort.direction).to.equal(sortObj.direction); }); - it('sets #hasSomeRows properly if the table group is empty', function () { + it('sets #hasSomeRows properly if the table group is empty', async function () { const vis = new OneRangeVis(); initController(vis); @@ -158,9 +154,7 @@ describe('Table Vis Controller', function () { const resp = _.cloneDeep(fixtures.oneRangeBucket); resp.aggregations.agg_2.buckets = {}; - attachEsResponseToScope(tabifyAggResponse(vis.getAggConfig(), resp, { - isHierarchical: vis.isHierarchical() - })); + attachEsResponseToScope(await tableAggResponse(vis, resp)); expect($scope.hasSomeRows).to.be(false); expect(!$scope.tableGroups).to.be.ok(); diff --git a/src/core_plugins/table_vis/public/table_vis.js b/src/core_plugins/table_vis/public/table_vis.js index 5d430b71e26f65..05cc5f4c3aab57 100644 --- a/src/core_plugins/table_vis/public/table_vis.js +++ b/src/core_plugins/table_vis/public/table_vis.js @@ -96,6 +96,7 @@ function TableVisTypeProvider(Private) { } ]) }, + responseHandler: 'legacy', responseHandlerConfig: { asAggConfigResults: true }, diff --git a/src/core_plugins/tagcloud/public/__tests__/tag_cloud_visualization.js b/src/core_plugins/tagcloud/public/__tests__/tag_cloud_visualization.js index 7a8db667f7d560..d37c19786b69b8 100644 --- a/src/core_plugins/tagcloud/public/__tests__/tag_cloud_visualization.js +++ b/src/core_plugins/tagcloud/public/__tests__/tag_cloud_visualization.js @@ -39,23 +39,27 @@ describe('TagCloudVisualizationTest', function () { let imageComparator; const dummyTableGroup = { - tables: [ - { - columns: [{ - 'aggConfig': { - 'id': '2', - 'enabled': true, - 'type': 'terms', - 'schema': 'segment', - 'params': { 'field': 'geo.dest', 'size': 5, 'order': 'desc', 'orderBy': '1' }, - fieldFormatter: () => (x => x) - }, 'title': 'geo.dest: Descending' - }, { - 'aggConfig': { 'id': '1', 'enabled': true, 'type': 'count', 'schema': 'metric', 'params': {} }, - 'title': 'Count' - }], - rows: [['CN', 26], ['IN', 17], ['US', 6], ['DE', 4], ['BR', 3]] - } + columns: [{ + id: 'col-0', + 'aggConfig': { + 'id': '2', + 'enabled': true, + 'type': 'terms', + 'schema': 'segment', + 'params': { 'field': 'geo.dest', 'size': 5, 'order': 'desc', 'orderBy': '1' }, + fieldFormatter: () => (x => x) + }, 'title': 'geo.dest: Descending' + }, { + id: 'col-1', + 'aggConfig': { 'id': '1', 'enabled': true, 'type': 'count', 'schema': 'metric', 'params': {} }, + 'title': 'Count' + }], + rows: [ + { 'col-0': 'CN', 'col-1': 26 }, + { 'col-0': 'IN', 'col-1': 17 }, + { 'col-0': 'US', 'col-1': 6 }, + { 'col-0': 'DE', 'col-1': 4 }, + { 'col-0': 'BR', 'col-1': 3 } ] }; diff --git a/src/core_plugins/tagcloud/public/tag_cloud_vis.js b/src/core_plugins/tagcloud/public/tag_cloud_vis.js index ee6aed7b6868b6..4ac7d92efdf478 100644 --- a/src/core_plugins/tagcloud/public/tag_cloud_vis.js +++ b/src/core_plugins/tagcloud/public/tag_cloud_vis.js @@ -77,6 +77,7 @@ VisTypesRegistryProvider.register(function (Private) { aggFilter: ['terms', 'significant_terms'] } ]) - } + }, + useCustomNoDataScreen: true }); }); diff --git a/src/core_plugins/tagcloud/public/tag_cloud_visualization.js b/src/core_plugins/tagcloud/public/tag_cloud_visualization.js index 8773deb268d4d7..251673ca30cae1 100644 --- a/src/core_plugins/tagcloud/public/tag_cloud_visualization.js +++ b/src/core_plugins/tagcloud/public/tag_cloud_visualization.js @@ -67,6 +67,7 @@ export class TagCloudVisualization { } async render(data, status) { + if (!(status.resize || status.data || status.params)) return; if (status.params || status.aggs) { this._updateParams(); @@ -109,13 +110,12 @@ export class TagCloudVisualization { } - _updateData(response) { - if (!response || !response.tables.length) { + _updateData(data) { + if (!data || !data.rows.length) { this._tagCloud.setData([]); return; } - const data = response.tables[0]; const segmentAggs = this._vis.aggs.bySchemaName.segment; if (segmentAggs && segmentAggs.length > 0) { this._bucketAgg = segmentAggs[0]; @@ -123,12 +123,16 @@ export class TagCloudVisualization { this._bucketAgg = null; } + const hasTags = data.columns.length === 2; + const tagColumn = hasTags ? data.columns[0].id : -1; + const metricColumn = data.columns[hasTags ? 1 : 0].id; const tags = data.rows.map((row, rowIndex) => { - const [tag, count] = row; + const tag = row[tagColumn] || 'all'; + const metric = row[metricColumn]; return { displayText: this._bucketAgg ? this._bucketAgg.fieldFormatter()(tag) : tag, rawText: tag, - value: count, + value: metric, meta: { data: data, rowIndex: rowIndex, diff --git a/src/core_plugins/tests_bundle/tests_entry_template.js b/src/core_plugins/tests_bundle/tests_entry_template.js index eb015454aaf272..799a25033bef20 100644 --- a/src/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/core_plugins/tests_bundle/tests_entry_template.js @@ -60,7 +60,7 @@ const legacyMetadata = { }, mapConfig: { includeElasticMapsService: true, - manifestServiceUrl: 'https://staging-dot-catalogue-dot-elastic-layer.appspot.com/v1/manifest' + manifestServiceUrl: 'https://catalogue-staging.maps.elastic.co/v2/manifest' }, vegaConfig: { enabled: true, diff --git a/src/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js b/src/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js index b1cd3bfd37c361..426c2c37a08ed3 100644 --- a/src/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js +++ b/src/core_plugins/tile_map/public/__tests__/coordinate_maps_visualization.js @@ -50,7 +50,7 @@ function mockRawData() { mockRawData(); -const manifestUrl = 'https://staging-dot-catalogue-dot-elastic-layer.appspot.com/v1/manifest'; +const manifestUrl = 'https://catalogue-staging.maps.elastic.co/v2/manifest'; const tmsManifestUrl = `"https://tiles-maps-stage.elastic.co/v2/manifest`; const manifest = { 'services': [{ diff --git a/src/core_plugins/tile_map/public/__tests__/geohash_layer.js b/src/core_plugins/tile_map/public/__tests__/geohash_layer.js index b8f172bc460ac4..240ffdf4276714 100644 --- a/src/core_plugins/tile_map/public/__tests__/geohash_layer.js +++ b/src/core_plugins/tile_map/public/__tests__/geohash_layer.js @@ -22,7 +22,7 @@ import { KibanaMap } from 'ui/vis/map/kibana_map'; import { GeohashLayer } from '../geohash_layer'; import heatmapPng from './heatmap.png'; import scaledCircleMarkersPng from './scaledCircleMarkers.png'; -import shadedCircleMarkersPng from './shadedCircleMarkers.png'; +// import shadedCircleMarkersPng from './shadedCircleMarkers.png'; import { ImageComparator } from 'test_utils/image_comparator'; import GeoHashSampleData from './dummy_es_response.json'; @@ -81,10 +81,11 @@ describe('geohash_layer', function () { options: { mapType: 'Scaled Circle Markers', colorRamp: 'Yellow to Red' }, expected: scaledCircleMarkersPng }, - { - options: { mapType: 'Shaded Circle Markers', colorRamp: 'Yellow to Red' }, - expected: shadedCircleMarkersPng - }, + // https://github.com/elastic/kibana/issues/19393 + // { + // options: { mapType: 'Shaded Circle Markers', colorRamp: 'Yellow to Red' }, + // expected: shadedCircleMarkersPng + // }, { options: { mapType: 'Heatmap', diff --git a/src/core_plugins/tile_map/public/coordinate_maps_visualization.js b/src/core_plugins/tile_map/public/coordinate_maps_visualization.js index 2cb2da826e83e5..7018db87c89d22 100644 --- a/src/core_plugins/tile_map/public/coordinate_maps_visualization.js +++ b/src/core_plugins/tile_map/public/coordinate_maps_visualization.js @@ -186,7 +186,7 @@ export function CoordinateMapsVisualizationProvider(Notifier, Private) { return; } - const indexPatternName = agg._indexPattern.id; + const indexPatternName = agg.getIndexPattern().id; const field = agg.fieldName(); const filter = { meta: { negate: false, index: indexPatternName } }; filter[filterName] = { ignore_unmapped: true }; diff --git a/src/core_plugins/tile_map/public/coordinatemap_response_handler.js b/src/core_plugins/tile_map/public/coordinatemap_response_handler.js index b99cef25104bb4..e1bc9b9592107c 100644 --- a/src/core_plugins/tile_map/public/coordinatemap_response_handler.js +++ b/src/core_plugins/tile_map/public/coordinatemap_response_handler.js @@ -34,9 +34,7 @@ export function makeGeoJsonResponseHandler() { //double conversion, first to table, then to geojson //This is to future-proof this code for Canvas-refactoring - const tabifiedResponse = tabifyAggResponse(vis.getAggConfig(), esResponse, { - asAggConfigResults: false - }); + const tabifiedResponse = tabifyAggResponse(vis.getAggConfig(), esResponse, { partialRows: true }); lastGeoJsonResponse = convertToGeoJson(tabifiedResponse); return lastGeoJsonResponse; diff --git a/src/core_plugins/timelion/public/app.js b/src/core_plugins/timelion/public/app.js index ffadf7c9cdb1bc..316a9704b5c3d3 100644 --- a/src/core_plugins/timelion/public/app.js +++ b/src/core_plugins/timelion/public/app.js @@ -18,7 +18,6 @@ */ import _ from 'lodash'; -import moment from 'moment-timezone'; import { DocTitleProvider } from 'ui/doc_title'; import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; @@ -101,9 +100,6 @@ app.controller('timelion', function ( $scope.page = config.get('timelion:showTutorial', true) ? 1 : 0; $scope.setPage = (page) => $scope.page = page; - // TODO: For some reason the Kibana core doesn't correctly do this for all apps. - moment.tz.setDefault(config.get('dateFormat:tz')); - timefilter.enableAutoRefreshSelector(); timefilter.enableTimeRangeSelector(); diff --git a/src/dev/build/tasks/clean_tasks.js b/src/dev/build/tasks/clean_tasks.js index a2f2cb07d4af7f..2fa7fb48b0074b 100644 --- a/src/dev/build/tasks/clean_tasks.js +++ b/src/dev/build/tasks/clean_tasks.js @@ -187,6 +187,7 @@ export const CleanExtraBrowsersTask = { if (platforms.windows) { paths.push(phantomPath('phantomjs-*-windows.zip')); paths.push(chromiumPath('chromium-*-win32.zip')); + paths.push(chromiumPath('chromium-*-windows.zip')); } if (platforms.darwin) { diff --git a/src/dev/build/tasks/transpile_scss_task.js b/src/dev/build/tasks/transpile_scss_task.js index 69621797ea2526..04a5ed03bd6394 100644 --- a/src/dev/build/tasks/transpile_scss_task.js +++ b/src/dev/build/tasks/transpile_scss_task.js @@ -26,7 +26,9 @@ export const TranspileScssTask = { async run(config, log, build) { const scanDirs = [ build.resolvePath('src/core_plugins') ]; - const { spec$ } = findPluginSpecs({ plugins: { scanDirs, paths: [] } }); + const paths = [ build.resolvePath('node_modules/x-pack') ]; + + const { spec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); const enabledPlugins = await spec$.pipe(toArray()).toPromise(); try { diff --git a/src/dev/ci_setup/git_setup.sh b/src/dev/ci_setup/git_setup.sh index b5e6902e2f2589..28992aaaadfcd5 100755 --- a/src/dev/ci_setup/git_setup.sh +++ b/src/dev/ci_setup/git_setup.sh @@ -80,6 +80,9 @@ function checkout_sibling { function checkout_clone_target { pick_clone_target + if [[ $cloneBranch = "master" && $cloneAuthor = "elastic" ]]; then + export TEST_ES_FROM=snapshot + fi echo " -> checking out '${cloneBranch}' branch from ${cloneAuthor}/${project}..." git clone -b "$cloneBranch" "git@github.com:${cloneAuthor}/${project}.git" "$targetDir" --depth=1 diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index 90edbd2268febd..b643f48988b646 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -30,13 +30,27 @@ fi ### ### download node ### +UNAME=$(uname) +OS='linux' +if [[ "$UNAME" = *"MINGW64_NT"* ]]; then + OS='win' +fi +echo " -- Running on OS: $OS" + nodeVersion="$(cat $dir/.node-version)" -nodeUrl="https://nodejs.org/download/release/v$nodeVersion/node-v$nodeVersion-linux-x64.tar.gz" nodeDir="$cacheDir/node/$nodeVersion" +if [[ $OS == 'win' ]]; then + nodeBin="$nodeDir" + nodeUrl="https://nodejs.org/download/release/v$nodeVersion/node-v$nodeVersion-win-x64.zip" +else + nodeBin="$nodeDir/bin" + nodeUrl="https://nodejs.org/download/release/v$nodeVersion/node-v$nodeVersion-linux-x64.tar.gz" +fi + echo " -- node: version=v${nodeVersion} dir=$nodeDir" echo " -- setting up node.js" -if [ -x "$nodeDir/bin/node" ] && [ "$($nodeDir/bin/node --version)" == "v$nodeVersion" ]; then +if [ -x "$nodeBin/node" ] && [ "$($nodeBin/node --version)" == "v$nodeVersion" ]; then echo " -- reusing node.js install" else if [ -d "$nodeDir" ]; then @@ -46,14 +60,21 @@ else echo " -- downloading node.js from $nodeUrl" mkdir -p "$nodeDir" - curl --silent "$nodeUrl" | tar -xz -C "$nodeDir" --strip-components=1 + if [[ $OS == 'win' ]]; then + nodePkg="$nodeDir/${nodeUrl##*/}" + curl --silent -o $nodePkg $nodeUrl + unzip -jqo $nodePkg -d $nodeDir + else + curl --silent "$nodeUrl" | tar -xz -C "$nodeDir" --strip-components=1 + fi + fi ### ### "install" node into this shell ### -export PATH="$nodeDir/bin:$PATH" +export PATH="$nodeBin:$PATH" hash -r diff --git a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap index b84544e6207039..1a9997dc07dd92 100644 --- a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap +++ b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap @@ -1,136 +1,63 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`dev/i18n/extract_default_translations extracts messages to en.json 1`] = ` -"{ - \\"formats\\": { - \\"number\\": { - \\"currency\\": { - \\"style\\": \\"currency\\" - }, - \\"percent\\": { - \\"style\\": \\"percent\\" - } +exports[`dev/i18n/extract_default_translations extracts messages from path to map 1`] = ` +Array [ + Array [ + "plugin_1.id_1", + Object { + "context": undefined, + "message": "Message 1", }, - \\"date\\": { - \\"short\\": { - \\"month\\": \\"numeric\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"2-digit\\" - }, - \\"medium\\": { - \\"month\\": \\"short\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - }, - \\"long\\": { - \\"month\\": \\"long\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - }, - \\"full\\": { - \\"weekday\\": \\"long\\", - \\"month\\": \\"long\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - } + ], + Array [ + "plugin_1.id_2", + Object { + "context": "Message context", + "message": "Message 2", }, - \\"time\\": { - \\"short\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\" - }, - \\"medium\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\" - }, - \\"long\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\", - \\"timeZoneName\\": \\"short\\" - }, - \\"full\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\", - \\"timeZoneName\\": \\"short\\" - } - } - }, - \\"plugin_1.id_1\\": \\"Message 1\\", - \\"plugin_1.id_2\\": { - \\"text\\": \\"Message 2\\", - \\"comment\\": \\"Message context\\" - }, - \\"plugin_1.id_3\\": \\"Message 3\\", - \\"plugin_1.id_4\\": \\"Message 4\\", - \\"plugin_1.id_5\\": \\"Message 5\\", - \\"plugin_1.id_6\\": \\"Message 6\\", - \\"plugin_1.id_7\\": \\"Message 7\\" -}" -`; - -exports[`dev/i18n/extract_default_translations injects default formats into en.json 1`] = ` -"{ - \\"formats\\": { - \\"number\\": { - \\"currency\\": { - \\"style\\": \\"currency\\" - }, - \\"percent\\": { - \\"style\\": \\"percent\\" - } + ], + Array [ + "plugin_1.id_3", + Object { + "context": undefined, + "message": "Message 3", + }, + ], + Array [ + "plugin_1.id_4", + Object { + "context": undefined, + "message": "Message 4", + }, + ], + Array [ + "plugin_1.id_5", + Object { + "context": undefined, + "message": "Message 5", }, - \\"date\\": { - \\"short\\": { - \\"month\\": \\"numeric\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"2-digit\\" - }, - \\"medium\\": { - \\"month\\": \\"short\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - }, - \\"long\\": { - \\"month\\": \\"long\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - }, - \\"full\\": { - \\"weekday\\": \\"long\\", - \\"month\\": \\"long\\", - \\"day\\": \\"numeric\\", - \\"year\\": \\"numeric\\" - } + ], + Array [ + "plugin_1.id_6", + Object { + "context": "", + "message": "Message 6", }, - \\"time\\": { - \\"short\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\" - }, - \\"medium\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\" - }, - \\"long\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\", - \\"timeZoneName\\": \\"short\\" - }, - \\"full\\": { - \\"hour\\": \\"numeric\\", - \\"minute\\": \\"numeric\\", - \\"second\\": \\"numeric\\", - \\"timeZoneName\\": \\"short\\" - } - } - }, - \\"plugin_2.message-id\\": \\"Message text\\" -}" + ], + Array [ + "plugin_1.id_7", + Object { + "context": undefined, + "message": "Message 7", + }, + ], +] +`; + +exports[`dev/i18n/extract_default_translations throws on id collision 1`] = ` +" I18N ERROR  Error in src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx +Error: There is more than one default message for the same id \\"plugin_3.duplicate_id\\": +\\"Message 1\\" and \\"Message 2\\"" `; -exports[`dev/i18n/extract_default_translations throws on wrong message namespace 1`] = `"Expected \\"wrong_plugin_namespace.message-id\\" id to have \\"plugin_2\\" namespace. See i18nrc.json for the list of supported namespaces."`; +exports[`dev/i18n/extract_default_translations throws on wrong message namespace 1`] = `"Expected \\"wrong_plugin_namespace.message-id\\" id to have \\"plugin_2\\" namespace. See .i18nrc.json for the list of supported namespaces."`; diff --git a/src/dev/i18n/__snapshots__/extract_handlebars_messages.test.js.snap b/src/dev/i18n/__snapshots__/extract_handlebars_messages.test.js.snap deleted file mode 100644 index 4506967f0eac09..00000000000000 --- a/src/dev/i18n/__snapshots__/extract_handlebars_messages.test.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dev/i18n/extract_handlebars_messages extracts handlebars default messages 1`] = ` -Array [ - Array [ - "ui.id-1", - Object { - "context": "Message context", - "message": "Message text", - }, - ], -] -`; - -exports[`dev/i18n/extract_handlebars_messages throws on empty id 1`] = `"Empty id argument in Handlebars i18n is not allowed."`; - -exports[`dev/i18n/extract_handlebars_messages throws on missing defaultMessage property 1`] = `"Empty defaultMessage in Handlebars i18n is not allowed (\\"message-id\\")."`; - -exports[`dev/i18n/extract_handlebars_messages throws on wrong number of arguments 1`] = `"Wrong number of arguments for handlebars i18n call."`; - -exports[`dev/i18n/extract_handlebars_messages throws on wrong properties argument type 1`] = `"Properties string in Handlebars i18n should be a string literal (\\"ui.id-1\\")."`; diff --git a/src/dev/i18n/__snapshots__/extract_i18n_call_messages.test.js.snap b/src/dev/i18n/__snapshots__/extract_i18n_call_messages.test.js.snap deleted file mode 100644 index 396c735726b294..00000000000000 --- a/src/dev/i18n/__snapshots__/extract_i18n_call_messages.test.js.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`extractI18nCallMessages extracts "i18n" and "i18n.translate" functions call message 1`] = ` -Array [ - "message-id-1", - Object { - "context": "Message context 1", - "message": "Default message 1", - }, -] -`; - -exports[`extractI18nCallMessages extracts "i18n" and "i18n.translate" functions call message 2`] = ` -Array [ - "message-id-2", - Object { - "context": "Message context 2", - "message": "Default message 2", - }, -] -`; - -exports[`extractI18nCallMessages throws if defaultMessage is not a string literal 1`] = `"defaultMessage value in i18n() or i18n.translate() should be a string literal (\\"message-id\\")."`; - -exports[`extractI18nCallMessages throws if message id value is not a string literal 1`] = `"Message id in i18n() or i18n.translate() should be a string literal."`; - -exports[`extractI18nCallMessages throws if properties object is not provided 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; - -exports[`extractI18nCallMessages throws on empty defaultMessage 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/__snapshots__/extract_pug_messages.test.js.snap b/src/dev/i18n/__snapshots__/extract_pug_messages.test.js.snap deleted file mode 100644 index d67546739a9c16..00000000000000 --- a/src/dev/i18n/__snapshots__/extract_pug_messages.test.js.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`extractPugMessages extracts messages from pug template 1`] = ` -Array [ - "message-id", - Object { - "context": "Message context", - "message": "Default message", - }, -] -`; - -exports[`extractPugMessages throws on empty id 1`] = `"Empty \\"id\\" value in i18n() or i18n.translate() is not allowed."`; - -exports[`extractPugMessages throws on missing default message 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/__snapshots__/extract_react_messages.test.js.snap b/src/dev/i18n/__snapshots__/extract_react_messages.test.js.snap deleted file mode 100644 index 542cd6817e4937..00000000000000 --- a/src/dev/i18n/__snapshots__/extract_react_messages.test.js.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dev/i18n/extract_react_messages extractFormattedMessages extracts messages from "" element 1`] = ` -Array [ - "message-id-2", - Object { - "context": "Message context 2", - "message": "Default message 2", - }, -] -`; - -exports[`dev/i18n/extract_react_messages extractIntlMessages extracts messages from "intl.formatMessage" function call 1`] = ` -Array [ - "message-id-1", - Object { - "context": "Message context 1", - "message": "Default message 1", - }, -] -`; - -exports[`dev/i18n/extract_react_messages extractIntlMessages throws if context value is not a string literal 1`] = `"context value should be a string literal (\\"message-id\\")."`; - -exports[`dev/i18n/extract_react_messages extractIntlMessages throws if defaultMessage value is not a string literal 1`] = `"defaultMessage value should be a string literal (\\"message-id\\")."`; - -exports[`dev/i18n/extract_react_messages extractIntlMessages throws if message id is not a string literal 1`] = `"Message id should be a string literal."`; diff --git a/src/dev/i18n/__snapshots__/utils.test.js.snap b/src/dev/i18n/__snapshots__/utils.test.js.snap index 85e61058072b1b..2a2f196d3f13f3 100644 --- a/src/dev/i18n/__snapshots__/utils.test.js.snap +++ b/src/dev/i18n/__snapshots__/utils.test.js.snap @@ -1,3 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`i18n utils should escape linebreaks 1`] = `"Text with\\\\n\\\\n\\\\nline-breaks and \\\\n\\\\n\\\\n \\\\n\\\\n\\\\n "`; +exports[`i18n utils should not escape linebreaks 1`] = ` +"Text + with + line-breaks +" +`; diff --git a/src/dev/i18n/extract_default_translations.js b/src/dev/i18n/extract_default_translations.js index 645c43ed319d15..06d397961e2332 100644 --- a/src/dev/i18n/extract_default_translations.js +++ b/src/dev/i18n/extract_default_translations.js @@ -18,27 +18,27 @@ */ import path from 'path'; -import { i18n } from '@kbn/i18n'; -import JSON5 from 'json5'; import normalize from 'normalize-path'; - -import { extractHtmlMessages } from './extract_html_messages'; -import { extractCodeMessages } from './extract_code_messages'; -import { extractPugMessages } from './extract_pug_messages'; -import { extractHandlebarsMessages } from './extract_handlebars_messages'; -import { globAsync, readFileAsync, writeFileAsync } from './utils'; +import chalk from 'chalk'; + +import { + extractHtmlMessages, + extractCodeMessages, + extractPugMessages, + extractHandlebarsMessages, +} from './extractors'; +import { globAsync, readFileAsync } from './utils'; import { paths, exclude } from '../../../.i18nrc.json'; - -const ESCAPE_SINGLE_QUOTE_REGEX = /\\([\s\S])|(')/g; +import { createFailError, isFailError } from '../run'; function addMessageToMap(targetMap, key, value) { const existingValue = targetMap.get(key); + if (targetMap.has(key) && existingValue.message !== value.message) { - throw new Error( - `There is more than one default message for the same id "${key}": \ -"${existingValue.message}" and "${value.message}"` - ); + throw createFailError(`There is more than one default message for the same id "${key}": +"${existingValue.message}" and "${value.message}"`); } + targetMap.set(key, value); } @@ -46,7 +46,7 @@ function normalizePath(inputPath) { return normalize(path.relative('.', inputPath)); } -function filterPaths(inputPaths) { +export function filterPaths(inputPaths) { const availablePaths = Object.values(paths); const pathsForExtraction = new Set(); @@ -78,8 +78,8 @@ export function validateMessageNamespace(id, filePath) { ); if (!id.startsWith(`${expectedNamespace}.`)) { - throw new Error(`Expected "${id}" id to have "${expectedNamespace}" namespace. \ -See i18nrc.json for the list of supported namespaces.`); + throw createFailError(`Expected "${id}" id to have "${expectedNamespace}" namespace. \ +See .i18nrc.json for the list of supported namespaces.`); } } @@ -131,70 +131,15 @@ export async function extractMessagesFromPathToMap(inputPath, targetMap) { addMessageToMap(targetMap, id, value); } } catch (error) { - throw new Error(`Error in ${name}\n${error.message || error}`); + if (isFailError(error)) { + throw createFailError( + `${chalk.white.bgRed(' I18N ERROR ')} Error in ${normalizePath(name)}\n${error}` + ); + } + + throw error; } } }) ); } - -function serializeToJson5(defaultMessages) { - // .slice(0, -1): remove closing curly brace from json to append messages - let jsonBuffer = Buffer.from( - JSON5.stringify({ formats: i18n.formats }, { quote: `'`, space: 2 }).slice(0, -1) - ); - - for (const [mapKey, mapValue] of defaultMessages) { - const formattedMessage = mapValue.message.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2'); - const formattedContext = mapValue.context - ? mapValue.context.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2') - : ''; - - jsonBuffer = Buffer.concat([ - jsonBuffer, - Buffer.from(` '${mapKey}': '${formattedMessage}',`), - Buffer.from(formattedContext ? ` // ${formattedContext}\n` : '\n'), - ]); - } - - // append previously removed closing curly brace - jsonBuffer = Buffer.concat([jsonBuffer, Buffer.from('}\n')]); - - return jsonBuffer; -} - -function serializeToJson(defaultMessages) { - const resultJsonObject = { formats: i18n.formats }; - - for (const [mapKey, mapValue] of defaultMessages) { - if (mapValue.context) { - resultJsonObject[mapKey] = { text: mapValue.message, comment: mapValue.context }; - } else { - resultJsonObject[mapKey] = mapValue.message; - } - } - - return JSON.stringify(resultJsonObject, undefined, 2); -} - -export async function extractDefaultTranslations({ paths, output, outputFormat }) { - const defaultMessagesMap = new Map(); - - for (const inputPath of filterPaths(paths)) { - await extractMessagesFromPathToMap(inputPath, defaultMessagesMap); - } - - // messages shouldn't be extracted to a file if output is not supplied - if (!output || !defaultMessagesMap.size) { - return; - } - - const defaultMessages = [...defaultMessagesMap].sort(([key1], [key2]) => - key1.localeCompare(key2) - ); - - await writeFileAsync( - path.resolve(output, 'en.json'), - outputFormat === 'json5' ? serializeToJson5(defaultMessages) : serializeToJson(defaultMessages) - ); -} diff --git a/src/dev/i18n/extract_default_translations.test.js b/src/dev/i18n/extract_default_translations.test.js index b1e3ade402ebc5..b89361e87fcf7e 100644 --- a/src/dev/i18n/extract_default_translations.test.js +++ b/src/dev/i18n/extract_default_translations.test.js @@ -20,7 +20,7 @@ import path from 'path'; import { - extractDefaultTranslations, + extractMessagesFromPathToMap, validateMessageNamespace, } from './extract_default_translations'; @@ -40,46 +40,22 @@ jest.mock('../../../.i18nrc.json', () => ({ exclude: [], })); -const utils = require('./utils'); -utils.writeFileAsync = jest.fn(); - describe('dev/i18n/extract_default_translations', () => { - test('extracts messages to en.json', async () => { + test('extracts messages from path to map', async () => { const [pluginPath] = pluginsPaths; + const resultMap = new Map(); - utils.writeFileAsync.mockClear(); - await extractDefaultTranslations({ - paths: [pluginPath], - output: pluginPath, - }); - - const [[, json]] = utils.writeFileAsync.mock.calls; - - expect(json.toString()).toMatchSnapshot(); - }); - - test('injects default formats into en.json', async () => { - const [, pluginPath] = pluginsPaths; - - utils.writeFileAsync.mockClear(); - await extractDefaultTranslations({ - paths: [pluginPath], - output: pluginPath, - }); + await extractMessagesFromPathToMap(pluginPath, resultMap); - const [[, json]] = utils.writeFileAsync.mock.calls; - - expect(json.toString()).toMatchSnapshot(); + expect([...resultMap].sort()).toMatchSnapshot(); }); test('throws on id collision', async () => { const [, , pluginPath] = pluginsPaths; + await expect( - extractDefaultTranslations({ paths: [pluginPath], output: pluginPath }) - ).rejects.toMatchObject({ - message: `Error in ${path.join(pluginPath, 'test_file.jsx')} -There is more than one default message for the same id "plugin_3.duplicate_id": "Message 1" and "Message 2"`, - }); + extractMessagesFromPathToMap(pluginPath, new Map()) + ).rejects.toThrowErrorMatchingSnapshot(); }); test('validates message namespace', () => { diff --git a/src/dev/i18n/__snapshots__/extract_code_messages.test.js.snap b/src/dev/i18n/extractors/__snapshots__/code.test.js.snap similarity index 51% rename from src/dev/i18n/__snapshots__/extract_code_messages.test.js.snap rename to src/dev/i18n/extractors/__snapshots__/code.test.js.snap index e9c1972c181f46..26c621e32964dc 100644 --- a/src/dev/i18n/__snapshots__/extract_code_messages.test.js.snap +++ b/src/dev/i18n/extractors/__snapshots__/code.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`extractCodeMessages extracts React, server-side and angular service default messages 1`] = ` +exports[`dev/i18n/extractors/code extracts React, server-side and angular service default messages 1`] = ` Array [ Array [ "kbn.mgmt.id-1", @@ -26,6 +26,6 @@ Array [ ] `; -exports[`extractCodeMessages throws on empty id 1`] = `"Empty \\"id\\" value in i18n() or i18n.translate() is not allowed."`; +exports[`dev/i18n/extractors/code throws on empty id 1`] = `"Empty \\"id\\" value in i18n() or i18n.translate() is not allowed."`; -exports[`extractCodeMessages throws on missing defaultMessage 1`] = `"Empty defaultMessage in intl.formatMessage() is not allowed (\\"message-id\\")."`; +exports[`dev/i18n/extractors/code throws on missing defaultMessage 1`] = `"Empty defaultMessage in intl.formatMessage() is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/extractors/__snapshots__/handlebars.test.js.snap b/src/dev/i18n/extractors/__snapshots__/handlebars.test.js.snap new file mode 100644 index 00000000000000..7ca5178c7538ff --- /dev/null +++ b/src/dev/i18n/extractors/__snapshots__/handlebars.test.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/extractors/handlebars extracts handlebars default messages 1`] = ` +Array [ + Array [ + "ui.id-1", + Object { + "context": "Message context", + "message": "Message text", + }, + ], +] +`; + +exports[`dev/i18n/extractors/handlebars throws on empty id 1`] = `"Empty id argument in Handlebars i18n is not allowed."`; + +exports[`dev/i18n/extractors/handlebars throws on missing defaultMessage property 1`] = `"Empty defaultMessage in Handlebars i18n is not allowed (\\"message-id\\")."`; + +exports[`dev/i18n/extractors/handlebars throws on wrong number of arguments 1`] = `"Wrong number of arguments for handlebars i18n call."`; + +exports[`dev/i18n/extractors/handlebars throws on wrong properties argument type 1`] = `"Properties string in Handlebars i18n should be a string literal (\\"ui.id-1\\")."`; diff --git a/src/dev/i18n/__snapshots__/extract_html_messages.test.js.snap b/src/dev/i18n/extractors/__snapshots__/html.test.js.snap similarity index 53% rename from src/dev/i18n/__snapshots__/extract_html_messages.test.js.snap rename to src/dev/i18n/extractors/__snapshots__/html.test.js.snap index 2e18503b5d35b2..982341c8800740 100644 --- a/src/dev/i18n/__snapshots__/extract_html_messages.test.js.snap +++ b/src/dev/i18n/extractors/__snapshots__/html.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`dev/i18n/extract_html_messages extracts default messages from HTML 1`] = ` +exports[`dev/i18n/extractors/html extracts default messages from HTML 1`] = ` Array [ Array [ "kbn.dashboard.id-1", @@ -26,6 +26,6 @@ Array [ ] `; -exports[`dev/i18n/extract_html_messages throws on empty i18n-id 1`] = `"Empty \\"i18n-id\\" value in angular directive is not allowed."`; +exports[`dev/i18n/extractors/html throws on empty i18n-id 1`] = `"Empty \\"i18n-id\\" value in angular directive is not allowed."`; -exports[`dev/i18n/extract_html_messages throws on missing i18n-default-message attribute 1`] = `"Empty defaultMessage in angular directive is not allowed (\\"message-id\\")."`; +exports[`dev/i18n/extractors/html throws on missing i18n-default-message attribute 1`] = `"Empty defaultMessage in angular directive is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/extractors/__snapshots__/i18n_call.test.js.snap b/src/dev/i18n/extractors/__snapshots__/i18n_call.test.js.snap new file mode 100644 index 00000000000000..c9bf2f07716d4c --- /dev/null +++ b/src/dev/i18n/extractors/__snapshots__/i18n_call.test.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/extractors/i18n_call extracts "i18n" and "i18n.translate" functions call message 1`] = ` +Array [ + "message-id-1", + Object { + "context": "Message context 1", + "message": "Default message 1", + }, +] +`; + +exports[`dev/i18n/extractors/i18n_call extracts "i18n" and "i18n.translate" functions call message 2`] = ` +Array [ + "message-id-2", + Object { + "context": "Message context 2", + "message": "Default message 2", + }, +] +`; + +exports[`dev/i18n/extractors/i18n_call throws if defaultMessage is not a string literal 1`] = `"defaultMessage value in i18n() or i18n.translate() should be a string literal (\\"message-id\\")."`; + +exports[`dev/i18n/extractors/i18n_call throws if message id value is not a string literal 1`] = `"Message id in i18n() or i18n.translate() should be a string literal."`; + +exports[`dev/i18n/extractors/i18n_call throws if properties object is not provided 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; + +exports[`dev/i18n/extractors/i18n_call throws on empty defaultMessage 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/extractors/__snapshots__/pug.test.js.snap b/src/dev/i18n/extractors/__snapshots__/pug.test.js.snap new file mode 100644 index 00000000000000..c95fb0d149cd03 --- /dev/null +++ b/src/dev/i18n/extractors/__snapshots__/pug.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/extractors/pug extracts messages from pug template 1`] = ` +Array [ + "message-id", + Object { + "context": "Message context", + "message": "Default message", + }, +] +`; + +exports[`dev/i18n/extractors/pug throws on empty id 1`] = `"Empty \\"id\\" value in i18n() or i18n.translate() is not allowed."`; + +exports[`dev/i18n/extractors/pug throws on missing default message 1`] = `"Empty defaultMessage in i18n() or i18n.translate() is not allowed (\\"message-id\\")."`; diff --git a/src/dev/i18n/extractors/__snapshots__/react.test.js.snap b/src/dev/i18n/extractors/__snapshots__/react.test.js.snap new file mode 100644 index 00000000000000..6a51a5e2160043 --- /dev/null +++ b/src/dev/i18n/extractors/__snapshots__/react.test.js.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/extractors/react extractFormattedMessages extracts messages from "" element 1`] = ` +Array [ + "message-id-2", + Object { + "context": "Message context 2", + "message": "Default message 2", + }, +] +`; + +exports[`dev/i18n/extractors/react extractIntlMessages extracts messages from "intl.formatMessage" function call 1`] = ` +Array [ + "message-id-1", + Object { + "context": "Message context 1", + "message": "Default message 1", + }, +] +`; + +exports[`dev/i18n/extractors/react extractIntlMessages throws if context value is not a string literal 1`] = `"context value should be a string literal (\\"message-id\\")."`; + +exports[`dev/i18n/extractors/react extractIntlMessages throws if defaultMessage value is not a string literal 1`] = `"defaultMessage value should be a string literal (\\"message-id\\")."`; + +exports[`dev/i18n/extractors/react extractIntlMessages throws if message id is not a string literal 1`] = `"Message id should be a string literal."`; diff --git a/src/dev/i18n/extract_code_messages.js b/src/dev/i18n/extractors/code.js similarity index 94% rename from src/dev/i18n/extract_code_messages.js rename to src/dev/i18n/extractors/code.js index e7b72e6efa162e..e7477b17e2759e 100644 --- a/src/dev/i18n/extract_code_messages.js +++ b/src/dev/i18n/extractors/code.js @@ -26,9 +26,9 @@ import { isMemberExpression, } from '@babel/types'; -import { extractI18nCallMessages } from './extract_i18n_call_messages'; -import { isI18nTranslateFunction, traverseNodes } from './utils'; -import { extractIntlMessages, extractFormattedMessages } from './extract_react_messages'; +import { extractI18nCallMessages } from './i18n_call'; +import { isI18nTranslateFunction, traverseNodes } from '../utils'; +import { extractIntlMessages, extractFormattedMessages } from './react'; /** * Detect Intl.formatMessage() function call (React). diff --git a/src/dev/i18n/extract_code_messages.test.js b/src/dev/i18n/extractors/code.test.js similarity index 89% rename from src/dev/i18n/extract_code_messages.test.js rename to src/dev/i18n/extractors/code.test.js index 5b3e64ebb4f078..3cc7d39f78d400 100644 --- a/src/dev/i18n/extract_code_messages.test.js +++ b/src/dev/i18n/extractors/code.test.js @@ -24,8 +24,8 @@ import { extractCodeMessages, isFormattedMessageElement, isIntlFormatMessageFunction, -} from './extract_code_messages'; -import { traverseNodes } from './utils'; +} from './code'; +import { traverseNodes } from '../utils'; const extractCodeMessagesSource = Buffer.from(` i18n('kbn.mgmt.id-1', { defaultMessage: 'Message text 1' }); @@ -65,7 +65,7 @@ function f() { } `; -describe('extractCodeMessages', () => { +describe('dev/i18n/extractors/code', () => { test('extracts React, server-side and angular service default messages', () => { const actual = Array.from(extractCodeMessages(extractCodeMessagesSource)); expect(actual.sort()).toMatchSnapshot(); @@ -84,12 +84,16 @@ describe('extractCodeMessages', () => { describe('isIntlFormatMessageFunction', () => { test('detects intl.formatMessage call expression', () => { - const callExpressionNodes = [...traverseNodes(parse(intlFormatMessageSource).program.body)].filter( - node => isCallExpression(node) - ); + const callExpressionNodes = [ + ...traverseNodes(parse(intlFormatMessageSource).program.body), + ].filter(node => isCallExpression(node)); expect(callExpressionNodes).toHaveLength(4); - expect(callExpressionNodes.every(callExpressionNode => isIntlFormatMessageFunction(callExpressionNode))).toBe(true); + expect( + callExpressionNodes.every(callExpressionNode => + isIntlFormatMessageFunction(callExpressionNode) + ) + ).toBe(true); }); }); diff --git a/src/dev/i18n/extract_handlebars_messages.js b/src/dev/i18n/extractors/handlebars.js similarity index 77% rename from src/dev/i18n/extract_handlebars_messages.js rename to src/dev/i18n/extractors/handlebars.js index b63e30a7dd7d95..7c57c8d0da731f 100644 --- a/src/dev/i18n/extract_handlebars_messages.js +++ b/src/dev/i18n/extractors/handlebars.js @@ -17,7 +17,8 @@ * under the License. */ -import { formatJSString } from './utils'; +import { formatJSString } from '../utils'; +import { createFailError } from '../../run'; const HBS_REGEX = /(?<=\{\{)([\s\S]*?)(?=\}\})/g; const TOKENS_REGEX = /[^'\s]+|(?:'([^'\\]|\\[\s\S])*')/g; @@ -36,21 +37,21 @@ export function* extractHandlebarsMessages(buffer) { } if (tokens.length !== 3) { - throw new Error('Wrong number of arguments for handlebars i18n call.'); + throw createFailError(`Wrong number of arguments for handlebars i18n call.`); } if (!idString.startsWith(`'`) || !idString.endsWith(`'`)) { - throw new Error('Message id should be a string literal.'); + throw createFailError(`Message id should be a string literal.`); } const messageId = formatJSString(idString.slice(1, -1)); if (!messageId) { - throw new Error(`Empty id argument in Handlebars i18n is not allowed.`); + throw createFailError(`Empty id argument in Handlebars i18n is not allowed.`); } if (!propertiesString.startsWith(`'`) || !propertiesString.endsWith(`'`)) { - throw new Error( + throw createFailError( `Properties string in Handlebars i18n should be a string literal ("${messageId}").` ); } @@ -59,19 +60,23 @@ export function* extractHandlebarsMessages(buffer) { const message = formatJSString(properties.defaultMessage); if (typeof message !== 'string') { - throw new Error( + throw createFailError( `defaultMessage value in Handlebars i18n should be a string ("${messageId}").` ); } if (!message) { - throw new Error(`Empty defaultMessage in Handlebars i18n is not allowed ("${messageId}").`); + throw createFailError( + `Empty defaultMessage in Handlebars i18n is not allowed ("${messageId}").` + ); } const context = formatJSString(properties.context); if (context != null && typeof context !== 'string') { - throw new Error(`Context value in Handlebars i18n should be a string ("${messageId}").`); + throw createFailError( + `Context value in Handlebars i18n should be a string ("${messageId}").` + ); } yield [messageId, { message, context }]; diff --git a/src/dev/i18n/extract_handlebars_messages.test.js b/src/dev/i18n/extractors/handlebars.test.js similarity index 95% rename from src/dev/i18n/extract_handlebars_messages.test.js rename to src/dev/i18n/extractors/handlebars.test.js index e4f53852b6cc3a..52365989bd7fdc 100644 --- a/src/dev/i18n/extract_handlebars_messages.test.js +++ b/src/dev/i18n/extractors/handlebars.test.js @@ -17,9 +17,9 @@ * under the License. */ -import { extractHandlebarsMessages } from './extract_handlebars_messages'; +import { extractHandlebarsMessages } from './handlebars'; -describe('dev/i18n/extract_handlebars_messages', () => { +describe('dev/i18n/extractors/handlebars', () => { test('extracts handlebars default messages', () => { const source = Buffer.from(`\ window.onload = function () { diff --git a/src/dev/i18n/extract_html_messages.js b/src/dev/i18n/extractors/html.js similarity index 84% rename from src/dev/i18n/extract_html_messages.js rename to src/dev/i18n/extractors/html.js index 456916daf350ae..b576acb31c6d2e 100644 --- a/src/dev/i18n/extract_html_messages.js +++ b/src/dev/i18n/extractors/html.js @@ -21,8 +21,9 @@ import { jsdom } from 'jsdom'; import { parse } from '@babel/parser'; import { isDirectiveLiteral, isObjectExpression, isStringLiteral } from '@babel/types'; -import { isPropertyWithKey, formatHTMLString, formatJSString, traverseNodes } from './utils'; -import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants'; +import { isPropertyWithKey, formatHTMLString, formatJSString, traverseNodes } from '../utils'; +import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants'; +import { createFailError } from '../../run'; /** * Find all substrings of "{{ any text }}" pattern @@ -51,13 +52,13 @@ function parseFilterObjectExpression(expression) { for (const property of node.properties) { if (isPropertyWithKey(property, DEFAULT_MESSAGE_KEY)) { if (!isStringLiteral(property.value)) { - throw new Error('defaultMessage value should be a string literal.'); + throw createFailError(`defaultMessage value should be a string literal.`); } message = formatJSString(property.value.value); } else if (isPropertyWithKey(property, CONTEXT_KEY)) { if (!isStringLiteral(property.value)) { - throw new Error('context value should be a string literal.'); + throw createFailError(`context value should be a string literal.`); } context = formatJSString(property.value.value); @@ -95,19 +96,19 @@ function* getFilterMessages(htmlContent) { const filterObjectExpression = expression.slice(filterStart + I18N_FILTER_MARKER.length).trim(); if (!filterObjectExpression || !idExpression) { - throw new Error(`Cannot parse i18n filter expression: {{ ${expression} }}`); + throw createFailError(`Cannot parse i18n filter expression: {{ ${expression} }}`); } const messageId = parseIdExpression(idExpression); if (!messageId) { - throw new Error('Empty "id" value in angular filter expression is not allowed.'); + throw createFailError(`Empty "id" value in angular filter expression is not allowed.`); } const { message, context } = parseFilterObjectExpression(filterObjectExpression) || {}; if (!message) { - throw new Error( + throw createFailError( `Empty defaultMessage in angular filter expression is not allowed ("${messageId}").` ); } @@ -124,12 +125,14 @@ function* getDirectiveMessages(htmlContent) { for (const element of document.querySelectorAll('[i18n-id]')) { const messageId = formatHTMLString(element.getAttribute('i18n-id')); if (!messageId) { - throw new Error('Empty "i18n-id" value in angular directive is not allowed.'); + throw createFailError(`Empty "i18n-id" value in angular directive is not allowed.`); } const message = formatHTMLString(element.getAttribute('i18n-default-message')); if (!message) { - throw new Error(`Empty defaultMessage in angular directive is not allowed ("${messageId}").`); + throw createFailError( + `Empty defaultMessage in angular directive is not allowed ("${messageId}").` + ); } const context = formatHTMLString(element.getAttribute('i18n-context')) || undefined; diff --git a/src/dev/i18n/extract_html_messages.test.js b/src/dev/i18n/extractors/html.test.js similarity index 94% rename from src/dev/i18n/extract_html_messages.test.js rename to src/dev/i18n/extractors/html.test.js index d5cf7d6fd5ee2b..40664edd81e4aa 100644 --- a/src/dev/i18n/extract_html_messages.test.js +++ b/src/dev/i18n/extractors/html.test.js @@ -17,7 +17,7 @@ * under the License. */ -import { extractHtmlMessages } from './extract_html_messages'; +import { extractHtmlMessages } from './html'; const htmlSourceBuffer = Buffer.from(`
@@ -37,7 +37,7 @@ const htmlSourceBuffer = Buffer.from(`
`); -describe('dev/i18n/extract_html_messages', () => { +describe('dev/i18n/extractors/html', () => { test('extracts default messages from HTML', () => { const actual = Array.from(extractHtmlMessages(htmlSourceBuffer)); expect(actual.sort()).toMatchSnapshot(); diff --git a/src/dev/i18n/extract_i18n_call_messages.js b/src/dev/i18n/extractors/i18n_call.js similarity index 81% rename from src/dev/i18n/extract_i18n_call_messages.js rename to src/dev/i18n/extractors/i18n_call.js index 5b537ba4e01d23..1adcf42598e16c 100644 --- a/src/dev/i18n/extract_i18n_call_messages.js +++ b/src/dev/i18n/extractors/i18n_call.js @@ -19,8 +19,9 @@ import { isObjectExpression, isStringLiteral } from '@babel/types'; -import { isPropertyWithKey, formatJSString } from './utils'; -import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants'; +import { isPropertyWithKey, formatJSString } from '../utils'; +import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants'; +import { createFailError } from '../../run'; /** * Extract messages from `funcName('id', { defaultMessage: 'Message text' })` call expression AST @@ -29,20 +30,20 @@ export function extractI18nCallMessages(node) { const [idSubTree, optionsSubTree] = node.arguments; if (!isStringLiteral(idSubTree)) { - throw new Error('Message id in i18n() or i18n.translate() should be a string literal.'); + throw createFailError(`Message id in i18n() or i18n.translate() should be a string literal.`); } const messageId = idSubTree.value; if (!messageId) { - throw new Error('Empty "id" value in i18n() or i18n.translate() is not allowed.'); + throw createFailError(`Empty "id" value in i18n() or i18n.translate() is not allowed.`); } let message; let context; if (!isObjectExpression(optionsSubTree)) { - throw new Error( + throw createFailError( `Empty defaultMessage in i18n() or i18n.translate() is not allowed ("${messageId}").` ); } @@ -50,7 +51,7 @@ export function extractI18nCallMessages(node) { for (const prop of optionsSubTree.properties) { if (isPropertyWithKey(prop, DEFAULT_MESSAGE_KEY)) { if (!isStringLiteral(prop.value)) { - throw new Error( + throw createFailError( `defaultMessage value in i18n() or i18n.translate() should be a string literal ("${messageId}").` ); } @@ -58,7 +59,7 @@ export function extractI18nCallMessages(node) { message = formatJSString(prop.value.value); } else if (isPropertyWithKey(prop, CONTEXT_KEY)) { if (!isStringLiteral(prop.value)) { - throw new Error( + throw createFailError( `context value in i18n() or i18n.translate() should be a string literal ("${messageId}").` ); } @@ -68,7 +69,7 @@ export function extractI18nCallMessages(node) { } if (!message) { - throw new Error( + throw createFailError( `Empty defaultMessage in i18n() or i18n.translate() is not allowed ("${messageId}").` ); } diff --git a/src/dev/i18n/extract_i18n_call_messages.test.js b/src/dev/i18n/extractors/i18n_call.test.js similarity index 95% rename from src/dev/i18n/extract_i18n_call_messages.test.js rename to src/dev/i18n/extractors/i18n_call.test.js index 0985233e4b3dd4..f3ab92f4f1d6e2 100644 --- a/src/dev/i18n/extract_i18n_call_messages.test.js +++ b/src/dev/i18n/extractors/i18n_call.test.js @@ -20,8 +20,8 @@ import { parse } from '@babel/parser'; import { isCallExpression } from '@babel/types'; -import { extractI18nCallMessages } from './extract_i18n_call_messages'; -import { traverseNodes } from './utils'; +import { extractI18nCallMessages } from './i18n_call'; +import { traverseNodes } from '../utils'; const i18nCallMessageSource = ` i18n('message-id-1', { defaultMessage: 'Default message 1', context: 'Message context 1' }); @@ -31,7 +31,7 @@ const translateCallMessageSource = ` i18n.translate('message-id-2', { defaultMessage: 'Default message 2', context: 'Message context 2' }); `; -describe('extractI18nCallMessages', () => { +describe('dev/i18n/extractors/i18n_call', () => { test('extracts "i18n" and "i18n.translate" functions call message', () => { let callExpressionNode = [...traverseNodes(parse(i18nCallMessageSource).program.body)].find( node => isCallExpression(node) diff --git a/src/dev/i18n/extractors/index.js b/src/dev/i18n/extractors/index.js new file mode 100644 index 00000000000000..7362eeb4e70039 --- /dev/null +++ b/src/dev/i18n/extractors/index.js @@ -0,0 +1,25 @@ +/* + * 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 { extractCodeMessages } from './code'; +export { extractHandlebarsMessages } from './handlebars'; +export { extractHtmlMessages } from './html'; +export { extractI18nCallMessages } from './i18n_call'; +export { extractPugMessages } from './pug'; +export { extractFormattedMessages, extractIntlMessages } from './react'; diff --git a/src/dev/i18n/extract_pug_messages.js b/src/dev/i18n/extractors/pug.js similarity index 91% rename from src/dev/i18n/extract_pug_messages.js rename to src/dev/i18n/extractors/pug.js index 8451c0b11db240..59851d19e88ab6 100644 --- a/src/dev/i18n/extract_pug_messages.js +++ b/src/dev/i18n/extractors/pug.js @@ -19,8 +19,8 @@ import { parse } from '@babel/parser'; -import { extractI18nCallMessages } from './extract_i18n_call_messages'; -import { isI18nTranslateFunction, traverseNodes } from './utils'; +import { extractI18nCallMessages } from './i18n_call'; +import { isI18nTranslateFunction, traverseNodes } from '../utils'; /** * Matches `i18n(...)` in `#{i18n('id', { defaultMessage: 'Message text' })}` diff --git a/src/dev/i18n/extract_pug_messages.test.js b/src/dev/i18n/extractors/pug.test.js similarity index 94% rename from src/dev/i18n/extract_pug_messages.test.js rename to src/dev/i18n/extractors/pug.test.js index 0f72c13a6a339f..7f901d1d992dbc 100644 --- a/src/dev/i18n/extract_pug_messages.test.js +++ b/src/dev/i18n/extractors/pug.test.js @@ -17,9 +17,9 @@ * under the License. */ -import { extractPugMessages } from './extract_pug_messages'; +import { extractPugMessages } from './pug'; -describe('extractPugMessages', () => { +describe('dev/i18n/extractors/pug', () => { test('extracts messages from pug template', () => { const source = Buffer.from(`\ #{i18n('message-id', { defaultMessage: 'Default message', context: 'Message context' })} diff --git a/src/dev/i18n/extract_react_messages.js b/src/dev/i18n/extractors/react.js similarity index 78% rename from src/dev/i18n/extract_react_messages.js rename to src/dev/i18n/extractors/react.js index 3c6f9c4ecb5a0a..074af4a76d5b47 100644 --- a/src/dev/i18n/extract_react_messages.js +++ b/src/dev/i18n/extractors/react.js @@ -19,12 +19,13 @@ import { isJSXIdentifier, isObjectExpression, isStringLiteral } from '@babel/types'; -import { isPropertyWithKey, formatJSString, formatHTMLString } from './utils'; -import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from './constants'; +import { isPropertyWithKey, formatJSString, formatHTMLString } from '../utils'; +import { DEFAULT_MESSAGE_KEY, CONTEXT_KEY } from '../constants'; +import { createFailError } from '../../run'; function extractMessageId(value) { if (!isStringLiteral(value)) { - throw new Error('Message id should be a string literal.'); + throw createFailError(`Message id should be a string literal.`); } return value.value; @@ -32,7 +33,7 @@ function extractMessageId(value) { function extractMessageValue(value, id) { if (!isStringLiteral(value)) { - throw new Error(`defaultMessage value should be a string literal ("${id}").`); + throw createFailError(`defaultMessage value should be a string literal ("${id}").`); } return value.value; @@ -40,7 +41,7 @@ function extractMessageValue(value, id) { function extractContextValue(value, id) { if (!isStringLiteral(value)) { - throw new Error(`context value should be a string literal ("${id}").`); + throw createFailError(`context value should be a string literal ("${id}").`); } return value.value; @@ -55,7 +56,9 @@ export function extractIntlMessages(node) { const options = node.arguments[0]; if (!isObjectExpression(options)) { - throw new Error('Object with defaultMessage property is not passed to intl.formatMessage().'); + throw createFailError( + `Object with defaultMessage property is not passed to intl.formatMessage().` + ); } const [messageIdProperty, messageProperty, contextProperty] = [ @@ -69,7 +72,7 @@ export function extractIntlMessages(node) { : undefined; if (!messageId) { - throw new Error('Empty "id" value in intl.formatMessage() is not allowed.'); + createFailError(`Empty "id" value in intl.formatMessage() is not allowed.`); } const message = messageProperty @@ -77,7 +80,9 @@ export function extractIntlMessages(node) { : undefined; if (!message) { - throw new Error(`Empty defaultMessage in intl.formatMessage() is not allowed ("${messageId}").`); + throw createFailError( + `Empty defaultMessage in intl.formatMessage() is not allowed ("${messageId}").` + ); } const context = contextProperty @@ -104,7 +109,7 @@ export function extractFormattedMessages(node) { : undefined; if (!messageId) { - throw new Error('Empty "id" value in is not allowed.'); + throw createFailError(`Empty "id" value in is not allowed.`); } const message = messageProperty @@ -112,7 +117,9 @@ export function extractFormattedMessages(node) { : undefined; if (!message) { - throw new Error(`Default message in is not allowed ("${messageId}").`); + throw createFailError( + `Empty default message in is not allowed ("${messageId}").` + ); } const context = contextProperty diff --git a/src/dev/i18n/extract_react_messages.test.js b/src/dev/i18n/extractors/react.test.js similarity index 97% rename from src/dev/i18n/extract_react_messages.test.js rename to src/dev/i18n/extractors/react.test.js index 00233ac1abed22..91e65a0ecc20fd 100644 --- a/src/dev/i18n/extract_react_messages.test.js +++ b/src/dev/i18n/extractors/react.test.js @@ -20,8 +20,8 @@ import { parse } from '@babel/parser'; import { isCallExpression, isJSXOpeningElement, isJSXIdentifier } from '@babel/types'; -import { extractIntlMessages, extractFormattedMessages } from './extract_react_messages'; -import { traverseNodes } from './utils'; +import { extractIntlMessages, extractFormattedMessages } from './react'; +import { traverseNodes } from '../utils'; const intlFormatMessageCallSource = ` const MyComponentContent = ({ intl }) => ( @@ -79,7 +79,7 @@ intl.formatMessage({ `, ]; -describe('dev/i18n/extract_react_messages', () => { +describe('dev/i18n/extractors/react', () => { describe('extractIntlMessages', () => { test('extracts messages from "intl.formatMessage" function call', () => { const ast = parse(intlFormatMessageCallSource, { plugins: ['jsx'] }); diff --git a/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.ts b/src/dev/i18n/index.js similarity index 79% rename from src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.ts rename to src/dev/i18n/index.js index ab9fe4da923cef..703e6ac6828555 100644 --- a/src/core/public/injected_metadata/__fixtures__/frozen_object_mutation.ts +++ b/src/dev/i18n/index.js @@ -17,17 +17,6 @@ * under the License. */ -import { deepFreeze } from '../deep_freeze'; - -const obj = deepFreeze({ - foo: { - bar: { - baz: 1, - }, - }, -}); - -delete obj.foo; -obj.foo = 1; -obj.foo.bar.baz = 2; -obj.foo.bar.box = false; +export { filterPaths, extractMessagesFromPathToMap } from './extract_default_translations'; +export { writeFileAsync } from './utils'; +export { serializeToJson, serializeToJson5 } from './serializers'; diff --git a/src/dev/i18n/serializers/__snapshots__/json.test.js.snap b/src/dev/i18n/serializers/__snapshots__/json.test.js.snap new file mode 100644 index 00000000000000..c35e91e25cbb68 --- /dev/null +++ b/src/dev/i18n/serializers/__snapshots__/json.test.js.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/serializers/json should serialize default messages to JSON 1`] = ` +"{ + \\"formats\\": { + \\"number\\": { + \\"currency\\": { + \\"style\\": \\"currency\\" + }, + \\"percent\\": { + \\"style\\": \\"percent\\" + } + }, + \\"date\\": { + \\"short\\": { + \\"month\\": \\"numeric\\", + \\"day\\": \\"numeric\\", + \\"year\\": \\"2-digit\\" + }, + \\"medium\\": { + \\"month\\": \\"short\\", + \\"day\\": \\"numeric\\", + \\"year\\": \\"numeric\\" + }, + \\"long\\": { + \\"month\\": \\"long\\", + \\"day\\": \\"numeric\\", + \\"year\\": \\"numeric\\" + }, + \\"full\\": { + \\"weekday\\": \\"long\\", + \\"month\\": \\"long\\", + \\"day\\": \\"numeric\\", + \\"year\\": \\"numeric\\" + } + }, + \\"time\\": { + \\"short\\": { + \\"hour\\": \\"numeric\\", + \\"minute\\": \\"numeric\\" + }, + \\"medium\\": { + \\"hour\\": \\"numeric\\", + \\"minute\\": \\"numeric\\", + \\"second\\": \\"numeric\\" + }, + \\"long\\": { + \\"hour\\": \\"numeric\\", + \\"minute\\": \\"numeric\\", + \\"second\\": \\"numeric\\", + \\"timeZoneName\\": \\"short\\" + }, + \\"full\\": { + \\"hour\\": \\"numeric\\", + \\"minute\\": \\"numeric\\", + \\"second\\": \\"numeric\\", + \\"timeZoneName\\": \\"short\\" + } + } + }, + \\"plugin1.message.id-1\\": \\"Message text 1 \\", + \\"plugin2.message.id-2\\": { + \\"text\\": \\"Message text 2\\", + \\"comment\\": \\"Message context\\" + } +}" +`; diff --git a/src/dev/i18n/serializers/__snapshots__/json5.test.js.snap b/src/dev/i18n/serializers/__snapshots__/json5.test.js.snap new file mode 100644 index 00000000000000..2166b32f28fd16 --- /dev/null +++ b/src/dev/i18n/serializers/__snapshots__/json5.test.js.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dev/i18n/serializers/json5 should serialize default messages to JSON5 1`] = ` +"{ + formats: { + number: { + currency: { + style: 'currency', + }, + percent: { + style: 'percent', + }, + }, + date: { + short: { + month: 'numeric', + day: 'numeric', + year: '2-digit', + }, + medium: { + month: 'short', + day: 'numeric', + year: 'numeric', + }, + long: { + month: 'long', + day: 'numeric', + year: 'numeric', + }, + full: { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }, + }, + time: { + short: { + hour: 'numeric', + minute: 'numeric', + }, + medium: { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }, + long: { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short', + }, + full: { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short', + }, + }, + }, + 'plugin1.message.id-1': 'Message text 1', + 'plugin2.message.id-2': 'Message text 2', // Message context +} +" +`; diff --git a/src/dev/i18n/serializers/index.js b/src/dev/i18n/serializers/index.js new file mode 100644 index 00000000000000..3c10d7754563d3 --- /dev/null +++ b/src/dev/i18n/serializers/index.js @@ -0,0 +1,21 @@ +/* + * 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 { serializeToJson } from './json'; +export { serializeToJson5 } from './json5'; diff --git a/src/core_plugins/kibana/public/kibana_root_controller.js b/src/dev/i18n/serializers/json.js similarity index 64% rename from src/core_plugins/kibana/public/kibana_root_controller.js rename to src/dev/i18n/serializers/json.js index 830cd0bd16c7b9..8e615af1e81d32 100644 --- a/src/core_plugins/kibana/public/kibana_root_controller.js +++ b/src/dev/i18n/serializers/json.js @@ -17,18 +17,18 @@ * under the License. */ -import moment from 'moment-timezone'; +import { i18n } from '@kbn/i18n'; -export function KibanaRootController($scope, courier, config) { - config.watch('dateFormat:tz', setDefaultTimezone, $scope); - config.watch('dateFormat:dow', setStartDayOfWeek, $scope); +export function serializeToJson(defaultMessages) { + const resultJsonObject = { formats: i18n.formats }; - function setDefaultTimezone(tz) { - moment.tz.setDefault(tz); + for (const [mapKey, mapValue] of defaultMessages) { + if (mapValue.context) { + resultJsonObject[mapKey] = { text: mapValue.message, comment: mapValue.context }; + } else { + resultJsonObject[mapKey] = mapValue.message; + } } - function setStartDayOfWeek(day) { - const dow = moment.weekdays().indexOf(day); - moment.updateLocale(moment.locale(), { week: { dow } }); - } + return JSON.stringify(resultJsonObject, undefined, 2); } diff --git a/src/ui/public/agg_response/tabify/__tests__/_table_group.js b/src/dev/i18n/serializers/json.test.js similarity index 64% rename from src/ui/public/agg_response/tabify/__tests__/_table_group.js rename to src/dev/i18n/serializers/json.test.js index 0051e46247ea24..9486a999fe7db2 100644 --- a/src/ui/public/agg_response/tabify/__tests__/_table_group.js +++ b/src/dev/i18n/serializers/json.test.js @@ -17,16 +17,21 @@ * under the License. */ -import expect from 'expect.js'; -import { TabifyTableGroup } from '../_table_group'; +import { serializeToJson } from './json'; -describe('Table Group class', function () { +describe('dev/i18n/serializers/json', () => { + test('should serialize default messages to JSON', () => { + const messages = new Map([ + ['plugin1.message.id-1', { message: 'Message text 1 ' }], + [ + 'plugin2.message.id-2', + { + message: 'Message text 2', + context: 'Message context', + }, + ], + ]); - it('exposes tables array and empty aggConfig, key and title', function () { - const tableGroup = new TabifyTableGroup(); - expect(tableGroup.tables).to.be.an('array'); - expect(tableGroup.aggConfig).to.be(null); - expect(tableGroup.key).to.be(null); - expect(tableGroup.title).to.be(null); + expect(serializeToJson(messages)).toMatchSnapshot(); }); }); diff --git a/src/dev/i18n/serializers/json5.js b/src/dev/i18n/serializers/json5.js new file mode 100644 index 00000000000000..0156053d5f43b5 --- /dev/null +++ b/src/dev/i18n/serializers/json5.js @@ -0,0 +1,48 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import JSON5 from 'json5'; +import { i18n } from '@kbn/i18n'; + +const ESCAPE_SINGLE_QUOTE_REGEX = /\\([\s\S])|(')/g; + +export function serializeToJson5(defaultMessages) { + // .slice(0, -1): remove closing curly brace from json to append messages + let jsonBuffer = Buffer.from( + JSON5.stringify({ formats: i18n.formats }, { quote: `'`, space: 2 }).slice(0, -1) + ); + + for (const [mapKey, mapValue] of defaultMessages) { + const formattedMessage = mapValue.message.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2'); + const formattedContext = mapValue.context + ? mapValue.context.replace(ESCAPE_SINGLE_QUOTE_REGEX, '\\$1$2') + : ''; + + jsonBuffer = Buffer.concat([ + jsonBuffer, + Buffer.from(` '${mapKey}': '${formattedMessage}',`), + Buffer.from(formattedContext ? ` // ${formattedContext}\n` : '\n'), + ]); + } + + // append previously removed closing curly brace + jsonBuffer = Buffer.concat([jsonBuffer, Buffer.from('}\n')]); + + return jsonBuffer; +} diff --git a/src/dev/run_extract_default_translations.js b/src/dev/i18n/serializers/json5.test.js similarity index 61% rename from src/dev/run_extract_default_translations.js rename to src/dev/i18n/serializers/json5.test.js index 9d5f0011d6f387..90be880bd32a38 100644 --- a/src/dev/run_extract_default_translations.js +++ b/src/dev/i18n/serializers/json5.test.js @@ -17,13 +17,26 @@ * under the License. */ -import { run } from './run'; -import { extractDefaultTranslations } from './i18n/extract_default_translations'; +import { serializeToJson5 } from './json5'; -run(async ({ flags: { path, output, 'output-format': outputFormat } }) => { - await extractDefaultTranslations({ - paths: Array.isArray(path) ? path : [path || './'], - output, - outputFormat, +describe('dev/i18n/serializers/json5', () => { + test('should serialize default messages to JSON5', () => { + const messages = new Map([ + [ + 'plugin1.message.id-1', + { + message: 'Message text 1', + }, + ], + [ + 'plugin2.message.id-2', + { + message: 'Message text 2', + context: 'Message context', + }, + ], + ]); + + expect(serializeToJson5(messages).toString()).toMatchSnapshot(); }); }); diff --git a/src/dev/i18n/utils.js b/src/dev/i18n/utils.js index af01c4721f14ad..658c1cbe671774 100644 --- a/src/dev/i18n/utils.js +++ b/src/dev/i18n/utils.js @@ -30,7 +30,6 @@ import { promisify } from 'util'; const ESCAPE_LINE_BREAK_REGEX = /(? { test('should remove escaped linebreak', () => { expect(formatJSString('Test\\\n str\\\ning')).toEqual('Test string'); }); - - test('should escape linebreaks', () => { + test('should not escape linebreaks', () => { expect( - formatJSString(`Text with - - -line-breaks and \n\n - \n\n - `) + formatJSString(`Text \n with + line-breaks +`) ).toMatchSnapshot(); }); - test('should detect i18n translate function call', () => { let source = i18nTranslateSources[0]; let expressionStatementNode = [...traverseNodes(parse(source).program.body)].find(node => diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 99b22b14c2d711..4bb11a6d437856 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -96,4 +96,6 @@ export default { 'default', '/src/dev/jest/junit_reporter.js', ], + // TODO: prevent tests from making web requests that rely on this setting, see https://github.com/facebook/jest/pull/6792 + testURL: 'about:blank', }; diff --git a/src/dev/jest/ts_transform.ts b/src/dev/jest/ts_transform.ts index 60f0b11ec94a41..ed366bcd091a0b 100644 --- a/src/dev/jest/ts_transform.ts +++ b/src/dev/jest/ts_transform.ts @@ -17,8 +17,7 @@ * under the License. */ -import { getCacheKey, install, process } from 'ts-jest'; -import { JestConfig, TransformOptions } from 'ts-jest/dist/jest-types'; +import TsJest from 'ts-jest'; import { getTsProjectForAbsolutePath } from '../typescript'; @@ -26,11 +25,11 @@ const DEFAULT_TS_CONFIG_PATH = require.resolve('../../../tsconfig.json'); const DEFAULT_BROWSER_TS_CONFIG_PATH = require.resolve('../../../tsconfig.browser.json'); function extendJestConfigJSON(jestConfigJSON: string, filePath: string) { - const jestConfig = JSON.parse(jestConfigJSON) as JestConfig; + const jestConfig = JSON.parse(jestConfigJSON) as jest.ProjectConfig; return JSON.stringify(extendJestConfig(jestConfig, filePath)); } -function extendJestConfig(jestConfig: JestConfig, filePath: string) { +function extendJestConfig(jestConfig: jest.ProjectConfig, filePath: string) { let tsConfigFile = getTsProjectForAbsolutePath(filePath).tsConfigPath; // swap ts config file for jest tests @@ -51,25 +50,25 @@ function extendJestConfig(jestConfig: JestConfig, filePath: string) { } module.exports = { + canInstrument: true, + process( src: string, - filePath: string, - jestConfig: JestConfig, - transformOptions: TransformOptions + filePath: jest.Path, + jestConfig: jest.ProjectConfig, + transformOptions: jest.TransformOptions ) { const extendedConfig = extendJestConfig(jestConfig, filePath); - return process(src, filePath, extendedConfig, transformOptions); + return TsJest.process(src, filePath, extendedConfig, transformOptions); }, getCacheKey( src: string, filePath: string, jestConfigJSON: string, - transformOptions: TransformOptions + transformOptions: jest.TransformOptions ) { const extendedConfigJSON = extendJestConfigJSON(jestConfigJSON, filePath); - return getCacheKey(src, filePath, extendedConfigJSON, transformOptions); + return TsJest.getCacheKey!(src, filePath, extendedConfigJSON, transformOptions); }, - - install, }; diff --git a/src/dev/mocha/__tests__/junit_report_generation.js b/src/dev/mocha/__tests__/junit_report_generation.js index 5d1c56f31c5998..7142a14b662e5e 100644 --- a/src/dev/mocha/__tests__/junit_report_generation.js +++ b/src/dev/mocha/__tests__/junit_report_generation.js @@ -93,7 +93,8 @@ describe('dev/mocha/junit report generation', () => { classname: sharedClassname, name: 'SUITE works', time: testPass.$.time, - } + }, + 'system-out': testPass['system-out'] }); expect(testFail.$.time).to.match(DURATION_REGEX); @@ -104,6 +105,7 @@ describe('dev/mocha/junit report generation', () => { name: 'SUITE fails', time: testFail.$.time, }, + 'system-out': testFail['system-out'], failure: [ testFail.failure[0] ] @@ -118,6 +120,7 @@ describe('dev/mocha/junit report generation', () => { name: 'SUITE SUB_SUITE "before each" hook: fail hook for "never runs"', time: beforeEachFail.$.time, }, + 'system-out': testFail['system-out'], failure: [ beforeEachFail.failure[0] ] @@ -128,6 +131,7 @@ describe('dev/mocha/junit report generation', () => { classname: sharedClassname, name: 'SUITE SUB_SUITE never runs', }, + 'system-out': testFail['system-out'], skipped: [''] }); }); diff --git a/src/dev/mocha/junit_report_generation.js b/src/dev/mocha/junit_report_generation.js index 5572d4287a241f..f3bc741f8f2b22 100644 --- a/src/dev/mocha/junit_report_generation.js +++ b/src/dev/mocha/junit_report_generation.js @@ -23,6 +23,27 @@ import { inspect } from 'util'; import mkdirp from 'mkdirp'; import xmlBuilder from 'xmlbuilder'; +import stripAnsi from 'strip-ansi'; +import regenerate from 'regenerate'; + +import { getSnapshotOfRunnableLogs } from './log_cache'; + +// create a regular expression using regenerate() that selects any character that is explicitly allowed by https://www.w3.org/TR/xml/#NT-Char +const validXmlCharsRE = new RegExp( + `(?:${ + regenerate() + .add(0x9, 0xA, 0xD) + .addRange(0x20, 0xD7FF) + .addRange(0xE000, 0xFFFD) + .addRange(0x10000, 0x10FFFF) + .toString() + })*`, + 'g' +); + +function escapeCdata(string) { + return stripAnsi(string).match(validXmlCharsRE).join(''); +} export function setupJUnitReportGeneration(runner, options = {}) { const { @@ -120,9 +141,12 @@ export function setupJUnitReportGeneration(runner, options = {}) { [...results, ...skippedResults].forEach(result => { const el = addTestcaseEl(result.node); + el.ele('system-out').dat( + escapeCdata(getSnapshotOfRunnableLogs(result.node) || '') + ); if (result.failed) { - el.ele('failure').dat(inspect(result.error)); + el.ele('failure').dat(escapeCdata(inspect(result.error))); return; } diff --git a/src/dev/mocha/log_cache.js b/src/dev/mocha/log_cache.js new file mode 100644 index 00000000000000..e879beffbd0c05 --- /dev/null +++ b/src/dev/mocha/log_cache.js @@ -0,0 +1,62 @@ +/* + * 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. + */ + +const cachedSuiteLogs = new WeakMap(); +const cachesRunnableLogs = new WeakMap(); + +/** + * Add a chunk of log output to the cached + * output for a suite + * @param {Mocha.Suite} suite + * @param {string} chunk + */ +export function recordLog(suite, chunk) { + const cache = cachedSuiteLogs.get(suite) || ''; + cachedSuiteLogs.set(suite, cache + chunk); +} + +/** + * Recursively walk up from a runnable to collect + * the cached log for its suite and all its parents + * @param {Mocha.Suite} suite + */ +function getCurrentCachedSuiteLogs(suite) { + const history = suite.parent ? getCurrentCachedSuiteLogs(suite.parent) : ''; + const ownLogs = cachedSuiteLogs.get(suite) || ''; + return history + ownLogs; +} + +/** + * Snapshot the logs from this runnable's suite at this point, + * as the suite logs will get updated to include output from + * subsequent runnables + * @param {Mocha.Runnable} runnable + */ +export function snapshotLogsForRunnable(runnable) { + cachesRunnableLogs.set(runnable, getCurrentCachedSuiteLogs(runnable.parent)); +} + +/** + * Get the suite logs as they were when the logs for this runnable + * were snapshotted + * @param {Mocha.Runnable} runnable + */ +export function getSnapshotOfRunnableLogs(runnable) { + return cachesRunnableLogs.get(runnable); +} diff --git a/src/dev/run/index.js b/src/dev/run/index.js index 1eef88d60b0a52..b176ac365fcf44 100644 --- a/src/dev/run/index.js +++ b/src/dev/run/index.js @@ -18,4 +18,4 @@ */ export { run } from './run'; -export { createFailError, combineErrors } from './fail'; +export { createFailError, combineErrors, isFailError } from './fail'; diff --git a/src/dev/run_i18n_check.js b/src/dev/run_i18n_check.js new file mode 100644 index 00000000000000..02d70622b54cd0 --- /dev/null +++ b/src/dev/run_i18n_check.js @@ -0,0 +1,62 @@ +/* + * 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 chalk from 'chalk'; +import Listr from 'listr'; +import { resolve } from 'path'; + +import { run, createFailError } from './run'; +import { + filterPaths, + extractMessagesFromPathToMap, + writeFileAsync, + serializeToJson, + serializeToJson5, +} from './i18n/'; + +run(async ({ flags: { path, output, 'output-format': outputFormat } }) => { + const paths = Array.isArray(path) ? path : [path || './']; + const filteredPaths = filterPaths(paths); + + if (filteredPaths.length === 0) { + throw createFailError( + `${chalk.white.bgRed(' I18N ERROR ')} \ +None of input paths is available for extraction or validation. See .i18nrc.json.` + ); + } + + const list = new Listr( + filteredPaths.map(filteredPath => ({ + task: messages => extractMessagesFromPathToMap(filteredPath, messages), + title: filteredPath, + })) + ); + + // messages shouldn't be extracted to a file if output is not supplied + const messages = await list.run(new Map()); + if (!output || !messages.size) { + return; + } + + const sortedMessages = [...messages].sort(([key1], [key2]) => key1.localeCompare(key2)); + await writeFileAsync( + resolve(output, 'en.json'), + outputFormat === 'json5' ? serializeToJson5(sortedMessages) : serializeToJson(sortedMessages) + ); +}); diff --git a/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js b/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js index 6acfb728161a40..3ff674c89682d9 100644 --- a/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js +++ b/src/functional_test_runner/__tests__/fixtures/failure_hooks/config.js @@ -42,6 +42,9 @@ export default function () { log.info('testHookFailureAfterDelay %s %s', err.message, test.fullTitle()); }); } + }, + mochaReporter: { + captureLogOutput: false } }; } diff --git a/src/functional_test_runner/lib/config/schema.js b/src/functional_test_runner/lib/config/schema.js index ec0aeaf2b5b768..faf7de7fea9403 100644 --- a/src/functional_test_runner/lib/config/schema.js +++ b/src/functional_test_runner/lib/config/schema.js @@ -96,6 +96,10 @@ export const schema = Joi.object().keys({ rootDirectory: Joi.string(), }).default(), + mochaReporter: Joi.object().keys({ + captureLogOutput: Joi.boolean().default(!!process.env.CI), + }).default(), + users: Joi.object().pattern( ID_PATTERN, Joi.object().keys({ diff --git a/src/functional_test_runner/lib/mocha/reporter/reporter.js b/src/functional_test_runner/lib/mocha/reporter/reporter.js index 96061a4b384658..39c69f914e43f0 100644 --- a/src/functional_test_runner/lib/mocha/reporter/reporter.js +++ b/src/functional_test_runner/lib/mocha/reporter/reporter.js @@ -20,16 +20,19 @@ import { format } from 'util'; import Mocha from 'mocha'; +import { ToolingLogTextWriter } from '@kbn/dev-utils'; import { setupJUnitReportGeneration } from '../../../../dev'; import * as colors from './colors'; import * as symbols from './symbols'; import { ms } from './ms'; import { writeEpilogue } from './write_epilogue'; +import { recordLog, snapshotLogsForRunnable } from '../../../../dev/mocha/log_cache'; export function MochaReporterProvider({ getService }) { const log = getService('log'); const config = getService('config'); + let originalLogWriters; return class MochaReporter extends Mocha.reporters.Base { constructor(runner, options) { @@ -55,11 +58,40 @@ export function MochaReporterProvider({ getService }) { } onStart = () => { + if (config.get('mochaReporter.captureLogOutput')) { + log.warning('debug logs are being captured, only error logs will be written to the console'); + originalLogWriters = log.getWriters(); + log.setWriters([ + new ToolingLogTextWriter({ + level: 'error', + writeTo: process.stdout + }), + new ToolingLogTextWriter({ + level: 'debug', + writeTo: { + write: (chunk) => { + // if the current runnable is a beforeEach hook then + // `runner.suite` is set to the suite that defined the + // hook, rather than the suite executing, so instead we + // grab the suite from the test, but that's only available + // when we are doing something test specific, so for global + // hooks we fallback to `runner.suite` + const currentSuite = this.runner.test + ? this.runner.test.parent + : this.runner.suite; + + recordLog(currentSuite, chunk); + } + } + }) + ]); + } + log.write(''); } onHookStart = hook => { - log.write('-> ' + colors.suite(hook.title)); + log.write(`-> ${colors.suite(hook.title)}`); log.indent(2); } @@ -76,7 +108,7 @@ export function MochaReporterProvider({ getService }) { } onSuiteEnd = () => { - if (log.indent(-2) === '') { + if (log.indent(-2) === 0) { log.write(); } } @@ -86,7 +118,8 @@ export function MochaReporterProvider({ getService }) { log.indent(2); } - onTestEnd = () => { + onTestEnd = (test) => { + snapshotLogsForRunnable(test); log.indent(-2); } @@ -96,7 +129,6 @@ export function MochaReporterProvider({ getService }) { } onPass = test => { - let time = ''; if (test.speed !== 'fast') { time = colors.speed(test.speed, ` (${ms(test.duration)})`); @@ -106,7 +138,7 @@ export function MochaReporterProvider({ getService }) { log.write(`- ${pass} ${time}`); } - onFail = test => { + onFail = runnable => { // NOTE: this is super gross // // - I started by trying to extract the Base.list() logic from mocha @@ -118,14 +150,13 @@ export function MochaReporterProvider({ getService }) { const realLog = console.log; console.log = (...args) => output += `${format(...args)}\n`; try { - Mocha.reporters.Base.list([test]); + Mocha.reporters.Base.list([runnable]); } finally { console.log = realLog; } log.write( - `- ${symbols.err} ` + - colors.fail(`fail: "${test.fullTitle()}"`) + + `- ${colors.fail(`${symbols.err} fail: "${runnable.fullTitle()}"`)}` + '\n' + output .split('\n') @@ -136,9 +167,17 @@ export function MochaReporterProvider({ getService }) { .map(line => ` ${line}`) .join('\n') ); + + // failed hooks trigger the `onFail(runnable)` callback, so we snapshot the logs for + // them here. Tests will re-capture the snapshot in `onTestEnd()` + snapshotLogsForRunnable(runnable); } onEnd = () => { + if (originalLogWriters) { + log.setWriters(originalLogWriters); + } + writeEpilogue(log, this.stats); } }; diff --git a/src/functional_test_runner/lib/providers/async_instance.js b/src/functional_test_runner/lib/providers/async_instance.js index f6118656ae0c42..aead3291efc914 100644 --- a/src/functional_test_runner/lib/providers/async_instance.js +++ b/src/functional_test_runner/lib/providers/async_instance.js @@ -18,95 +18,102 @@ */ const createdInstanceProxies = new WeakSet(); +const INITIALIZING = Symbol('async instance initializing'); export const isAsyncInstance = val =>( createdInstanceProxies.has(val) ); export const createAsyncInstance = (type, name, promiseForValue) => { - let finalValue; + let instance = INITIALIZING; - const initPromise = promiseForValue.then(v => finalValue = v); + const initPromise = promiseForValue.then(v => instance = v); const initFn = () => initPromise; const assertReady = desc => { - if (!finalValue) { + if (instance === INITIALIZING) { throw new Error(` ${type} \`${desc}\` is loaded asynchronously but isn't available yet. Either await the promise returned from ${name}.init(), or move this access into a test hook like \`before()\` or \`beforeEach()\`. `); } + + if (typeof instance !== 'object') { + throw new TypeError(` + ${type} \`${desc}\` is not supported because ${name} is ${typeof instance} + `); + } }; const proxy = new Proxy({}, { apply(target, context, args) { assertReady(`${name}()`); - return Reflect.apply(finalValue, context, args); + return Reflect.apply(instance, context, args); }, construct(target, args, newTarget) { assertReady(`new ${name}()`); - return Reflect.construct(finalValue, args, newTarget); + return Reflect.construct(instance, args, newTarget); }, defineProperty(target, prop, descriptor) { assertReady(`${name}.${prop}`); - return Reflect.defineProperty(finalValue, prop, descriptor); + return Reflect.defineProperty(instance, prop, descriptor); }, deleteProperty(target, prop) { assertReady(`${name}.${prop}`); - return Reflect.deleteProperty(finalValue, prop); + return Reflect.deleteProperty(instance, prop); }, get(target, prop, receiver) { if (prop === 'init') return initFn; assertReady(`${name}.${prop}`); - return Reflect.get(finalValue, prop, receiver); + return Reflect.get(instance, prop, receiver); }, getOwnPropertyDescriptor(target, prop) { assertReady(`${name}.${prop}`); - return Reflect.getOwnPropertyDescriptor(finalValue, prop); + return Reflect.getOwnPropertyDescriptor(instance, prop); }, getPrototypeOf() { assertReady(`${name}`); - return Reflect.getPrototypeOf(finalValue); + return Reflect.getPrototypeOf(instance); }, has(target, prop) { if (prop === 'init') return true; assertReady(`${name}.${prop}`); - return Reflect.has(finalValue, prop); + return Reflect.has(instance, prop); }, isExtensible() { assertReady(`${name}`); - return Reflect.isExtensible(finalValue); + return Reflect.isExtensible(instance); }, ownKeys() { assertReady(`${name}`); - return Reflect.ownKeys(finalValue); + return Reflect.ownKeys(instance); }, preventExtensions() { assertReady(`${name}`); - return Reflect.preventExtensions(finalValue); + return Reflect.preventExtensions(instance); }, set(target, prop, value, receiver) { assertReady(`${name}.${prop}`); - return Reflect.set(finalValue, prop, value, receiver); + return Reflect.set(instance, prop, value, receiver); }, setPrototypeOf(target, prototype) { assertReady(`${name}`); - return Reflect.setPrototypeOf(finalValue, prototype); + return Reflect.setPrototypeOf(instance, prototype); } }); diff --git a/src/server/config/__tests__/deprecation_warnings.js b/src/server/config/__tests__/deprecation_warnings.js index 4e90708456b253..9935a2e4bddbff 100644 --- a/src/server/config/__tests__/deprecation_warnings.js +++ b/src/server/config/__tests__/deprecation_warnings.js @@ -40,7 +40,8 @@ describe('config/deprecation warnings mixin', function () { env: { CREATE_SERVER_OPTS: JSON.stringify({ logging: { - quiet: false + quiet: false, + silent: false }, uiSettings: { enabled: true diff --git a/src/server/config/__tests__/fixtures/run_kbn_server_startup.js b/src/server/config/__tests__/fixtures/run_kbn_server_startup.js index 46eb6b4661f49f..d6622cf69ddb00 100644 --- a/src/server/config/__tests__/fixtures/run_kbn_server_startup.js +++ b/src/server/config/__tests__/fixtures/run_kbn_server_startup.js @@ -17,18 +17,18 @@ * under the License. */ -import { createServer } from '../../../../test_utils/kbn_server'; +import { createRoot } from '../../../../test_utils/kbn_server'; (async function run() { - const server = createServer(JSON.parse(process.env.CREATE_SERVER_OPTS)); + const root = createRoot(JSON.parse(process.env.CREATE_SERVER_OPTS)); // We just need the server to run through startup so that it will // log the deprecation messages. Once it has started up we close it // to allow the process to exit naturally try { - await server.ready(); + await root.start(); } finally { - await server.close(); + await root.shutdown(); } }()); diff --git a/src/server/config/schema.js b/src/server/config/schema.js index bbeb41eae0020f..3648e88fac2e88 100644 --- a/src/server/config/schema.js +++ b/src/server/config/schema.js @@ -186,7 +186,7 @@ export default () => Joi.object({ then: Joi.default(!process.stdout.isTTY), otherwise: Joi.default(true) }), - useUTC: Joi.boolean().default(true), + timezone: Joi.string().allow(false).default('UTC') }).default(), ops: Joi.object({ @@ -255,8 +255,4 @@ export default () => Joi.object({ locale: Joi.string().default('en'), }).default(), - // This is a configuration node that is specifically handled by the config system - // in the new platform, and that the current platform doesn't need to handle at all. - __newPlatform: Joi.any(), - }).default(); diff --git a/src/server/config/transform_deprecations.js b/src/server/config/transform_deprecations.js index 44571cee6c58db..d15e171f031ff0 100644 --- a/src/server/config/transform_deprecations.js +++ b/src/server/config/transform_deprecations.js @@ -17,8 +17,9 @@ * under the License. */ -import _, { partial } from 'lodash'; +import _, { partial, set } from 'lodash'; import { createTransform, Deprecations } from '../../deprecation'; +import { unset } from '../../utils'; const { rename, unused } = Deprecations; @@ -55,6 +56,15 @@ const rewriteBasePath = (settings, log) => { } }; +const loggingTimezone = (settings, log) => { + if (_.has(settings, 'logging.useUTC')) { + const timezone = settings.logging.useUTC ? 'UTC' : false; + set('logging.timezone', timezone); + unset(settings, 'logging.UTC'); + log(`Config key "logging.useUTC" is deprecated. It has been replaced with "logging.timezone"`); + } +}; + const deprecations = [ //server rename('server.ssl.cert', 'server.ssl.certificate'), @@ -68,6 +78,7 @@ const deprecations = [ serverSslEnabled, savedObjectsIndexCheckTimeout, rewriteBasePath, + loggingTimezone, ]; export const transformDeprecations = createTransform(deprecations); diff --git a/src/server/http/__snapshots__/max_payload_size.test.js.snap b/src/server/http/__snapshots__/max_payload_size.test.js.snap deleted file mode 100644 index 12e9ab278e1fb8..00000000000000 --- a/src/server/http/__snapshots__/max_payload_size.test.js.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`fails with 400 if payload size is larger than default and route config allows 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Payload content length greater than maximum allowed: 200\\"}"`; diff --git a/src/server/http/__snapshots__/xsrf.test.js.snap b/src/server/http/__snapshots__/xsrf.test.js.snap deleted file mode 100644 index 2113d27927dce9..00000000000000 --- a/src/server/http/__snapshots__/xsrf.test.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`xsrf request filter destructiveMethod: DELETE rejects requests without either an xsrf or version header: DELETE reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; - -exports[`xsrf request filter destructiveMethod: POST rejects requests without either an xsrf or version header: POST reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; - -exports[`xsrf request filter destructiveMethod: PUT rejects requests without either an xsrf or version header: PUT reject response 1`] = `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"Request must contain a kbn-xsrf header.\\"}"`; diff --git a/src/server/http/index.js b/src/server/http/index.js index 3b16cec484c305..7012b095a86585 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -30,35 +30,7 @@ export default async function (kbnServer, server, config) { kbnServer.server = new Hapi.Server(); server = kbnServer.server; - // Note that all connection options configured here should be exactly the same - // as in `getServerOptions()` in the new platform (see `src/core/server/http/http_tools`). - // - // The only exception is `tls` property: TLS is entirely handled by the new - // platform and we don't have to duplicate all TLS related settings here, we just need - // to indicate to Hapi connection that TLS is used so that it can use correct protocol - // name in `server.info` and `request.connection.info` that are used throughout Kibana. - // - // Any change SHOULD BE applied in both places. - server.connection({ - host: config.get('server.host'), - port: config.get('server.port'), - tls: config.get('server.ssl.enabled'), - listener: kbnServer.newPlatform.proxyListener, - state: { - strictHeader: false, - }, - routes: { - cors: config.get('server.cors'), - payload: { - maxBytes: config.get('server.maxPayloadBytes'), - }, - validate: { - options: { - abortEarly: false, - }, - }, - }, - }); + server.connection(kbnServer.core.serverOptions); registerHapiPlugins(server); diff --git a/src/server/http/integration_tests/max_payload_size.test.js b/src/server/http/integration_tests/max_payload_size.test.js new file mode 100644 index 00000000000000..3fa7ca721e1efb --- /dev/null +++ b/src/server/http/integration_tests/max_payload_size.test.js @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as kbnTestServer from '../../../test_utils/kbn_server'; + +let root; +beforeAll(async () => { + root = kbnTestServer.createRoot({ server: { maxPayloadBytes: 100 } }); + + await root.start(); + + kbnTestServer.getKbnServer(root).server.route({ + path: '/payload_size_check/test/route', + method: 'POST', + config: { payload: { maxBytes: 200 } }, + handler: (req, reply) => reply(null, req.payload.data.slice(0, 5)), + }); +}, 30000); + +afterAll(async () => await root.shutdown()); + +test('accepts payload with a size larger than default but smaller than route config allows', async () => { + await kbnTestServer.request.post(root, '/payload_size_check/test/route') + .send({ data: Array(150).fill('+').join('') }) + .expect(200, '+++++'); +}); + +test('fails with 400 if payload size is larger than default and route config allows', async () => { + await kbnTestServer.request.post(root, '/payload_size_check/test/route') + .send({ data: Array(250).fill('+').join('') }) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Payload content length greater than maximum allowed: 200' + }); +}); diff --git a/src/server/http/version_check.test.js b/src/server/http/integration_tests/version_check.test.js similarity index 53% rename from src/server/http/version_check.test.js rename to src/server/http/integration_tests/version_check.test.js index e5257f814e8ae9..676391ce3233bf 100644 --- a/src/server/http/version_check.test.js +++ b/src/server/http/integration_tests/version_check.test.js @@ -18,71 +18,48 @@ */ import { resolve } from 'path'; -import * as kbnTestServer from '../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_utils/kbn_server'; -const src = resolve.bind(null, __dirname, '../../../src'); +const src = resolve.bind(null, __dirname, '../../../../src'); const versionHeader = 'kbn-version'; const version = require(src('../package.json')).version; describe('version_check request filter', function () { - async function makeRequest(kbnServer, opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); - } + let root; + beforeAll(async () => { + root = kbnTestServer.createRoot(); - async function makeServer() { - const kbnServer = kbnTestServer.createServer(); + await root.start(); - await kbnServer.ready(); - - kbnServer.server.route({ + kbnTestServer.getKbnServer(root).server.route({ path: '/version_check/test/route', method: 'GET', handler: function (req, reply) { reply(null, 'ok'); } }); + }, 30000); - return kbnServer; - } - - let kbnServer; - beforeEach(async () => kbnServer = await makeServer()); - afterEach(async () => await kbnServer.close()); + afterAll(async () => await root.shutdown()); it('accepts requests with the correct version passed in the version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET', - headers: { - [versionHeader]: version, - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .set(versionHeader, version) + .expect(200, 'ok'); }); it('rejects requests with an incorrect version passed in the version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET', - headers: { - [versionHeader]: `invalid:${version}`, - }, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.payload).toMatch(/"Browser client is out of date/); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .set(versionHeader, `invalid:${version}`) + .expect(400, /"Browser client is out of date/); }); it('accepts requests that do not include a version header', async function () { - const resp = await makeRequest(kbnServer, { - url: '/version_check/test/route', - method: 'GET' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, '/version_check/test/route') + .expect(200, 'ok'); }); }); diff --git a/src/server/http/xsrf.test.js b/src/server/http/integration_tests/xsrf.test.js similarity index 55% rename from src/server/http/xsrf.test.js rename to src/server/http/integration_tests/xsrf.test.js index 2fc6dba4703efc..a8c87653e9b409 100644 --- a/src/server/http/xsrf.test.js +++ b/src/server/http/integration_tests/xsrf.test.js @@ -18,10 +18,10 @@ */ import { resolve } from 'path'; -import * as kbnTestServer from '../../test_utils/kbn_server'; +import * as kbnTestServer from '../../../test_utils/kbn_server'; const destructiveMethods = ['POST', 'PUT', 'DELETE']; -const src = resolve.bind(null, __dirname, '../../../src'); +const src = resolve.bind(null, __dirname, '../../../../src'); const xsrfHeader = 'kbn-xsrf'; const versionHeader = 'kbn-version'; @@ -29,23 +29,18 @@ const testPath = '/xsrf/test/route'; const whitelistedTestPath = '/xsrf/test/route/whitelisted'; const actualVersion = require(src('../package.json')).version; -describe('xsrf request filter', function () { - async function inject(kbnServer, opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); - } - - const makeServer = async function () { - const kbnServer = kbnTestServer.createServer({ +describe('xsrf request filter', () => { + let root; + beforeAll(async () => { + root = kbnTestServer.createRoot({ server: { - xsrf: { - disableProtection: false, - whitelist: [whitelistedTestPath] - } + xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] } } }); - await kbnServer.ready(); + await root.start(); + const kbnServer = kbnTestServer.getKbnServer(root); kbnServer.server.route({ path: testPath, method: 'GET', @@ -81,117 +76,68 @@ describe('xsrf request filter', function () { reply(null, 'ok'); } }); + }, 30000); - return kbnServer; - }; - - let kbnServer; - beforeEach(async () => { - kbnServer = await makeServer(); - }); - - afterEach(async () => { - await kbnServer.close(); - }); + afterAll(async () => await root.shutdown()); describe(`nonDestructiveMethod: GET`, function () { it('accepts requests without a token', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'GET' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, testPath) + .expect(200, 'ok'); }); it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'GET', - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request + .get(root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); }); }); describe(`nonDestructiveMethod: HEAD`, function () { it('accepts requests without a token', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'HEAD' - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toHaveLength(0); + await kbnTestServer.request + .head(root, testPath) + .expect(200, undefined); }); it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: 'HEAD', - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toHaveLength(0); + await kbnTestServer.request + .head(root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, undefined); }); }); for (const method of destructiveMethods) { describe(`destructiveMethod: ${method}`, function () { // eslint-disable-line no-loop-func it('accepts requests with the xsrf header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method, - headers: { - [xsrfHeader]: 'anything', - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); }); // this is still valid for existing csrf protection support // it does not actually do any validation on the version value itself it('accepts requests with the version header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method, - headers: { - [versionHeader]: actualVersion, - }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); }); it('rejects requests without either an xsrf or version header', async function () { - const resp = await inject(kbnServer, { - url: testPath, - method: method - }); - - expect(resp.statusCode).toBe(400); - expect(resp.result).toMatchSnapshot(`${method} reject response`); + await kbnTestServer.request[method.toLowerCase()](root, testPath) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Request must contain a kbn-xsrf header.' + }); }); it('accepts whitelisted requests without either an xsrf or version header', async function () { - const resp = await inject(kbnServer, { - url: whitelistedTestPath, - method: method - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('ok'); + await kbnTestServer.request[method.toLowerCase()](root, whitelistedTestPath) + .expect(200, 'ok'); }); }); } diff --git a/src/server/http/max_payload_size.test.js b/src/server/http/max_payload_size.test.js deleted file mode 100644 index 499ce43b8d09a5..00000000000000 --- a/src/server/http/max_payload_size.test.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as kbnTestServer from '../../test_utils/kbn_server'; - -let kbnServer; -async function makeServer({ maxPayloadBytesDefault, maxPayloadBytesRoute }) { - kbnServer = kbnTestServer.createServer({ - server: { maxPayloadBytes: maxPayloadBytesDefault } - }); - - await kbnServer.ready(); - - kbnServer.server.route({ - path: '/payload_size_check/test/route', - method: 'POST', - config: { payload: { maxBytes: maxPayloadBytesRoute } }, - handler: function (req, reply) { - reply(null, req.payload.data.slice(0, 5)); - } - }); -} - -async function makeRequest(opts) { - return await kbnTestServer.makeRequest(kbnServer, opts); -} - -afterEach(async () => await kbnServer.close()); - -test('accepts payload with a size larger than default but smaller than route config allows', async () => { - await makeServer({ maxPayloadBytesDefault: 100, maxPayloadBytesRoute: 200 }); - - const resp = await makeRequest({ - url: '/payload_size_check/test/route', - method: 'POST', - payload: { data: Array(150).fill('+').join('') }, - }); - - expect(resp.statusCode).toBe(200); - expect(resp.payload).toBe('+++++'); -}); - -test('fails with 400 if payload size is larger than default and route config allows', async () => { - await makeServer({ maxPayloadBytesDefault: 100, maxPayloadBytesRoute: 200 }); - - const resp = await makeRequest({ - url: '/payload_size_check/test/route', - method: 'POST', - payload: { data: Array(250).fill('+').join('') }, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.payload).toMatchSnapshot(); -}); diff --git a/src/server/http/setup_connection.js b/src/server/http/setup_connection.js deleted file mode 100644 index e69de29bb2d1d6..00000000000000 diff --git a/src/server/kbn_server.js b/src/server/kbn_server.js index 7279a8f407b110..4f4334d764ddbf 100644 --- a/src/server/kbn_server.js +++ b/src/server/kbn_server.js @@ -21,6 +21,7 @@ import { constant, once, compact, flatten } from 'lodash'; import { fromNode } from 'bluebird'; import { isWorker } from 'cluster'; import { fromRoot, pkg } from '../utils'; +import { Config } from './config'; import loggingConfiguration from './logging/configuration'; import configSetupMixin from './config/setup'; import httpMixin from './http'; @@ -30,6 +31,7 @@ import { usageMixin } from './usage'; import { statusMixin } from './status'; import pidMixin from './pid'; import { configDeprecationWarningsMixin } from './config/deprecation_warnings'; +import { transformDeprecations } from './config/transform_deprecations'; import configCompleteMixin from './config/complete'; import optimizeMixin from '../optimize'; import * as Plugins from './plugins'; @@ -41,27 +43,26 @@ import { urlShorteningMixin } from './url_shortening'; import { serverExtensionsMixin } from './server_extensions'; import { uiMixin } from '../ui'; import { sassMixin } from './sass'; -import { injectIntoKbnServer as newPlatformMixin } from '../core'; import { i18nMixin } from './i18n'; const rootDir = fromRoot('.'); export default class KbnServer { - constructor(settings) { + constructor(settings, core) { this.name = pkg.name; this.version = pkg.version; this.build = pkg.build || false; this.rootDir = rootDir; this.settings = settings || {}; + this.core = core; + this.ready = constant(this.mixin( Plugins.waitForInitSetupMixin, // sets this.config, reads this.settings configSetupMixin, - newPlatformMixin, - // sets this.server httpMixin, @@ -111,13 +112,6 @@ export default class KbnServer { // notify any deferred setup logic that plugins have initialized Plugins.waitForInitResolveMixin, - - () => { - if (this.config.get('server.autoListen')) { - this.ready = constant(Promise.resolve()); - return this.listen(); - } - } )); this.listen = once(this.listen); @@ -148,14 +142,17 @@ export default class KbnServer { async listen() { await this.ready(); - const { server } = this; - await fromNode(cb => server.start(cb)); - if (isWorker) { // help parent process know when we are ready process.send(['WORKER_LISTENING']); } + const { server, config } = this; + server.log(['listening', 'info'], `Server running at ${server.info.uri}${ + config.get('server.rewriteBasePath') + ? config.get('server.basePath') + : '' + }`); return server; } @@ -171,7 +168,12 @@ export default class KbnServer { return await this.server.inject(opts); } - async applyLoggingConfiguration(config) { + applyLoggingConfiguration(settings) { + const config = new Config( + this.config.getSchema(), + transformDeprecations(settings) + ); + const loggingOptions = loggingConfiguration(config); const subset = { ops: config.get('ops'), diff --git a/src/server/logging/configuration.js b/src/server/logging/configuration.js index beec30b54bccbf..59019ad8731290 100644 --- a/src/server/logging/configuration.js +++ b/src/server/logging/configuration.js @@ -61,7 +61,7 @@ export default function loggingConfiguration(config) { config: { json: config.get('logging.json'), dest: config.get('logging.dest'), - useUTC: config.get('logging.useUTC'), + timezone: config.get('logging.timezone'), // I'm adding the default here because if you add another filter // using the commandline it will remove authorization. I want users diff --git a/src/server/logging/log_format.js b/src/server/logging/log_format.js index 43ffca7fd39c62..994b5af8b89f45 100644 --- a/src/server/logging/log_format.js +++ b/src/server/logging/log_format.js @@ -18,7 +18,7 @@ */ import Stream from 'stream'; -import moment from 'moment'; +import moment from 'moment-timezone'; import { get, _ } from 'lodash'; import numeral from '@elastic/numeral'; import chalk from 'chalk'; @@ -66,10 +66,10 @@ export default class TransformObjStream extends Stream.Transform { } extractAndFormatTimestamp(data, format) { - const { useUTC } = this.config; + const { timezone } = this.config; const date = moment(data['@timestamp']); - if (useUTC) { - date.utc(); + if (timezone) { + date.tz(timezone); } return date.format(format); } diff --git a/src/server/logging/log_format_json.test.js b/src/server/logging/log_format_json.test.js index b9878e63f08983..1632b2b401c8ae 100644 --- a/src/server/logging/log_format_json.test.js +++ b/src/server/logging/log_format_json.test.js @@ -196,10 +196,10 @@ describe('KbnLoggerJsonFormat', () => { }); }); - describe('useUTC', () => { - it('logs in UTC when useUTC is true', async () => { + describe('timezone', () => { + it('logs in UTC', async () => { const format = new KbnLoggerJsonFormat({ - useUTC: true + timezone: 'UTC' }); const result = await createPromiseFromStreams([ @@ -211,10 +211,8 @@ describe('KbnLoggerJsonFormat', () => { expect(timestamp).toBe(moment.utc(time).format()); }); - it('logs in local timezone when useUTC is false', async () => { - const format = new KbnLoggerJsonFormat({ - useUTC: false - }); + it('logs in local timezone timezone is undefined', async () => { + const format = new KbnLoggerJsonFormat({}); const result = await createPromiseFromStreams([ createListStream([makeEvent('log')]), diff --git a/src/server/logging/log_format_string.test.js b/src/server/logging/log_format_string.test.js index ca572f8c03e661..e20b5eb59b76c1 100644 --- a/src/server/logging/log_format_string.test.js +++ b/src/server/logging/log_format_string.test.js @@ -37,9 +37,9 @@ const makeEvent = () => ({ }); describe('KbnLoggerStringFormat', () => { - it('logs in UTC when useUTC is true', async () => { + it('logs in UTC', async () => { const format = new KbnLoggerStringFormat({ - useUTC: true + timezone: 'UTC' }); const result = await createPromiseFromStreams([ @@ -51,10 +51,8 @@ describe('KbnLoggerStringFormat', () => { .toContain(moment.utc(time).format('HH:mm:ss.SSS')); }); - it('logs in local timezone when useUTC is false', async () => { - const format = new KbnLoggerStringFormat({ - useUTC: false - }); + it('logs in local timezone when timezone is undefined', async () => { + const format = new KbnLoggerStringFormat({}); const result = await createPromiseFromStreams([ createListStream([makeEvent()]), diff --git a/src/server/sample_data/data_sets/index.js b/src/server/sample_data/data_sets/index.js index 58f39a7c4bfec3..eccc6365fbbcdb 100644 --- a/src/server/sample_data/data_sets/index.js +++ b/src/server/sample_data/data_sets/index.js @@ -18,3 +18,4 @@ */ export { flightsSpecProvider } from './flights'; +export { logsSpecProvider } from './logs'; diff --git a/src/server/sample_data/data_sets/logs/index.js b/src/server/sample_data/data_sets/logs/index.js new file mode 100644 index 00000000000000..db633af0e1d8c3 --- /dev/null +++ b/src/server/sample_data/data_sets/logs/index.js @@ -0,0 +1,167 @@ +/* + * 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 path from 'path'; +import { savedObjects } from './saved_objects'; + +export function logsSpecProvider() { + return { + id: 'logs', + name: 'Sample web logs', + description: 'Sample data, visualizations, and dashboards for monitoring web logs.', + previewImagePath: '/plugins/kibana/home/sample_data_resources/logs/dashboard.png', + overviewDashboard: 'edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b', + defaultIndex: '90943e30-9a47-11e8-b64d-95841ca0b247', + dataPath: path.join(__dirname, './logs.json.gz'), + fields: { + request: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + geo: { + properties: { + srcdest: { + type: 'keyword' + }, + src: { + type: 'keyword' + }, + dest: { + type: 'keyword' + }, + coordinates: { + type: 'geo_point' + } + } + }, + utc_time: { + type: 'date' + }, + url: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + message: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + host: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + clientip: { + type: 'ip' + }, + response: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + machine: { + properties: { + ram: { + type: 'long' + }, + os: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + } + } + }, + agent: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + bytes: { + type: 'long' + }, + tags: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + }, + referer: { + type: 'keyword' + }, + ip: { + type: 'ip' + }, + timestamp: { + type: 'date' + }, + phpmemory: { + type: 'long' + }, + memory: { + type: 'double' + }, + extension: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256 + } + } + } + }, + timeFields: ['timestamp'], + currentTimeMarker: '2018-08-01T00:00:00', + preserveDayOfWeekTimeOfDay: true, + savedObjects: savedObjects, + }; +} diff --git a/src/server/sample_data/data_sets/logs/logs.json.gz b/src/server/sample_data/data_sets/logs/logs.json.gz new file mode 100644 index 00000000000000..3b17db6168c99c Binary files /dev/null and b/src/server/sample_data/data_sets/logs/logs.json.gz differ diff --git a/src/server/sample_data/data_sets/logs/saved_objects.js b/src/server/sample_data/data_sets/logs/saved_objects.js new file mode 100644 index 00000000000000..5e2954177974a7 --- /dev/null +++ b/src/server/sample_data/data_sets/logs/saved_objects.js @@ -0,0 +1,236 @@ +/* + * 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. + */ + +/* eslint max-len: 0 */ +/* eslint quotes: 0 */ + +export const savedObjects = [ + { + "id": "e1d0f010-9ee7-11e7-8711-e7a007dcef99", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Unique Visitors vs. Average Bytes", + "visState": "{\"title\":\"[Logs] Unique Visitors vs. Average Bytes\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Avg. Bytes\"}},{\"id\":\"ValueAxis-2\",\"name\":\"RightAxis-1\",\"type\":\"value\",\"position\":\"right\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Unique Visitors\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"histogram\",\"mode\":\"stacked\",\"data\":{\"label\":\"Avg. Bytes\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"},{\"show\":true,\"mode\":\"stacked\",\"type\":\"line\",\"drawLinesBetweenPoints\":false,\"showCircles\":true,\"interpolate\":\"linear\",\"data\":{\"id\":\"2\",\"label\":\"Unique Visitors\"},\"valueAxis\":\"ValueAxis-2\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false,\"radiusRatio\":17},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"avg\",\"schema\":\"metric\",\"params\":{\"field\":\"bytes\",\"customLabel\":\"Avg. Bytes\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"clientip\",\"customLabel\":\"Unique Visitors\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"timestamp\",\"interval\":\"auto\",\"time_zone\":\"America/Los_Angeles\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}},{\"id\":\"4\",\"enabled\":true,\"type\":\"count\",\"schema\":\"radius\",\"params\":{}}]}", + "uiStateJSON": "{\"vis\":{\"colors\":{\"Avg. Bytes\":\"#70DBED\",\"Unique Visitors\":\"#0A437C\"}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + }, + { + "id": "06cf9c40-9ee8-11e7-8711-e7a007dcef99", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Unique Visitors by Country", + "visState": "{\"title\":\"[Logs] Unique Visitors by Country\",\"type\":\"region_map\",\"params\":{\"legendPosition\":\"bottomright\",\"addTooltip\":true,\"colorSchema\":\"Reds\",\"selectedLayer\":{\"attribution\":\"

Made with NaturalEarth | Elastic Maps Service

\",\"name\":\"World Countries\",\"weight\":1,\"format\":{\"type\":\"geojson\"},\"url\":\"https://vector.maps.elastic.co/blob/5659313586569216?elastic_tile_service_tos=agree&my_app_version=6.2.3&license=77ab0ecf-a521-499d-bd52-fbd740bb81d0\",\"fields\":[{\"name\":\"iso2\",\"description\":\"Two letter abbreviation\"},{\"name\":\"name\",\"description\":\"Country name\"},{\"name\":\"iso3\",\"description\":\"Three letter abbreviation\"}],\"created_at\":\"2017-04-26T17:12:15.978370\",\"tags\":[],\"id\":5659313586569216,\"layerId\":\"elastic_maps_service.World Countries\"},\"selectedJoinField\":{\"name\":\"iso2\",\"description\":\"Two letter abbreviation\"},\"isDisplayWarning\":false,\"wms\":{\"enabled\":false,\"options\":{\"format\":\"image/png\",\"transparent\":true},\"baseLayersAreLoaded\":{},\"tmsLayers\":[{\"id\":\"road_map\",\"url\":\"https://tiles.maps.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=6.2.3&license=77ab0ecf-a521-499d-bd52-fbd740bb81d0\",\"minZoom\":0,\"maxZoom\":18,\"attribution\":\"

© OpenStreetMap contributors | Elastic Maps Service

\",\"subdomains\":[]}],\"selectedTmsLayer\":{\"id\":\"road_map\",\"url\":\"https://tiles.maps.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana&my_app_version=6.2.3&license=77ab0ecf-a521-499d-bd52-fbd740bb81d0\",\"minZoom\":0,\"maxZoom\":18,\"attribution\":\"

© OpenStreetMap contributors | Elastic Maps Service

\",\"subdomains\":[]}},\"mapZoom\":2,\"mapCenter\":[0,0],\"outlineWeight\":1,\"showAllShapes\":true,\"emsHotLink\":null},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"clientip\",\"customLabel\":\"Unique Visitors\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"geo.src\",\"size\":50,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\"}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + }, + { + "id": "935afa20-e0cd-11e7-9d07-1398ccfcefa3", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Heatmap", + "visState": "{\"title\":\"[Logs] Heatmap\",\"type\":\"heatmap\",\"params\":{\"type\":\"heatmap\",\"addTooltip\":true,\"addLegend\":true,\"enableHover\":true,\"legendPosition\":\"right\",\"times\":[],\"colorsNumber\":10,\"colorSchema\":\"Reds\",\"setColorRange\":false,\"colorsRange\":[],\"invertColors\":false,\"percentageMode\":false,\"valueAxes\":[{\"show\":false,\"id\":\"ValueAxis-1\",\"type\":\"value\",\"scale\":{\"type\":\"linear\",\"defaultYExtents\":false},\"labels\":{\"show\":false,\"rotate\":0,\"color\":\"#555\",\"overwriteColor\":false}}]},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"clientip\"}},{\"id\":\"3\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"group\",\"params\":{\"field\":\"geo.src\",\"size\":5,\"order\":\"desc\",\"orderBy\":\"1\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Country Source\"}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"hour_of_day\",\"size\":25,\"order\":\"asc\",\"orderBy\":\"_key\",\"otherBucket\":false,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"customLabel\":\"Hour of Day\"}}]}", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 4\":\"rgb(255,245,240)\",\"4 - 8\":\"rgb(254,228,216)\",\"8 - 12\":\"rgb(253,202,181)\",\"12 - 16\":\"rgb(252,171,142)\",\"16 - 20\":\"rgb(252,138,106)\",\"20 - 24\":\"rgb(251,106,74)\",\"24 - 28\":\"rgb(241,68,50)\",\"28 - 32\":\"rgb(217,38,35)\",\"32 - 36\":\"rgb(188,20,26)\",\"36 - 40\":\"rgb(152,12,19)\"}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + }, + { + "id": "4eb6e500-e1c7-11e7-b6d5-4dc382ef7f5b", + "type": "visualization", + "updated_at": "2018-08-29T13:23:20.897Z", + "version": 2, + "attributes": { + "title": "[Logs] Host, Visits and Bytes Table", + "visState": "{\"title\":\"[Logs] Host, Visits and Bytes Table\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"table\",\"series\":[{\"id\":\"bd09d600-e5b1-11e7-bfc2-a1f7e71965a1\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1\",\"type\":\"sum\",\"field\":\"bytes\"},{\"sigma\":\"\",\"id\":\"c9514c90-e5b1-11e7-bfc2-a1f7e71965a1\",\"type\":\"sum_bucket\",\"field\":\"bd09d601-e5b1-11e7-bfc2-a1f7e71965a1\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"bytes\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"color_rules\":[{\"id\":\"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1\"}],\"label\":\"Bytes (Total)\"},{\"id\":\"b7672c30-a6df-11e8-8b18-1da1dfc50975\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"b7672c31-a6df-11e8-8b18-1da1dfc50975\",\"type\":\"sum\",\"field\":\"bytes\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"bytes\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"color_rules\":[{\"id\":\"c0c668d0-e5b1-11e7-bfc2-a1f7e71965a1\"}],\"label\":\"Bytes (Last Hour)\"},{\"id\":\"f2c20700-a6df-11e8-8b18-1da1dfc50975\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"f2c20701-a6df-11e8-8b18-1da1dfc50975\",\"type\":\"cardinality\",\"field\":\"ip\"},{\"sigma\":\"\",\"id\":\"f46333e0-a6df-11e8-8b18-1da1dfc50975\",\"type\":\"sum_bucket\",\"field\":\"f2c20701-a6df-11e8-8b18-1da1dfc50975\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"label\":\"Unique Visits (Total)\",\"color_rules\":[{\"value\":1000,\"id\":\"2e963080-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(211,49,21,1)\",\"operator\":\"lt\"},{\"value\":1000,\"id\":\"3d4fb880-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(252,196,0,1)\",\"operator\":\"gte\"},{\"value\":1500,\"id\":\"435f8a20-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(104,188,0,1)\",\"operator\":\"gte\"}],\"offset_time\":\"\",\"value_template\":\"\",\"trend_arrows\":1},{\"id\":\"46fd7fc0-e5b1-11e7-bfc2-a1f7e71965a1\",\"color\":\"#68BC00\",\"split_mode\":\"everything\",\"metrics\":[{\"id\":\"46fd7fc1-e5b1-11e7-bfc2-a1f7e71965a1\",\"type\":\"cardinality\",\"field\":\"ip\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"number\",\"chart_type\":\"line\",\"line_width\":1,\"point_size\":1,\"fill\":0.5,\"stacked\":\"none\",\"label\":\"Unique Visits (Last Hour)\",\"color_rules\":[{\"value\":10,\"id\":\"4e90aeb0-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(211,49,21,1)\",\"operator\":\"lt\"},{\"value\":10,\"id\":\"6d59b1c0-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(252,196,0,1)\",\"operator\":\"gte\"},{\"value\":25,\"id\":\"77578670-a6e0-11e8-8b18-1da1dfc50975\",\"text\":\"rgba(104,188,0,1)\",\"operator\":\"gte\"}],\"offset_time\":\"\",\"value_template\":\"\",\"trend_arrows\":1}],\"time_field\":\"timestamp\",\"index_pattern\":\"kibana_sample_data_logs\",\"interval\":\"1h\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"bar_color_rules\":[{\"id\":\"e9b4e490-e1c6-11e7-b4f6-0f68c45f7387\"}],\"pivot_id\":\"extension.keyword\",\"pivot_label\":\"Type\",\"drilldown_url\":\"\",\"axis_scale\":\"normal\"},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + }, + { + "id": "69a34b00-9ee8-11e7-8711-e7a007dcef99", + "type": "visualization", + "updated_at": "2018-08-29T13:24:46.136Z", + "version": 2, + "attributes": { + "title": "[Logs] Goals", + "visState": "{\"title\":\"[Logs] Goals\",\"type\":\"gauge\",\"params\":{\"type\":\"gauge\",\"addTooltip\":true,\"addLegend\":false,\"gauge\":{\"verticalSplit\":false,\"extendRange\":true,\"percentageMode\":false,\"gaugeType\":\"Arc\",\"gaugeStyle\":\"Full\",\"backStyle\":\"Full\",\"orientation\":\"vertical\",\"colorSchema\":\"Green to Red\",\"gaugeColorMode\":\"Labels\",\"colorsRange\":[{\"from\":0,\"to\":500},{\"from\":500,\"to\":1000},{\"from\":1000,\"to\":1500}],\"invertColors\":true,\"labels\":{\"show\":false,\"color\":\"black\"},\"scale\":{\"show\":true,\"labels\":false,\"color\":\"#333\"},\"type\":\"meter\",\"style\":{\"bgWidth\":0.9,\"width\":0.9,\"mask\":false,\"bgMask\":false,\"maskBars\":50,\"bgFill\":\"#eee\",\"bgColor\":false,\"subText\":\"visitors\",\"fontSize\":60,\"labelColor\":true}},\"isDisplayWarning\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"cardinality\",\"schema\":\"metric\",\"params\":{\"field\":\"clientip\",\"customLabel\":\"Unique Visitors\"}}]}", + "uiStateJSON": "{\"vis\":{\"defaultColors\":{\"0 - 500\":\"rgb(165,0,38)\",\"500 - 1000\":\"rgb(255,255,190)\",\"1000 - 1500\":\"rgb(0,104,55)\"},\"colors\":{\"75 - 100\":\"#629E51\",\"50 - 75\":\"#EAB839\",\"0 - 50\":\"#E24D42\",\"0 - 100\":\"#E24D42\",\"200 - 300\":\"#7EB26D\",\"500 - 1000\":\"#E5AC0E\",\"0 - 500\":\"#E24D42\",\"1000 - 1500\":\"#7EB26D\"},\"legendOpen\":true}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + }, + { + "id": "42b997f0-0c26-11e8-b0ec-3bb475f6b6ff", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] File Type Scatter Plot", + "visState": "{\"title\":\"[Logs] File Type Scatter Plot\",\"type\":\"vega\",\"params\":{\"spec\":\"{\\n $schema: \\\"https://vega.github.io/schema/vega-lite/v2.json\\\"\\n // Use points for drawing to actually create a scatterplot\\n mark: point\\n // Specify where to load data from\\n data: {\\n // By using an object to the url parameter we will\\n // construct an Elasticsearch query\\n url: {\\n // Context == true means filters of the dashboard will be taken into account\\n %context%: true\\n // Specify on which field the time picker should operate\\n %timefield%: timestamp\\n // Specify the index pattern to load data from\\n index: kibana_sample_data_logs\\n // This body will be send to Elasticsearch's _search endpoint\\n // You can use everything the ES Query DSL supports here\\n body: {\\n // Set the size to load 10000 documents\\n size: 10000,\\n // Just ask for the fields we actually need for visualization\\n _source: [\\\"timestamp\\\", \\\"bytes\\\", \\\"extension\\\"]\\n }\\n }\\n // Tell Vega, that the array of data will be inside hits.hits of the response\\n // since the result returned from Elasticsearch fill have a format like:\\n // {\\n // hits: {\\n // total: 42000,\\n // max_score: 2,\\n // hits: [\\n // < our individual documents >\\n // ]\\n // }\\n // }\\n format: { property: \\\"hits.hits\\\" }\\n }\\n // You can do transformation and calculation of the data before drawing it\\n transform: [\\n // Since timestamp is a string value, we need to convert it to a unix timestamp\\n // so that Vega can work on it properly.\\n {\\n // Convert _source.timestamp field to a date\\n calculate: \\\"toDate(datum._source['timestamp'])\\\"\\n // Store the result in a field named \\\"time\\\" in the object\\n as: \\\"time\\\"\\n }\\n ]\\n // Specify what data will be drawn on which axis\\n encoding: {\\n x: {\\n // Draw the time field on the x-axis in temporal mode (i.e. as a time axis)\\n field: time\\n type: temporal\\n // Hide the axis label for the x-axis\\n axis: { title: false }\\n }\\n y: {\\n // Draw the bytes of each document on the y-axis\\n field: _source.bytes\\n // Mark the y-axis as quantitative\\n type: quantitative\\n // Specify the label for this axis\\n axis: { title: \\\"Transferred bytes\\\" }\\n }\\n color: {\\n // Make the color of each point depend on the _source.extension field\\n field: _source.extension\\n // Treat different values as completely unrelated values to each other.\\n // You could switch this to quantitative if you have a numeric field and\\n // want to create a color scale from one color to another depending on that\\n // field's value.\\n type: nominal\\n // Rename the legend title so it won't just state: \\\"_source.extension\\\"\\n legend: { title: 'File type' }\\n }\\n shape: {\\n // Also make the shape of each point dependent on the extension.\\n field: _source.extension\\n type: nominal\\n }\\n }\\n}\"},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + } + } + }, + { + "id": "7cbd2350-2223-11e8-b802-5bcf64c2cfb4", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Source and Destination Sankey Chart", + "visState": "{\"title\":\"[Logs] Source and Destination Sankey Chart\",\"type\":\"vega\",\"params\":{\"spec\":\"{ \\n $schema: https://vega.github.io/schema/vega/v3.0.json\\n data: [\\n\\t{\\n \\t// query ES based on the currently selected time range and filter string\\n \\tname: rawData\\n \\turl: {\\n \\t%context%: true\\n \\t%timefield%: timestamp\\n \\tindex: kibana_sample_data_logs\\n \\tbody: {\\n \\tsize: 0\\n \\taggs: {\\n \\ttable: {\\n \\tcomposite: {\\n \\tsize: 10000\\n \\tsources: [\\n \\t{\\n \\tstk1: {\\n \\tterms: {field: \\\"geo.src\\\"}\\n \\t}\\n \\t}\\n \\t{\\n \\tstk2: {\\n \\tterms: {field: \\\"geo.dest\\\"}\\n \\t}\\n \\t}\\n \\t]\\n \\t}\\n \\t}\\n \\t}\\n \\t}\\n \\t}\\n \\t// From the result, take just the data we are interested in\\n \\tformat: {property: \\\"aggregations.table.buckets\\\"}\\n \\t// Convert key.stk1 -> stk1 for simpler access below\\n \\ttransform: [\\n \\t{type: \\\"formula\\\", expr: \\\"datum.key.stk1\\\", as: \\\"stk1\\\"}\\n \\t{type: \\\"formula\\\", expr: \\\"datum.key.stk2\\\", as: \\\"stk2\\\"}\\n \\t{type: \\\"formula\\\", expr: \\\"datum.doc_count\\\", as: \\\"size\\\"}\\n \\t]\\n\\t}\\n\\t{\\n \\tname: nodes\\n \\tsource: rawData\\n \\ttransform: [\\n \\t// when a country is selected, filter out unrelated data\\n \\t{\\n \\ttype: filter\\n \\texpr: !groupSelector || groupSelector.stk1 == datum.stk1 || groupSelector.stk2 == datum.stk2\\n \\t}\\n \\t// Set new key for later lookups - identifies each node\\n \\t{type: \\\"formula\\\", expr: \\\"datum.stk1+datum.stk2\\\", as: \\\"key\\\"}\\n \\t// instead of each table row, create two new rows,\\n \\t// one for the source (stack=stk1) and one for destination node (stack=stk2).\\n \\t// The country code stored in stk1 and stk2 fields is placed into grpId field.\\n \\t{\\n \\ttype: fold\\n \\tfields: [\\\"stk1\\\", \\\"stk2\\\"]\\n \\tas: [\\\"stack\\\", \\\"grpId\\\"]\\n \\t}\\n \\t// Create a sortkey, different for stk1 and stk2 stacks.\\n \\t{\\n \\ttype: formula\\n \\texpr: datum.stack == 'stk1' ? datum.stk1+datum.stk2 : datum.stk2+datum.stk1\\n \\tas: sortField\\n \\t}\\n \\t// Calculate y0 and y1 positions for stacking nodes one on top of the other,\\n \\t// independently for each stack, and ensuring they are in the proper order,\\n \\t// alphabetical from the top (reversed on the y axis)\\n \\t{\\n \\ttype: stack\\n \\tgroupby: [\\\"stack\\\"]\\n \\tsort: {field: \\\"sortField\\\", order: \\\"descending\\\"}\\n \\tfield: size\\n \\t}\\n \\t// calculate vertical center point for each node, used to draw edges\\n \\t{type: \\\"formula\\\", expr: \\\"(datum.y0+datum.y1)/2\\\", as: \\\"yc\\\"}\\n \\t]\\n\\t}\\n\\t{\\n \\tname: groups\\n \\tsource: nodes\\n \\ttransform: [\\n \\t// combine all nodes into country groups, summing up the doc counts\\n \\t{\\n \\ttype: aggregate\\n \\tgroupby: [\\\"stack\\\", \\\"grpId\\\"]\\n \\tfields: [\\\"size\\\"]\\n \\tops: [\\\"sum\\\"]\\n \\tas: [\\\"total\\\"]\\n \\t}\\n \\t// re-calculate the stacking y0,y1 values\\n \\t{\\n \\ttype: stack\\n \\tgroupby: [\\\"stack\\\"]\\n \\tsort: {field: \\\"grpId\\\", order: \\\"descending\\\"}\\n \\tfield: total\\n \\t}\\n \\t// project y0 and y1 values to screen coordinates\\n \\t// doing it once here instead of doing it several times in marks\\n \\t{type: \\\"formula\\\", expr: \\\"scale('y', datum.y0)\\\", as: \\\"scaledY0\\\"}\\n \\t{type: \\\"formula\\\", expr: \\\"scale('y', datum.y1)\\\", as: \\\"scaledY1\\\"}\\n \\t// boolean flag if the label should be on the right of the stack\\n \\t{type: \\\"formula\\\", expr: \\\"datum.stack == 'stk1'\\\", as: \\\"rightLabel\\\"}\\n \\t// Calculate traffic percentage for this country using \\\"y\\\" scale\\n \\t// domain upper bound, which represents the total traffic\\n \\t{\\n \\ttype: formula\\n \\texpr: datum.total/domain('y')[1]\\n \\tas: percentage\\n \\t}\\n \\t]\\n\\t}\\n\\t{\\n \\t// This is a temp lookup table with all the 'stk2' stack nodes\\n \\tname: destinationNodes\\n \\tsource: nodes\\n \\ttransform: [\\n \\t{type: \\\"filter\\\", expr: \\\"datum.stack == 'stk2'\\\"}\\n \\t]\\n\\t}\\n\\t{\\n \\tname: edges\\n \\tsource: nodes\\n \\ttransform: [\\n \\t// we only want nodes from the left stack\\n \\t{type: \\\"filter\\\", expr: \\\"datum.stack == 'stk1'\\\"}\\n \\t// find corresponding node from the right stack, keep it as \\\"target\\\"\\n \\t{\\n \\ttype: lookup\\n \\tfrom: destinationNodes\\n \\tkey: key\\n \\tfields: [\\\"key\\\"]\\n \\tas: [\\\"target\\\"]\\n \\t}\\n \\t// calculate SVG link path between stk1 and stk2 stacks for the node pair\\n \\t{\\n \\ttype: linkpath\\n \\torient: horizontal\\n \\tshape: diagonal\\n \\tsourceY: {expr: \\\"scale('y', datum.yc)\\\"}\\n \\tsourceX: {expr: \\\"scale('x', 'stk1') + bandwidth('x')\\\"}\\n \\ttargetY: {expr: \\\"scale('y', datum.target.yc)\\\"}\\n \\ttargetX: {expr: \\\"scale('x', 'stk2')\\\"}\\n \\t}\\n \\t// A little trick to calculate the thickness of the line.\\n \\t// The value needs to be the same as the hight of the node, but scaling\\n \\t// size to screen's height gives inversed value because screen's Y\\n \\t// coordinate goes from the top to the bottom, whereas the graph's Y=0\\n \\t// is at the bottom. So subtracting scaled doc count from screen height\\n \\t// (which is the \\\"lower\\\" bound of the \\\"y\\\" scale) gives us the right value\\n \\t{\\n \\ttype: formula\\n \\texpr: range('y')[0]-scale('y', datum.size)\\n \\tas: strokeWidth\\n \\t}\\n \\t// Tooltip needs individual link's percentage of all traffic\\n \\t{\\n \\ttype: formula\\n \\texpr: datum.size/domain('y')[1]\\n \\tas: percentage\\n \\t}\\n \\t]\\n\\t}\\n ]\\n scales: [\\n\\t{\\n \\t// calculates horizontal stack positioning\\n \\tname: x\\n \\ttype: band\\n \\trange: width\\n \\tdomain: [\\\"stk1\\\", \\\"stk2\\\"]\\n \\tpaddingOuter: 0.05\\n \\tpaddingInner: 0.95\\n\\t}\\n\\t{\\n \\t// this scale goes up as high as the highest y1 value of all nodes\\n \\tname: y\\n \\ttype: linear\\n \\trange: height\\n \\tdomain: {data: \\\"nodes\\\", field: \\\"y1\\\"}\\n\\t}\\n\\t{\\n \\t// use rawData to ensure the colors stay the same when clicking.\\n \\tname: color\\n \\ttype: ordinal\\n \\trange: category\\n \\tdomain: {data: \\\"rawData\\\", field: \\\"stk1\\\"}\\n\\t}\\n\\t{\\n \\t// this scale is used to map internal ids (stk1, stk2) to stack names\\n \\tname: stackNames\\n \\ttype: ordinal\\n \\trange: [\\\"Source\\\", \\\"Destination\\\"]\\n \\tdomain: [\\\"stk1\\\", \\\"stk2\\\"]\\n\\t}\\n ]\\n axes: [\\n\\t{\\n \\t// x axis should use custom label formatting to print proper stack names\\n \\torient: bottom\\n \\tscale: x\\n \\tencode: {\\n \\tlabels: {\\n \\tupdate: {\\n \\ttext: {scale: \\\"stackNames\\\", field: \\\"value\\\"}\\n \\t}\\n \\t}\\n \\t}\\n\\t}\\n\\t{orient: \\\"left\\\", scale: \\\"y\\\"}\\n ]\\n marks: [\\n\\t{\\n \\t// draw the connecting line between stacks\\n \\ttype: path\\n \\tname: edgeMark\\n \\tfrom: {data: \\\"edges\\\"}\\n \\t// this prevents some autosizing issues with large strokeWidth for paths\\n \\tclip: true\\n \\tencode: {\\n \\tupdate: {\\n \\t// By default use color of the left node, except when showing traffic\\n \\t// from just one country, in which case use destination color.\\n \\tstroke: [\\n \\t{\\n \\ttest: groupSelector && groupSelector.stack=='stk1'\\n \\tscale: color\\n \\tfield: stk2\\n \\t}\\n \\t{scale: \\\"color\\\", field: \\\"stk1\\\"}\\n \\t]\\n \\tstrokeWidth: {field: \\\"strokeWidth\\\"}\\n \\tpath: {field: \\\"path\\\"}\\n \\t// when showing all traffic, and hovering over a country,\\n \\t// highlight the traffic from that country.\\n \\tstrokeOpacity: {\\n \\tsignal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 0.9 : 0.3\\n \\t}\\n \\t// Ensure that the hover-selected edges show on top\\n \\tzindex: {\\n \\tsignal: !groupSelector && (groupHover.stk1 == datum.stk1 || groupHover.stk2 == datum.stk2) ? 1 : 0\\n \\t}\\n \\t// format tooltip string\\n \\ttooltip: {\\n \\tsignal: datum.stk1 + ' → ' + datum.stk2 + '\\t' + format(datum.size, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'\\n \\t}\\n \\t}\\n \\t// Simple mouseover highlighting of a single line\\n \\thover: {\\n \\tstrokeOpacity: {value: 1}\\n \\t}\\n \\t}\\n\\t}\\n\\t{\\n \\t// draw stack groups (countries)\\n \\ttype: rect\\n \\tname: groupMark\\n \\tfrom: {data: \\\"groups\\\"}\\n \\tencode: {\\n \\tenter: {\\n \\tfill: {scale: \\\"color\\\", field: \\\"grpId\\\"}\\n \\twidth: {scale: \\\"x\\\", band: 1}\\n \\t}\\n \\tupdate: {\\n \\tx: {scale: \\\"x\\\", field: \\\"stack\\\"}\\n \\ty: {field: \\\"scaledY0\\\"}\\n \\ty2: {field: \\\"scaledY1\\\"}\\n \\tfillOpacity: {value: 0.6}\\n \\ttooltip: {\\n \\tsignal: datum.grpId + ' ' + format(datum.total, ',.0f') + ' (' + format(datum.percentage, '.1%') + ')'\\n \\t}\\n \\t}\\n \\thover: {\\n \\tfillOpacity: {value: 1}\\n \\t}\\n \\t}\\n\\t}\\n\\t{\\n \\t// draw country code labels on the inner side of the stack\\n \\ttype: text\\n \\tfrom: {data: \\\"groups\\\"}\\n \\t// don't process events for the labels - otherwise line mouseover is unclean\\n \\tinteractive: false\\n \\tencode: {\\n \\tupdate: {\\n \\t// depending on which stack it is, position x with some padding\\n \\tx: {\\n \\tsignal: scale('x', datum.stack) + (datum.rightLabel ? bandwidth('x') + 8 : -8)\\n \\t}\\n \\t// middle of the group\\n \\tyc: {signal: \\\"(datum.scaledY0 + datum.scaledY1)/2\\\"}\\n \\talign: {signal: \\\"datum.rightLabel ? 'left' : 'right'\\\"}\\n \\tbaseline: {value: \\\"middle\\\"}\\n \\tfontWeight: {value: \\\"bold\\\"}\\n \\t// only show text label if the group's height is large enough\\n \\ttext: {signal: \\\"abs(datum.scaledY0-datum.scaledY1) > 13 ? datum.grpId : ''\\\"}\\n \\t}\\n \\t}\\n\\t}\\n\\t{\\n \\t// Create a \\\"show all\\\" button. Shown only when a country is selected.\\n \\ttype: group\\n \\tdata: [\\n \\t// We need to make the button show only when groupSelector signal is true.\\n \\t// Each mark is drawn as many times as there are elements in the backing data.\\n \\t// Which means that if values list is empty, it will not be drawn.\\n \\t// Here I create a data source with one empty object, and filter that list\\n \\t// based on the signal value. This can only be done in a group.\\n \\t{\\n \\tname: dataForShowAll\\n \\tvalues: [{}]\\n \\ttransform: [{type: \\\"filter\\\", expr: \\\"groupSelector\\\"}]\\n \\t}\\n \\t]\\n \\t// Set button size and positioning\\n \\tencode: {\\n \\tenter: {\\n \\txc: {signal: \\\"width/2\\\"}\\n \\ty: {value: 30}\\n \\twidth: {value: 80}\\n \\theight: {value: 30}\\n \\t}\\n \\t}\\n \\tmarks: [\\n \\t{\\n \\t// This group is shown as a button with rounded corners.\\n \\ttype: group\\n \\t// mark name allows signal capturing\\n \\tname: groupReset\\n \\t// Only shows button if dataForShowAll has values.\\n \\tfrom: {data: \\\"dataForShowAll\\\"}\\n \\tencode: {\\n \\tenter: {\\n \\tcornerRadius: {value: 6}\\n \\tfill: {value: \\\"#f5f5f5\\\"}\\n \\tstroke: {value: \\\"#c1c1c1\\\"}\\n \\tstrokeWidth: {value: 2}\\n \\t// use parent group's size\\n \\theight: {\\n \\tfield: {group: \\\"height\\\"}\\n \\t}\\n \\twidth: {\\n \\tfield: {group: \\\"width\\\"}\\n \\t}\\n \\t}\\n \\tupdate: {\\n \\t// groups are transparent by default\\n \\topacity: {value: 1}\\n \\t}\\n \\thover: {\\n \\topacity: {value: 0.7}\\n \\t}\\n \\t}\\n \\tmarks: [\\n \\t{\\n \\ttype: text\\n \\t// if true, it will prevent clicking on the button when over text.\\n \\tinteractive: false\\n \\tencode: {\\n \\tenter: {\\n \\t// center text in the paren group\\n \\txc: {\\n \\tfield: {group: \\\"width\\\"}\\n \\tmult: 0.5\\n \\t}\\n \\tyc: {\\n \\tfield: {group: \\\"height\\\"}\\n \\tmult: 0.5\\n \\toffset: 2\\n \\t}\\n \\talign: {value: \\\"center\\\"}\\n \\tbaseline: {value: \\\"middle\\\"}\\n \\tfontWeight: {value: \\\"bold\\\"}\\n \\ttext: {value: \\\"Show All\\\"}\\n \\t}\\n \\t}\\n \\t}\\n \\t]\\n \\t}\\n \\t]\\n\\t}\\n ]\\n signals: [\\n\\t{\\n \\t// used to highlight traffic to/from the same country\\n \\tname: groupHover\\n \\tvalue: {}\\n \\ton: [\\n \\t{\\n \\tevents: @groupMark:mouseover\\n \\tupdate: \\\"{stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}\\\"\\n \\t}\\n \\t{events: \\\"mouseout\\\", update: \\\"{}\\\"}\\n \\t]\\n\\t}\\n\\t// used to filter only the data related to the selected country\\n\\t{\\n \\tname: groupSelector\\n \\tvalue: false\\n \\ton: [\\n \\t{\\n \\t// Clicking groupMark sets this signal to the filter values\\n \\tevents: @groupMark:click!\\n \\tupdate: \\\"{stack:datum.stack, stk1:datum.stack=='stk1' && datum.grpId, stk2:datum.stack=='stk2' && datum.grpId}\\\"\\n \\t}\\n \\t{\\n \\t// Clicking \\\"show all\\\" button, or double-clicking anywhere resets it\\n \\tevents: [\\n \\t{type: \\\"click\\\", markname: \\\"groupReset\\\"}\\n \\t{type: \\\"dblclick\\\"}\\n \\t]\\n \\tupdate: \\\"false\\\"\\n \\t}\\n \\t]\\n\\t}\\n ]\\n}\\n\"},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + } + } + }, + { + "id": "314c6f60-2224-11e8-b802-5bcf64c2cfb4", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Response Codes Over Time + Annotations", + "visState": "{\"title\":\"[Logs] Response Codes Over Time + Annotations\",\"type\":\"metrics\",\"params\":{\"id\":\"61ca57f0-469d-11e7-af02-69e470af7417\",\"type\":\"timeseries\",\"series\":[{\"id\":\"61ca57f1-469d-11e7-af02-69e470af7417\",\"color\":\"rgba(115,216,255,1)\",\"split_mode\":\"terms\",\"metrics\":[{\"id\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"type\":\"cardinality\",\"field\":\"ip\"}],\"seperate_axis\":0,\"axis_position\":\"right\",\"formatter\":\"percent\",\"chart_type\":\"line\",\"line_width\":\"2\",\"point_size\":\"0\",\"fill\":\"0.5\",\"stacked\":\"percent\",\"terms_field\":\"response.keyword\",\"terms_order_by\":\"61ca57f2-469d-11e7-af02-69e470af7417\",\"label\":\"Response Code Count\",\"split_color_mode\":\"gradient\"}],\"time_field\":\"timestamp\",\"index_pattern\":\"kibana_sample_data_logs\",\"interval\":\">=4h\",\"axis_position\":\"left\",\"axis_formatter\":\"number\",\"show_legend\":1,\"show_grid\":1,\"annotations\":[{\"fields\":\"geo.src, host\",\"template\":\"Security Error from {{geo.src}} on {{host}}\",\"index_pattern\":\"kibana_sample_data_logs\",\"query_string\":\"tags:error AND tags:security\",\"id\":\"bd7548a0-2223-11e8-832f-d5027f3c8a47\",\"color\":\"rgba(211,49,21,1)\",\"time_field\":\"timestamp\",\"icon\":\"fa-asterisk\",\"ignore_global_filters\":1,\"ignore_panel_filters\":1}],\"legend_position\":\"bottom\",\"axis_scale\":\"normal\",\"drop_last_bucket\":0},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + }, + { + "id": "24a3e970-4257-11e8-b3aa-73fdaf54bfc9", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Input Controls", + "visState": "{\"title\":\"[Logs] Input Controls\",\"type\":\"input_control_vis\",\"params\":{\"controls\":[{\"id\":\"1523980210832\",\"indexPattern\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"fieldName\":\"geo.src\",\"label\":\"Source Country\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":100,\"order\":\"desc\"},\"parent\":\"\"},{\"id\":\"1523980191978\",\"indexPattern\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"fieldName\":\"machine.os.keyword\",\"label\":\"OS\",\"type\":\"list\",\"options\":{\"type\":\"terms\",\"multiselect\":true,\"size\":100,\"order\":\"desc\"},\"parent\":\"1523980210832\"},{\"id\":\"1523980232790\",\"indexPattern\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"fieldName\":\"bytes\",\"label\":\"Bytes\",\"type\":\"range\",\"options\":{\"decimalPlaces\":0,\"step\":1024}}],\"updateFiltersOnChange\":true,\"useTimeFilter\":true,\"pinFilters\":false},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + } + } + }, + { + "id": "14e2e710-4258-11e8-b3aa-73fdaf54bfc9", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Article Tags", + "visState": "{\"title\":\"[Logs] Article Tags\",\"type\":\"pie\",\"params\":{\"type\":\"pie\",\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"isDonut\":true,\"labels\":{\"show\":true,\"values\":true,\"last_level\":true,\"truncate\":100}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"terms\",\"schema\":\"segment\",\"params\":{\"field\":\"machine.os.keyword\",\"otherBucket\":true,\"otherBucketLabel\":\"Other\",\"missingBucket\":false,\"missingBucketLabel\":\"Missing\",\"size\":10,\"order\":\"desc\",\"orderBy\":\"1\"}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"90943e30-9a47-11e8-b64d-95841ca0b247\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + }, + { + "id": "47f2c680-a6e3-11e8-94b4-c30c0228351b", + "type": "visualization", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "[Logs] Markdown Instructions", + "visState": "{\"title\":\"[Logs] Markdown Instructions\",\"type\":\"markdown\",\"params\":{\"fontSize\":12,\"openLinksInNewTab\":true,\"markdown\":\"### Sample Logs Data\\nThis dashboard contains sample data for you to play with. You can view it, search it, and interact with the visualizations. For more information about Kibana, check our [docs](https://www.elastic.co/guide/en/kibana/current/index.html).\"},\"aggs\":[]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + }, + { + "id": "90943e30-9a47-11e8-b64d-95841ca0b247", + "type": "index-pattern", + "updated_at": "2018-08-29T13:22:17.617Z", + "version": 1, + "attributes": { + "title": "kibana_sample_data_logs", + "timeFieldName": "timestamp", + "fields": "[{\"name\":\"message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"message.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"tags.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":1,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.keyword\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"hour_of_day\",\"type\":\"number\",\"count\":0,\"scripted\":true,\"script\":\"doc['timestamp'].value.getHourOfDay()\",\"lang\":\"painless\",\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false}]", + "fieldFormatMap": "{\"hour_of_day\":{}}" + } + }, + { + "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b", + "type": "dashboard", + "updated_at": "2018-08-29T13:26:13.463Z", + "version": 3, + "attributes": { + "title": "[Logs] Web Traffic", + "hits": 0, + "description": "Analyze mock web traffic log data for Elastic's website", + "panelsJSON": "[{\"embeddableConfig\":{\"vis\":{\"colors\":{\"Avg. Bytes\":\"#6ED0E0\",\"Unique Visitors\":\"#0A437C\"},\"legendOpen\":false}},\"gridData\":{\"x\":27,\"y\":11,\"w\":21,\"h\":13,\"i\":\"2\"},\"id\":\"e1d0f010-9ee7-11e7-8711-e7a007dcef99\",\"panelIndex\":\"2\",\"type\":\"visualization\",\"version\":\"7.0.0-alpha1\"},{\"gridData\":{\"x\":0,\"y\":49,\"w\":24,\"h\":18,\"i\":\"4\"},\"id\":\"06cf9c40-9ee8-11e7-8711-e7a007dcef99\",\"panelIndex\":\"4\",\"type\":\"visualization\",\"version\":\"7.0.0-alpha1\"},{\"embeddableConfig\":{\"vis\":{\"defaultColors\":{\"0 - 22\":\"rgb(247,251,255)\",\"22 - 44\":\"rgb(208,225,242)\",\"44 - 66\":\"rgb(148,196,223)\",\"66 - 88\":\"rgb(74,152,201)\",\"88 - 110\":\"rgb(23,100,171)\"},\"legendOpen\":false}},\"gridData\":{\"x\":0,\"y\":36,\"w\":24,\"h\":13,\"i\":\"7\"},\"id\":\"935afa20-e0cd-11e7-9d07-1398ccfcefa3\",\"panelIndex\":\"7\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"embeddableConfig\":{\"mapCenter\":[36.8092847020594,-96.94335937500001],\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}},\"gridData\":{\"x\":27,\"y\":24,\"w\":21,\"h\":12,\"i\":\"9\"},\"id\":\"4eb6e500-e1c7-11e7-b6d5-4dc382ef7f5b\",\"panelIndex\":\"9\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"embeddableConfig\":{\"vis\":{\"colors\":{\"0 - 500\":\"#BF1B00\",\"1000 - 1500\":\"#7EB26D\",\"500 - 1000\":\"#F2C96D\"},\"defaultColors\":{\"0 - 500\":\"rgb(165,0,38)\",\"1000 - 1500\":\"rgb(0,104,55)\",\"500 - 1000\":\"rgb(255,255,190)\"},\"legendOpen\":false}},\"gridData\":{\"x\":10,\"y\":0,\"w\":9,\"h\":11,\"i\":\"11\"},\"id\":\"69a34b00-9ee8-11e7-8711-e7a007dcef99\",\"panelIndex\":\"11\",\"title\":\"\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"gridData\":{\"x\":0,\"y\":24,\"w\":27,\"h\":12,\"i\":\"13\"},\"id\":\"42b997f0-0c26-11e8-b0ec-3bb475f6b6ff\",\"panelIndex\":\"13\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"gridData\":{\"x\":24,\"y\":36,\"w\":24,\"h\":31,\"i\":\"14\"},\"id\":\"7cbd2350-2223-11e8-b802-5bcf64c2cfb4\",\"panelIndex\":\"14\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"gridData\":{\"x\":0,\"y\":11,\"w\":27,\"h\":13,\"i\":\"15\"},\"id\":\"314c6f60-2224-11e8-b802-5bcf64c2cfb4\",\"panelIndex\":\"15\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"gridData\":{\"x\":19,\"y\":0,\"w\":15,\"h\":11,\"i\":\"16\"},\"id\":\"24a3e970-4257-11e8-b3aa-73fdaf54bfc9\",\"panelIndex\":\"16\",\"title\":\"\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"embeddableConfig\":{\"vis\":{\"legendOpen\":false}},\"gridData\":{\"x\":34,\"y\":0,\"w\":14,\"h\":11,\"i\":\"17\"},\"id\":\"14e2e710-4258-11e8-b3aa-73fdaf54bfc9\",\"panelIndex\":\"17\",\"type\":\"visualization\",\"version\":\"6.3.0\"},{\"embeddableConfig\":{},\"gridData\":{\"x\":0,\"y\":0,\"w\":10,\"h\":11,\"i\":\"18\"},\"id\":\"47f2c680-a6e3-11e8-94b4-c30c0228351b\",\"panelIndex\":\"18\",\"title\":\"\",\"type\":\"visualization\",\"version\":\"7.0.0-alpha1\"}]", + "optionsJSON": "{\"darkTheme\":false,\"hidePanelTitles\":false,\"useMargins\":true}", + "version": 1, + "timeRestore": true, + "timeTo": "now", + "timeFrom": "now-7d", + "refreshInterval": { + "pause": false, + "value": 900000 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"lucene\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } +]; diff --git a/src/server/sample_data/routes/lib/load_data.test.js b/src/server/sample_data/routes/lib/load_data.test.js index 0d8a0503d04565..0288741ae78c02 100644 --- a/src/server/sample_data/routes/lib/load_data.test.js +++ b/src/server/sample_data/routes/lib/load_data.test.js @@ -19,7 +19,7 @@ import { loadData } from './load_data'; -test('load data', done => { +test('load flight data', done => { let myDocsCount = 0; const bulkInsertMock = (docs) => { myDocsCount += docs.length; @@ -30,3 +30,15 @@ test('load data', done => { done(); }); }); + +test('load log data', done => { + let myDocsCount = 0; + const bulkInsertMock = (docs) => { + myDocsCount += docs.length; + }; + loadData('./src/server/sample_data/data_sets/logs/logs.json.gz', bulkInsertMock, async (err, count) => { + expect(myDocsCount).toBe(14005); + expect(count).toBe(14005); + done(); + }); +}); diff --git a/src/server/sample_data/routes/uninstall.js b/src/server/sample_data/routes/uninstall.js index d28b04dad61c2e..98b03dad060108 100644 --- a/src/server/sample_data/routes/uninstall.js +++ b/src/server/sample_data/routes/uninstall.js @@ -63,7 +63,7 @@ export const createUninstallRoute = () => ({ } } - reply(); + reply({}); } } }); diff --git a/src/server/sample_data/sample_data_mixin.js b/src/server/sample_data/sample_data_mixin.js index 85c314b24ae52b..160bb2be189500 100644 --- a/src/server/sample_data/sample_data_mixin.js +++ b/src/server/sample_data/sample_data_mixin.js @@ -26,6 +26,7 @@ import { } from './routes'; import { flightsSpecProvider, + logsSpecProvider, } from './data_sets'; export function sampleDataMixin(kbnServer, server) { @@ -66,4 +67,5 @@ export function sampleDataMixin(kbnServer, server) { }); server.registerSampleDataset(flightsSpecProvider); + server.registerSampleDataset(logsSpecProvider); } diff --git a/src/server/sass/build.js b/src/server/sass/build.js index 6aebd9cbb34e5f..fee7891bac0f9c 100644 --- a/src/server/sass/build.js +++ b/src/server/sass/build.js @@ -66,6 +66,10 @@ export class Build { outFile, sourceMap: true, sourceMapEmbed: true, + includePaths: [ + path.resolve(__dirname, '../..'), + path.resolve(__dirname, '../../../node_modules') + ] }); diff --git a/src/server/sass/build.test.js b/src/server/sass/build.test.js index 7e5f55576df8b5..fbc3ede3a355d0 100644 --- a/src/server/sass/build.test.js +++ b/src/server/sass/build.test.js @@ -17,6 +17,7 @@ * under the License. */ +import path from 'path'; import sass from 'node-sass'; import { Build } from './build'; @@ -27,7 +28,7 @@ describe('SASS builder', () => { it('generates a glob', () => { const builder = new Build('/foo/style.sass'); - expect(builder.getGlob()).toEqual('/foo/**/*.s{a,c}ss'); + expect(builder.getGlob()).toEqual(path.join('/foo', '**', '*.s{a,c}ss')); }); it('builds SASS', () => { @@ -35,16 +36,15 @@ describe('SASS builder', () => { const builder = new Build('/foo/style.sass'); builder.build(); - expect(sass.render.mock.calls[0][0]).toEqual({ - file: '/foo/style.sass', - outFile: '/foo/style.css', - sourceMap: true, - sourceMapEmbed: true - }); + const sassCall = sass.render.mock.calls[0][0]; + expect(sassCall.file).toEqual('/foo/style.sass'); + expect(sassCall.outFile).toEqual(path.join('/foo', 'style.css')); + expect(sassCall.sourceMap).toBe(true); + expect(sassCall.sourceMapEmbed).toBe(true); }); it('has an output file with a different extension', () => { const builder = new Build('/foo/style.sass'); - expect(builder.outputPath()).toEqual('/foo/style.css'); + expect(builder.outputPath()).toEqual(path.join('/foo', 'style.css')); }); }); \ No newline at end of file diff --git a/src/server/saved_objects/service/lib/decorate_es_error.js b/src/server/saved_objects/service/lib/decorate_es_error.js index 66d34d95dc1099..0f6190fcca465a 100644 --- a/src/server/saved_objects/service/lib/decorate_es_error.js +++ b/src/server/saved_objects/service/lib/decorate_es_error.js @@ -28,6 +28,7 @@ const { Conflict, 401: NotAuthorized, 403: Forbidden, + 413: RequestEntityTooLarge, NotFound, BadRequest } = elasticsearch.errors; @@ -36,6 +37,7 @@ import { decorateBadRequestError, decorateNotAuthorizedError, decorateForbiddenError, + decorateRequestEntityTooLargeError, createGenericNotFoundError, decorateConflictError, decorateEsUnavailableError, @@ -69,6 +71,10 @@ export function decorateEsError(error) { return decorateForbiddenError(error, reason); } + if (error instanceof RequestEntityTooLarge) { + return decorateRequestEntityTooLargeError(error, reason); + } + if (error instanceof NotFound) { return createGenericNotFoundError(); } diff --git a/src/server/saved_objects/service/lib/decorate_es_error.test.js b/src/server/saved_objects/service/lib/decorate_es_error.test.js index 07e284d0a894a3..8d070e17132027 100644 --- a/src/server/saved_objects/service/lib/decorate_es_error.test.js +++ b/src/server/saved_objects/service/lib/decorate_es_error.test.js @@ -25,6 +25,7 @@ import { isConflictError, isNotAuthorizedError, isForbiddenError, + isRequestEntityTooLargeError, isNotFoundError, isBadRequestError, } from './errors'; @@ -84,6 +85,13 @@ describe('savedObjectsClient/decorateEsError', () => { expect(isForbiddenError(error)).toBe(true); }); + it('makes es.RequestEntityTooLarge a SavedObjectsClient/RequestEntityTooLarge error', () => { + const error = new esErrors.RequestEntityTooLarge(); + expect(isRequestEntityTooLargeError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(isRequestEntityTooLargeError(error)).toBe(true); + }); + it('discards es.NotFound errors and returns a generic NotFound error', () => { const error = new esErrors.NotFound(); expect(isNotFoundError(error)).toBe(false); diff --git a/src/server/saved_objects/service/lib/errors.js b/src/server/saved_objects/service/lib/errors.js index dba2f61419a667..d0a4d6ecec796d 100644 --- a/src/server/saved_objects/service/lib/errors.js +++ b/src/server/saved_objects/service/lib/errors.js @@ -71,6 +71,16 @@ export function isForbiddenError(error) { } +// 413 - Request Entity Too Large +const CODE_REQUEST_ENTITY_TOO_LARGE = 'SavedObjectsClient/requestEntityTooLarge'; +export function decorateRequestEntityTooLargeError(error, reason) { + return decorate(error, CODE_REQUEST_ENTITY_TOO_LARGE, 413, reason); +} +export function isRequestEntityTooLargeError(error) { + return error && error[code] === CODE_REQUEST_ENTITY_TOO_LARGE; +} + + // 404 - Not Found const CODE_NOT_FOUND = 'SavedObjectsClient/notFound'; export function createGenericNotFoundError(type = null, id = null) { diff --git a/src/test_utils/kbn_server.js b/src/test_utils/kbn_server.js deleted file mode 100644 index 9c7d55208c4bb8..00000000000000 --- a/src/test_utils/kbn_server.js +++ /dev/null @@ -1,106 +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 { defaultsDeep, set } from 'lodash'; -import { header as basicAuthHeader } from './base_auth'; -import { esTestConfig, kibanaTestUser, kibanaServerTestUser } from '@kbn/test'; -import KbnServer from '../../src/server/kbn_server'; - -const DEFAULTS_SETTINGS = { - server: { - autoListen: true, - // Use the ephemeral port to make sure that tests use the first available - // port and aren't affected by the timing issues in test environment. - port: 0, - xsrf: { - disableProtection: true - } - }, - logging: { - quiet: true - }, - plugins: {}, - optimize: { - enabled: false - }, -}; - -const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { - plugins: { - scanDirs: [ - resolve(__dirname, '../core_plugins'), - ], - }, - elasticsearch: { - url: esTestConfig.getUrl(), - username: kibanaServerTestUser.username, - password: kibanaServerTestUser.password - }, -}; - -/** - * Creates an instance of KbnServer with default configuration - * tailored for unit tests - * - * @param {Object} [settings={}] Any config overrides for this instance - * @return {KbnServer} - */ -export function createServer(settings = {}) { - return new KbnServer(defaultsDeep({}, settings, DEFAULTS_SETTINGS)); -} - -/** - * Creates an instance of KbnServer, including all of the core plugins, - * with default configuration tailored for unit tests - * - * @param {Object} [settings={}] - * @return {KbnServer} - */ -export function createServerWithCorePlugins(settings = {}) { - return new KbnServer(defaultsDeep({}, settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS, DEFAULTS_SETTINGS)); -} - -/** - * Creates request configuration with a basic auth header - */ -export function authOptions() { - const { username, password } = kibanaTestUser; - const authHeader = basicAuthHeader(username, password); - return set({}, 'headers.Authorization', authHeader); -} - -/** - * Makes a request with test headers via hapi server inject() - * - * The given options are decorated with default testing options, so it's - * recommended to use this function instead of using inject() directly whenever - * possible throughout the tests. - * - * @param {KbnServer} kbnServer - * @param {object} options Any additional options or overrides for inject() - */ -export async function makeRequest(kbnServer, options) { - // Since all requests to Kibana hit core http server first and only after that - // are proxied to the "legacy" Kibana we should inject requests through the top - // level Hapi server used by the core. - return await kbnServer.newPlatform.proxyListener.root.server.http.service.httpServer.server.inject( - defaultsDeep({}, authOptions(), options) - ); -} diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts new file mode 100644 index 00000000000000..3b841c2b6ac024 --- /dev/null +++ b/src/test_utils/kbn_server.ts @@ -0,0 +1,184 @@ +/* + * 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 { ToolingLog } from '@kbn/dev-utils'; +// @ts-ignore: implicit any for JS file +import { createEsTestCluster, esTestConfig, kibanaServerTestUser, kibanaTestUser } from '@kbn/test'; +import { defaultsDeep } from 'lodash'; +import { resolve } from 'path'; +import { BehaviorSubject } from 'rxjs'; +import supertest from 'supertest'; +import { Env } from '../core/server/config'; +import { LegacyObjectToConfigAdapter } from '../core/server/legacy_compat'; +import { Root } from '../core/server/root'; + +type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; + +const DEFAULTS_SETTINGS = { + server: { + autoListen: true, + // Use the ephemeral port to make sure that tests use the first available + // port and aren't affected by the timing issues in test environment. + port: 0, + xsrf: { disableProtection: true }, + }, + logging: { silent: true }, + plugins: {}, + optimize: { enabled: false }, +}; + +const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { + plugins: { scanDirs: [resolve(__dirname, '../core_plugins')] }, + elasticsearch: { + url: esTestConfig.getUrl(), + username: kibanaServerTestUser.username, + password: kibanaServerTestUser.password, + }, +}; + +export function createRootWithSettings(...settings: Array>) { + const env = Env.createDefault({ + configs: [], + cliArgs: { + dev: false, + quiet: false, + silent: false, + watch: false, + repl: false, + basePath: false, + }, + isDevClusterMaster: false, + }); + + return new Root( + new BehaviorSubject( + new LegacyObjectToConfigAdapter(defaultsDeep({}, ...settings, DEFAULTS_SETTINGS)) + ), + env + ); +} + +/** + * Returns supertest request attached to the core's internal native Node server. + * @param root + * @param method + * @param path + */ +function getSupertest(root: Root, method: HttpMethod, path: string) { + const testUserCredentials = new Buffer(`${kibanaTestUser.username}:${kibanaTestUser.password}`); + return supertest((root as any).server.http.service.httpServer.server.listener) + [method](path) + .set('Authorization', `Basic ${testUserCredentials.toString('base64')}`); +} + +/** + * Creates an instance of Root with default configuration + * tailored for unit tests. + * + * @param {Object} [settings={}] Any config overrides for this instance. + * @returns {Root} + */ +export function createRoot(settings = {}) { + return createRootWithSettings(settings); +} + +/** + * Creates an instance of Root, including all of the core plugins, + * with default configuration tailored for unit tests. + * + * @param {Object} [settings={}] Any config overrides for this instance. + * @returns {Root} + */ +export function createRootWithCorePlugins(settings = {}) { + return createRootWithSettings(settings, DEFAULT_SETTINGS_WITH_CORE_PLUGINS); +} + +/** + * Returns `kbnServer` instance used in the "legacy" Kibana. + * @param root + */ +export function getKbnServer(root: Root) { + return (root as any).server.legacy.service.kbnServer; +} + +export const request: Record< + HttpMethod, + (root: Root, path: string) => ReturnType +> = { + delete: (root, path) => getSupertest(root, 'delete', path), + get: (root, path) => getSupertest(root, 'get', path), + head: (root, path) => getSupertest(root, 'head', path), + post: (root, path) => getSupertest(root, 'post', path), + put: (root, path) => getSupertest(root, 'put', path), +}; + +/** + * Creates an instance of the Root, including all of the core "legacy" plugins, + * with default configuration tailored for unit tests, and starts es. + * + * @param options + * @prop settings Any config overrides for this instance. + * @prop adjustTimeout A function(t) => this.timeout(t) that adjust the timeout of a + * test, ensuring the test properly waits for the server to boot without timing out. + */ +export async function startTestServers({ + adjustTimeout, + settings = {}, +}: { + adjustTimeout: (timeout: number) => void; + settings: Record; +}) { + if (!adjustTimeout) { + throw new Error('adjustTimeout is required in order to avoid flaky tests'); + } + + const log = new ToolingLog({ + level: 'debug', + writeTo: process.stdout, + }); + + log.indent(6); + log.info('starting elasticsearch'); + log.indent(4); + + const es = createEsTestCluster({ log }); + + log.indent(-4); + + adjustTimeout(es.getStartTimeout()); + + await es.start(); + + const root = createRootWithCorePlugins(settings); + await root.start(); + + const kbnServer = getKbnServer(root); + await kbnServer.server.plugins.elasticsearch.waitUntilReady(); + + return { + kbnServer, + root, + es, + + async stop() { + await root.shutdown(); + await es.cleanup(); + }, + }; +} diff --git a/src/test_utils/public/enzyme_helpers.js b/src/test_utils/public/enzyme_helpers.js index ca7618d38705fd..4bbaefe515f6f9 100644 --- a/src/test_utils/public/enzyme_helpers.js +++ b/src/test_utils/public/enzyme_helpers.js @@ -19,24 +19,57 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { intl } from './mocks/intl'; /** - * Creates the wrapper instance with provided intl object into context + * Creates the wrapper instance using shallow with provided intl object into context * * @param node The React element or cheerio wrapper * @param options properties to pass into shallow wrapper * @return The wrapper instance around the rendered output with intl object in context */ export function shallowWithIntl(node, { context = {}, childContextTypes = {}, ...props } = {}) { + const clonedNode = cloneNode(node); + const options = getOptions(context, childContextTypes, props); + + if (React.isValidElement(node)) { + return shallow(clonedNode, options); + } + + return clonedNode.shallow(options); +} + +/** + * Creates the wrapper instance using mount with provided intl object into context + * + * @param node The React element or cheerio wrapper + * @param options properties to pass into mount wrapper + * @return The wrapper instance around the rendered output with intl object in context + */ +export function mountWithIntl(node, { context = {}, childContextTypes = {}, ...props } = {}) { + const clonedNode = cloneNode(node); + const options = getOptions(context, childContextTypes, props); + + if (React.isValidElement(node)) { + return mount(clonedNode, options); + } + + return clonedNode.mount(options); +} + +export { intl }; + +function cloneNode(node) { if (!node) { throw new Error(`First argument should be cheerio object or React element, not ${node}`); } - const clonedNode = React.cloneElement(node, { intl }); + return React.cloneElement(node, { intl }); +} - const options = { +function getOptions(context, childContextTypes, props) { + return { context: { ...context, intl, @@ -47,12 +80,4 @@ export function shallowWithIntl(node, { context = {}, childContextTypes = {}, .. }, ...props, }; - - if (React.isValidElement(node)) { - return shallow(clonedNode, options); - } - - return clonedNode.shallow(options); } - -export { intl }; diff --git a/src/ui/__tests__/ui_exports_replace_injected_vars.js b/src/ui/__tests__/ui_exports_replace_injected_vars.js index b7762ef104b901..5cb05ac1dbeeb7 100644 --- a/src/ui/__tests__/ui_exports_replace_injected_vars.js +++ b/src/ui/__tests__/ui_exports_replace_injected_vars.js @@ -25,10 +25,10 @@ import sinon from 'sinon'; import cheerio from 'cheerio'; import { noop } from 'lodash'; -import KbnServer from '../../server/kbn_server'; +import { createRoot, getKbnServer, request } from '../../test_utils/kbn_server'; const getInjectedVarsFromResponse = (resp) => { - const $ = cheerio.load(resp.payload); + const $ = cheerio.load(resp.text); const data = $('kbn-injected-metadata').attr('data'); return JSON.parse(data).legacyMetadata.vars; }; @@ -45,45 +45,46 @@ const injectReplacer = (kbnServer, replacer) => { }; describe('UiExports', function () { - describe('#replaceInjectedVars', function () { + let root; + let kbnServer; + before(async () => { this.slow(2000); - this.timeout(10000); - - let kbnServer; - beforeEach(async () => { - kbnServer = new KbnServer({ - server: { port: 0 }, // pick a random open port - logging: { silent: true }, // no logs - optimize: { enabled: false }, - plugins: { - paths: [resolve(__dirname, './fixtures/test_app')] // inject an app so we can hit /app/{id} - }, - }); + this.timeout(30000); - await kbnServer.ready(); - - // TODO: hopefully we can add better support for something - // like this in the new platform - kbnServer.server._requestor._decorations.getUiSettingsService = { - apply: undefined, - method() { - return { - getDefaults: noop, - getUserProvided: noop - }; - } - }; + root = root = createRoot({ + // inject an app so we can hit /app/{id} + plugins: { paths: [resolve(__dirname, './fixtures/test_app')] }, }); - afterEach(async () => { - await kbnServer.close(); - kbnServer = null; - }); + await root.start(); + + kbnServer = getKbnServer(root); + + // TODO: hopefully we can add better support for something + // like this in the new platform + kbnServer.server._requestor._decorations.getUiSettingsService = { + apply: undefined, + method: () => ({ getDefaults: noop, getUserProvided: noop }) + }; + }); + + after(async () => await root.shutdown()); + let originalInjectedVarsReplacers; + beforeEach(() => { + originalInjectedVarsReplacers = kbnServer.uiExports.injectedVarsReplacers; + }); + + afterEach(() => { + kbnServer.uiExports.injectedVarsReplacers = originalInjectedVarsReplacers; + }); + + describe('#replaceInjectedVars', function () { it('allows sync replacing of injected vars', async () => { injectReplacer(kbnServer, () => ({ a: 1 })); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ a: 1 }); @@ -98,7 +99,8 @@ describe('UiExports', function () { }; }); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ @@ -111,7 +113,8 @@ describe('UiExports', function () { injectReplacer(kbnServer, () => ({ foo: 'bar' })); injectReplacer(kbnServer, stub); - await kbnServer.inject('/app/test_app'); + await await request.get(root, '/app/test_app') + .expect(200); sinon.assert.calledOnce(stub); expect(stub.firstCall.args[0]).to.eql({ foo: 'bar' }); // originalInjectedVars @@ -126,7 +129,8 @@ describe('UiExports', function () { injectReplacer(kbnServer, orig => ({ name: orig.name + 'a' })); injectReplacer(kbnServer, orig => ({ name: orig.name + 'm' })); - const resp = await kbnServer.inject('/app/test_app'); + const resp = await request.get(root, '/app/test_app') + .expect(200); const injectedVars = getInjectedVarsFromResponse(resp); expect(injectedVars).to.eql({ name: 'sam' }); @@ -138,15 +142,17 @@ describe('UiExports', function () { throw new Error('replacer failed'); }); - const resp = await kbnServer.inject('/app/test_app'); - expect(resp).to.have.property('statusCode', 500); + await request.get(root, '/app/test_app') + .expect(500); }); it('starts off with the injected vars for the app merged with the default injected vars', async () => { const stub = sinon.stub(); injectReplacer(kbnServer, stub); - await kbnServer.inject('/app/test_app'); + await request.get(root, '/app/test_app') + .expect(200); + sinon.assert.calledOnce(stub); expect(stub.firstCall.args[0]).to.eql({ from_defaults: true, from_test_app: true }); }); diff --git a/src/ui/field_formats/__tests__/field_formats_mixin.js b/src/ui/field_formats/__tests__/field_formats_mixin.js index 58c61962c2414e..3159705f3d8ed8 100644 --- a/src/ui/field_formats/__tests__/field_formats_mixin.js +++ b/src/ui/field_formats/__tests__/field_formats_mixin.js @@ -22,31 +22,31 @@ import sinon from 'sinon'; import { FieldFormat } from '../field_format'; import * as FieldFormatsServiceNS from '../field_formats_service'; -import { createServer } from '../../../test_utils/kbn_server'; +import { fieldFormatsMixin } from '../field_formats_mixin'; describe('server.registerFieldFormat(createFormat)', () => { const sandbox = sinon.createSandbox(); - let kbnServer; + let registerFieldFormat; + let fieldFormatServiceFactory; + const serverMock = { decorate() {} }; beforeEach(async () => { - kbnServer = createServer(); - await kbnServer.ready(); + sandbox.stub(serverMock); + await fieldFormatsMixin({}, serverMock); + [[,, fieldFormatServiceFactory], [,, registerFieldFormat]] = serverMock.decorate.args; }); - afterEach(async () => { - sandbox.restore(); - await kbnServer.close(); - }); + afterEach(() => sandbox.restore()); it('throws if createFormat is not a function', () => { - expect(() => kbnServer.server.registerFieldFormat()).to.throwError(error => { + expect(() => registerFieldFormat()).to.throwError(error => { expect(error.message).to.match(/createFormat is not a function/i); }); }); it('calls the createFormat() function with the FieldFormat class', () => { const createFormat = sinon.stub(); - kbnServer.server.registerFieldFormat(createFormat); + registerFieldFormat(createFormat); sinon.assert.calledOnce(createFormat); sinon.assert.calledWithExactly(createFormat, sinon.match.same(FieldFormat)); }); @@ -61,9 +61,9 @@ describe('server.registerFieldFormat(createFormat)', () => { class FooFormat { static id = 'foo' } - kbnServer.server.registerFieldFormat(() => FooFormat); + registerFieldFormat(() => FooFormat); - const fieldFormats = await kbnServer.server.fieldFormatServiceFactory({ + const fieldFormats = await fieldFormatServiceFactory({ getAll: () => ({}), getDefaults: () => ({}) }); diff --git a/src/ui/public/agg_response/hierarchical/__tests__/build_hierarchical_data.js b/src/ui/public/agg_response/hierarchical/__tests__/build_hierarchical_data.js index 293032e0f6cc7b..f0b5f8f2bb533a 100644 --- a/src/ui/public/agg_response/hierarchical/__tests__/build_hierarchical_data.js +++ b/src/ui/public/agg_response/hierarchical/__tests__/build_hierarchical_data.js @@ -23,6 +23,7 @@ import fixtures from 'fixtures/fake_hierarchical_data'; import sinon from 'sinon'; import expect from 'expect.js'; import ngMock from 'ng_mock'; +import { toastNotifications } from 'ui/notify'; import { VisProvider } from '../../../vis'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { BuildHierarchicalDataProvider } from '../build_hierarchical_data'; @@ -276,6 +277,9 @@ describe('buildHierarchicalData', function () { let results; beforeEach(function () { + // Clear existing toasts. + toastNotifications.list.splice(0); + let id = 1; vis = new Vis(indexPattern, { type: 'pie', @@ -299,10 +303,11 @@ describe('buildHierarchicalData', function () { }); it('should set the hits attribute for the results', function () { - const errCall = Notifier.prototype.error.getCall(0); - expect(errCall).to.be.ok(); - expect(errCall.args[0]).to.contain('not supported'); - + // Ideally, buildHierarchicalData shouldn't be tightly coupled to toastNotifications. Instead, + // it should notify its consumer of this error and the consumer should be responsible for + // notifying the user. This test verifies the side effect of the error until we can remove + // this coupling. + expect(toastNotifications.list).to.have.length(1); expect(results).to.have.property('slices'); expect(results).to.have.property('names'); expect(results.names).to.have.length(2); diff --git a/src/ui/public/agg_response/hierarchical/build_hierarchical_data.js b/src/ui/public/agg_response/hierarchical/build_hierarchical_data.js index d0b98a7ef01ba9..fc7734a13d8526 100644 --- a/src/ui/public/agg_response/hierarchical/build_hierarchical_data.js +++ b/src/ui/public/agg_response/hierarchical/build_hierarchical_data.js @@ -18,6 +18,7 @@ */ import _ from 'lodash'; +import { toastNotifications } from 'ui/notify'; import { extractBuckets } from './_extract_buckets'; import { createRawData } from './_create_raw_data'; import { arrayToLinkedList } from './_array_to_linked_list'; @@ -25,15 +26,10 @@ import AggConfigResult from '../../vis/agg_config_result'; import { AggResponseHierarchicalBuildSplitProvider } from './_build_split'; import { HierarchicalTooltipFormatterProvider } from './_hierarchical_tooltip_formatter'; -export function BuildHierarchicalDataProvider(Private, Notifier) { +export function BuildHierarchicalDataProvider(Private) { const buildSplit = Private(AggResponseHierarchicalBuildSplitProvider); const tooltipFormatter = Private(HierarchicalTooltipFormatterProvider); - - const notify = new Notifier({ - location: 'Pie chart response converter' - }); - return function (vis, resp) { // Create a reference to the buckets let buckets = vis.getAggConfig().bySchemaGroup.buckets; @@ -73,7 +69,10 @@ export function BuildHierarchicalDataProvider(Private, Notifier) { const aggData = resp.aggregations ? resp.aggregations[firstAgg.id] : null; if (!firstAgg._next && firstAgg.schema.name === 'split') { - notify.error('Splitting charts without splitting slices is not supported. Pretending that we are just splitting slices.'); + toastNotifications.addDanger({ + title: 'Splitting charts without splitting slices is not supported', + text: 'Pretending that we are just splitting slices.' + }); } // start with splitting slices diff --git a/src/ui/public/agg_response/point_series/__tests__/_add_to_siri.js b/src/ui/public/agg_response/point_series/__tests__/_add_to_siri.js index b7e97639d05254..d7fbb223a2210f 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_add_to_siri.js +++ b/src/ui/public/agg_response/point_series/__tests__/_add_to_siri.js @@ -18,16 +18,9 @@ */ import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesAddToSiriProvider } from '../_add_to_siri'; +import { addToSiri } from '../_add_to_siri'; describe('addToSiri', function () { - let addToSiri; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - addToSiri = Private(PointSeriesAddToSiriProvider); - })); it('creates a new series the first time it sees an id', function () { const series = new Map(); diff --git a/src/ui/public/agg_response/point_series/__tests__/_fake_x_aspect.js b/src/ui/public/agg_response/point_series/__tests__/_fake_x_aspect.js index 9475b564d92cc0..3b6ac3b31d9e68 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_fake_x_aspect.js +++ b/src/ui/public/agg_response/point_series/__tests__/_fake_x_aspect.js @@ -18,47 +18,27 @@ */ import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { VisProvider } from '../../../vis'; -import { AggConfig } from '../../../vis/agg_config'; import { AggType } from '../../../agg_types/agg_type'; -import { PointSeriesFakeXAxisProvider } from '../_fake_x_aspect'; +import { makeFakeXAspect } from '../_fake_x_aspect'; describe('makeFakeXAspect', function () { - let makeFakeXAspect; - let Vis; - let indexPattern; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - Vis = Private(VisProvider); - indexPattern = Private(VisProvider); - makeFakeXAspect = Private(PointSeriesFakeXAxisProvider); - })); - it('creates an object that looks like an aspect', function () { - const vis = new Vis(indexPattern, { type: 'histogram' }); - const aspect = makeFakeXAspect(vis); + const aspect = makeFakeXAspect(); expect(aspect) .to.have.property('i', -1) - .and.have.property('agg') - .and.have.property('col'); + .and.have.property('aggConfig'); - expect(aspect.agg) - .to.be.an(AggConfig) + expect(aspect.aggConfig) + .to.have.property('fieldFormatter') .and.to.have.property('type'); - expect(aspect.agg.type) + expect(aspect.aggConfig.type) .to.be.an(AggType) .and.to.have.property('name', 'all') .and.to.have.property('title', 'All docs') .and.to.have.property('hasNoDsl', true); - expect(aspect.col) - .to.be.an('object') - .and.to.have.property('aggConfig', aspect.agg) - .and.to.have.property('label', aspect.agg.makeLabel()); }); }); diff --git a/src/ui/public/agg_response/point_series/__tests__/_get_aspects.js b/src/ui/public/agg_response/point_series/__tests__/_get_aspects.js index 7338cd781239bf..21a61b4d276ad3 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_get_aspects.js +++ b/src/ui/public/agg_response/point_series/__tests__/_get_aspects.js @@ -22,19 +22,16 @@ import moment from 'moment'; import expect from 'expect.js'; import ngMock from 'ng_mock'; import { VisProvider } from '../../../vis'; -import { AggConfig } from '../../../vis/agg_config'; -import { PointSeriesGetAspectsProvider } from '../_get_aspects'; +import { getAspects } from '../_get_aspects'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; describe('getAspects', function () { let Vis; let indexPattern; - let getAspects; beforeEach(ngMock.module('kibana')); beforeEach(ngMock.inject(function (Private) { Vis = Private(VisProvider); - getAspects = Private(PointSeriesGetAspectsProvider); indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); })); @@ -58,8 +55,7 @@ describe('getAspects', function () { expect(aspect) .to.be.an('object') .and.have.property('i', i) - .and.have.property('agg', vis.aggs[i]) - .and.have.property('col', table.columns[i]); + .and.have.property('aggConfig', vis.aggs[i]); } function init(group, x, y) { @@ -115,7 +111,7 @@ describe('getAspects', function () { it('produces an aspect object for each of the aspect types found in the columns', function () { init(1, 1, 1); - const aspects = getAspects(vis, table); + const aspects = getAspects(table); validate(aspects.x, 0); validate(aspects.series, 1); validate(aspects.y, 2); @@ -124,7 +120,7 @@ describe('getAspects', function () { it('uses arrays only when there are more than one aspect of a specific type', function () { init(0, 1, 2); - const aspects = getAspects(vis, table); + const aspects = getAspects(table); validate(aspects.x, 0); expect(aspects.series == null).to.be(true); @@ -138,25 +134,20 @@ describe('getAspects', function () { init(0, 2, 1); expect(function () { - getAspects(vis, table); + getAspects(table); }).to.throwError(TypeError); }); it('creates a fake x aspect if the column does not exist', function () { init(0, 0, 1); - const aspects = getAspects(vis, table); + const aspects = getAspects(table); expect(aspects.x) .to.be.an('object') .and.have.property('i', -1) - .and.have.property('agg') - .and.have.property('col'); - - expect(aspects.x.agg).to.be.an(AggConfig); - expect(aspects.x.col) - .to.be.an('object') - .and.to.have.property('aggConfig', aspects.x.agg); + .and.have.property('aggConfig') + .and.have.property('title'); }); }); diff --git a/src/ui/public/agg_response/point_series/__tests__/_get_point.js b/src/ui/public/agg_response/point_series/__tests__/_get_point.js index db2d7e5e1171e3..24c0a0ce3768a0 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_get_point.js +++ b/src/ui/public/agg_response/point_series/__tests__/_get_point.js @@ -19,33 +19,23 @@ import _ from 'lodash'; import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesGetPointProvider } from '../_get_point'; +import { getPoint } from '../_get_point'; describe('getPoint', function () { - let getPoint; - const truthFormatted = { fieldFormatter: _.constant(_.constant(true)) }; const identFormatted = { fieldFormatter: _.constant(_.identity) }; - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - getPoint = Private(PointSeriesGetPointProvider); - })); - describe('Without series aspect', function () { let seriesAspect; let xAspect; - let yCol; let yAspect; let yScale; beforeEach(function () { seriesAspect = null; xAspect = { i: 0 }; - yCol = { title: 'Y', aggConfig: {} }; - yAspect = { i: 1, col: yCol }; + yAspect = { i: 1, title: 'Y', aggConfig: {} }; yScale = 5; }); @@ -58,7 +48,7 @@ describe('getPoint', function () { .to.have.property('x', 1) .and.have.property('y', 10) .and.have.property('z', 3) - .and.have.property('series', yCol.title) + .and.have.property('series', yAspect.title) .and.have.property('aggConfigResult', row[1]); }); @@ -83,7 +73,7 @@ describe('getPoint', function () { }); it('properly unwraps and scales values', function () { - const seriesAspect = { i: 1, agg: identFormatted }; + const seriesAspect = { i: 1, aggConfig: identFormatted }; const point = getPoint(xAspect, seriesAspect, yScale, row, yAspect); expect(point) @@ -94,7 +84,7 @@ describe('getPoint', function () { }); it('properly formats series values', function () { - const seriesAspect = { i: 1, agg: truthFormatted }; + const seriesAspect = { i: 1, aggConfig: truthFormatted }; const point = getPoint(xAspect, seriesAspect, yScale, row, yAspect); expect(point) @@ -105,7 +95,7 @@ describe('getPoint', function () { }); it ('adds the aggConfig to the points', function () { - const seriesAspect = { i: 1, agg: truthFormatted }; + const seriesAspect = { i: 1, aggConfig: truthFormatted }; const point = getPoint(xAspect, seriesAspect, yScale, row, yAspect); expect(point).to.have.property('aggConfig', truthFormatted); diff --git a/src/ui/public/agg_response/point_series/__tests__/_get_series.js b/src/ui/public/agg_response/point_series/__tests__/_get_series.js index 0823f07d61143a..a63ec0c3c753d3 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_get_series.js +++ b/src/ui/public/agg_response/point_series/__tests__/_get_series.js @@ -19,19 +19,11 @@ import _ from 'lodash'; import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesGetSeriesProvider } from '../_get_series'; +import { getSeries } from '../_get_series'; describe('getSeries', function () { - let getSeries; - const agg = { fieldFormatter: _.constant(_.identity) }; - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - getSeries = Private(PointSeriesGetSeriesProvider); - })); - function wrapRows(row) { return row.map(function (v) { return { value: v }; @@ -47,11 +39,10 @@ describe('getSeries', function () { [1, 2, 3] ].map(wrapRows); - const yCol = { aggConfig: {}, title: 'y' }; const chart = { aspects: { x: { i: 0 }, - y: { i: 1, col: yCol, agg: { id: 'id' } }, + y: { i: 1, title: 'y', aggConfig: { id: 'id' } }, z: { i: 2 } } }; @@ -65,7 +56,7 @@ describe('getSeries', function () { const siri = series[0]; expect(siri) .to.be.an('object') - .and.have.property('label', yCol.title) + .and.have.property('label', chart.aspects.y.title) .and.have.property('values'); expect(siri.values) @@ -93,8 +84,8 @@ describe('getSeries', function () { aspects: { x: { i: 0 }, y: [ - { i: 1, col: { title: '0' }, agg: { id: 1 } }, - { i: 2, col: { title: '1' }, agg: { id: 2 } }, + { i: 1, title: '0', aggConfig: { id: 1 } }, + { i: 2, title: '1', aggConfig: { id: 2 } }, ] } }; @@ -138,8 +129,8 @@ describe('getSeries', function () { const chart = { aspects: { x: { i: -1 }, - series: { i: 0, agg: agg }, - y: { i: 1, col: { title: '0' }, agg: agg } + series: { i: 0, aggConfig: agg }, + y: { i: 1, title: '0', aggConfig: agg } } }; @@ -180,10 +171,10 @@ describe('getSeries', function () { const chart = { aspects: { x: { i: -1 }, - series: { i: 0, agg: agg }, + series: { i: 0, aggConfig: agg }, y: [ - { i: 1, col: { title: '0' }, agg: { id: 1 } }, - { i: 2, col: { title: '1' }, agg: { id: 2 } } + { i: 1, title: '0', aggConfig: { id: 1 } }, + { i: 2, title: '1', aggConfig: { id: 2 } } ] } }; @@ -230,10 +221,10 @@ describe('getSeries', function () { const chart = { aspects: { x: { i: -1 }, - series: { i: 0, agg: agg }, + series: { i: 0, aggConfig: agg }, y: [ - { i: 1, col: { title: '0' }, agg: { id: 1 } }, - { i: 2, col: { title: '1' }, agg: { id: 2 } } + { i: 1, title: '0', aggConfig: { id: 1 } }, + { i: 2, title: '1', aggConfig: { id: 2 } } ] } }; diff --git a/src/ui/public/agg_response/point_series/__tests__/_init_x_axis.js b/src/ui/public/agg_response/point_series/__tests__/_init_x_axis.js index 48beab766df44c..43872dba8af54e 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_init_x_axis.js +++ b/src/ui/public/agg_response/point_series/__tests__/_init_x_axis.js @@ -19,57 +19,50 @@ import _ from 'lodash'; import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesInitXAxisProvider } from '../_init_x_axis'; +import { initXAxis } from '../_init_x_axis'; describe('initXAxis', function () { - let initXAxis; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - initXAxis = Private(PointSeriesInitXAxisProvider); - })); + const field = {}; + const indexPattern = {}; const baseChart = { aspects: { x: { - agg: { + aggConfig: { fieldFormatter: _.constant({}), write: _.constant({ params: {} }), + aggConfigs: {}, + getIndexPattern: () => { + return indexPattern; + }, type: {} }, - col: { - title: 'label' - } + title: 'label' } } }; - const field = {}; - const indexPattern = {}; it('sets the xAxisFormatter if the agg is not ordered', function () { const chart = _.cloneDeep(baseChart); initXAxis(chart); expect(chart) .to.have.property('xAxisLabel', 'label') - .and.have.property('xAxisFormatter', chart.aspects.x.agg.fieldFormatter()); + .and.have.property('xAxisFormatter', chart.aspects.x.aggConfig.fieldFormatter()); }); it('makes the chart ordered if the agg is ordered', function () { const chart = _.cloneDeep(baseChart); - chart.aspects.x.agg.type.ordered = true; - chart.aspects.x.agg.params = { + chart.aspects.x.aggConfig.type.ordered = true; + chart.aspects.x.aggConfig.params = { field: field }; - chart.aspects.x.agg.vis = { - indexPattern: indexPattern - }; + chart.aspects.x.aggConfig.aggConfigs.indexPattern = indexPattern; initXAxis(chart); expect(chart) .to.have.property('xAxisLabel', 'label') - .and.have.property('xAxisFormatter', chart.aspects.x.agg.fieldFormatter()) + .and.have.property('xAxisFormatter', chart.aspects.x.aggConfig.fieldFormatter()) .and.have.property('indexPattern', indexPattern) .and.have.property('xAxisField', field) .and.have.property('ordered'); @@ -81,19 +74,17 @@ describe('initXAxis', function () { it('reads the interval param from the x agg', function () { const chart = _.cloneDeep(baseChart); - chart.aspects.x.agg.type.ordered = true; - chart.aspects.x.agg.write = _.constant({ params: { interval: 10 } }); - chart.aspects.x.agg.params = { + chart.aspects.x.aggConfig.type.ordered = true; + chart.aspects.x.aggConfig.write = _.constant({ params: { interval: 10 } }); + chart.aspects.x.aggConfig.params = { field: field }; - chart.aspects.x.agg.vis = { - indexPattern: indexPattern - }; + chart.aspects.x.aggConfig.aggConfigs.indexPattern = indexPattern; initXAxis(chart); expect(chart) .to.have.property('xAxisLabel', 'label') - .and.have.property('xAxisFormatter', chart.aspects.x.agg.fieldFormatter()) + .and.have.property('xAxisFormatter', chart.aspects.x.aggConfig.fieldFormatter()) .and.have.property('indexPattern', indexPattern) .and.have.property('xAxisField', field) .and.have.property('ordered'); diff --git a/src/ui/public/agg_response/point_series/__tests__/_init_y_axis.js b/src/ui/public/agg_response/point_series/__tests__/_init_y_axis.js index 507bd01b88fa2d..92776918005d27 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_init_y_axis.js +++ b/src/ui/public/agg_response/point_series/__tests__/_init_y_axis.js @@ -19,17 +19,10 @@ import _ from 'lodash'; import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesInitYAxisProvider } from '../_init_y_axis'; +import { initYAxis } from '../_init_y_axis'; describe('initYAxis', function () { - let initYAxis; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - initYAxis = Private(PointSeriesInitYAxisProvider); - })); function agg() { return { @@ -42,12 +35,12 @@ describe('initYAxis', function () { const baseChart = { aspects: { y: [ - { agg: agg(), col: { title: 'y1' } }, - { agg: agg(), col: { title: 'y2' } }, + { aggConfig: agg(), title: 'y1' }, + { aggConfig: agg(), title: 'y2' }, ], x: { - agg: agg(), - col: { title: 'x' } + aggConfig: agg(), + title: 'x' } } }; @@ -59,7 +52,7 @@ describe('initYAxis', function () { it('sets the yAxisFormatter the the field formats convert fn', function () { const chart = _.cloneDeep(singleYBaseChart); initYAxis(chart); - expect(chart).to.have.property('yAxisFormatter', chart.aspects.y.agg.fieldFormatter()); + expect(chart).to.have.property('yAxisFormatter', chart.aspects.y.aggConfig.fieldFormatter()); }); it('sets the yAxisLabel', function () { @@ -76,8 +69,8 @@ describe('initYAxis', function () { expect(chart).to.have.property('yAxisFormatter'); expect(chart.yAxisFormatter) - .to.be(chart.aspects.y[0].agg.fieldFormatter()) - .and.not.be(chart.aspects.y[1].agg.fieldFormatter()); + .to.be(chart.aspects.y[0].aggConfig.fieldFormatter()) + .and.not.be(chart.aspects.y[1].aggConfig.fieldFormatter()); }); it('does not set the yAxisLabel, it does not make sense to put multiple labels on the same axis', function () { diff --git a/src/ui/public/agg_response/point_series/__tests__/_main.js b/src/ui/public/agg_response/point_series/__tests__/_main.js index 9cfad3eaa053ef..e7b03a11966e01 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_main.js +++ b/src/ui/public/agg_response/point_series/__tests__/_main.js @@ -23,7 +23,6 @@ import AggConfigResult from '../../../vis/agg_config_result'; import expect from 'expect.js'; import ngMock from 'ng_mock'; import { VisProvider } from '../../../vis'; -import { TabifyTable } from '../../tabify/_table'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { AggResponsePointSeriesProvider } from '../point_series'; @@ -47,11 +46,11 @@ describe('pointSeriesChartDataFromTable', function () { const agg = vis.aggs[0]; const result = new AggConfigResult(vis.aggs[0], void 0, 100, 100); - const table = new TabifyTable(); + const table = { rows: [] }; table.columns = [ { aggConfig: agg } ]; table.rows.push([ result ]); - const chartData = pointSeriesChartDataFromTable(vis, table); + const chartData = pointSeriesChartDataFromTable(table); expect(chartData).to.be.an('object'); expect(chartData.series).to.be.an('array'); @@ -86,14 +85,14 @@ describe('pointSeriesChartDataFromTable', function () { }; const rowCount = 3; - const table = new TabifyTable(); + const table = { rows: [] }; table.columns = [ x.col, y.col ]; _.times(rowCount, function (i) { const date = new AggConfigResult(x.agg, void 0, x.at(i)); table.rows.push([date, new AggConfigResult(y.agg, date, y.at(i))]); }); - const chartData = pointSeriesChartDataFromTable(vis, table); + const chartData = pointSeriesChartDataFromTable(table); expect(chartData).to.be.an('object'); expect(chartData.series).to.be.an('array'); @@ -147,7 +146,7 @@ describe('pointSeriesChartDataFromTable', function () { }; const rowCount = 3; - const table = new TabifyTable(); + const table = { rows: [] }; table.columns = [ date.col, avg.col, max.col ]; _.times(rowCount, function (i) { const dateResult = new AggConfigResult(date.agg, void 0, date.at(i)); @@ -156,7 +155,7 @@ describe('pointSeriesChartDataFromTable', function () { table.rows.push([dateResult, avgResult, maxResult]); }); - const chartData = pointSeriesChartDataFromTable(vis, table); + const chartData = pointSeriesChartDataFromTable(table); expect(chartData).to.be.an('object'); expect(chartData.series).to.be.an('array'); expect(chartData.series).to.have.length(2); @@ -226,7 +225,7 @@ describe('pointSeriesChartDataFromTable', function () { const metricCount = 2; const rowsPerSegment = 2; const rowCount = extensions.length * rowsPerSegment; - const table = new TabifyTable(); + const table = { rows: [] }; table.columns = [ date.col, term.col, avg.col, max.col ]; _.times(rowCount, function (i) { const dateResult = new AggConfigResult(date.agg, void 0, date.at(i)); @@ -236,7 +235,7 @@ describe('pointSeriesChartDataFromTable', function () { table.rows.push([dateResult, termResult, avgResult, maxResult]); }); - const chartData = pointSeriesChartDataFromTable(vis, table); + const chartData = pointSeriesChartDataFromTable(table); expect(chartData).to.be.an('object'); expect(chartData.series).to.be.an('array'); // one series for each extension, and then one for each metric inside diff --git a/src/ui/public/agg_response/point_series/__tests__/_ordered_date_axis.js b/src/ui/public/agg_response/point_series/__tests__/_ordered_date_axis.js index 92eaa98b739fcf..f46c2bd37c9bc9 100644 --- a/src/ui/public/agg_response/point_series/__tests__/_ordered_date_axis.js +++ b/src/ui/public/agg_response/point_series/__tests__/_ordered_date_axis.js @@ -21,8 +21,7 @@ import moment from 'moment'; import _ from 'lodash'; import sinon from 'sinon'; import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import { PointSeriesOrderedDateAxisProvider } from '../_ordered_date_axis'; +import { orderedDateAxis } from '../_ordered_date_axis'; describe('orderedDateAxis', function () { @@ -35,7 +34,7 @@ describe('orderedDateAxis', function () { chart: { aspects: { x: { - agg: { + aggConfig: { fieldIsTimeField: _.constant(true), buckets: { getScaledDateFormat: _.constant('hh:mm:ss'), @@ -48,17 +47,10 @@ describe('orderedDateAxis', function () { } }; - let orderedDateAxis; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - orderedDateAxis = Private(PointSeriesOrderedDateAxisProvider); - })); - describe('xAxisFormatter', function () { it('sets the xAxisFormatter', function () { const args = _.cloneDeep(baseArgs); - orderedDateAxis(args.vis, args.chart); + orderedDateAxis(args.chart); expect(args.chart).to.have.property('xAxisFormatter'); expect(args.chart.xAxisFormatter).to.be.a('function'); @@ -66,7 +58,7 @@ describe('orderedDateAxis', function () { it('formats values using moment, and returns strings', function () { const args = _.cloneDeep(baseArgs); - orderedDateAxis(args.vis, args.chart); + orderedDateAxis(args.chart); const val = '2014-08-06T12:34:01'; expect(args.chart.xAxisFormatter(val)) @@ -77,7 +69,7 @@ describe('orderedDateAxis', function () { describe('ordered object', function () { it('sets date: true', function () { const args = _.cloneDeep(baseArgs); - orderedDateAxis(args.vis, args.chart); + orderedDateAxis(args.chart); expect(args.chart) .to.have.property('ordered'); @@ -88,22 +80,22 @@ describe('orderedDateAxis', function () { it('relies on agg.buckets for the interval', function () { const args = _.cloneDeep(baseArgs); - const spy = sinon.spy(args.chart.aspects.x.agg.buckets, 'getInterval'); - orderedDateAxis(args.vis, args.chart); + const spy = sinon.spy(args.chart.aspects.x.aggConfig.buckets, 'getInterval'); + orderedDateAxis(args.chart); expect(spy).to.have.property('callCount', 1); }); it('sets the min/max when the buckets are bounded', function () { const args = _.cloneDeep(baseArgs); - orderedDateAxis(args.vis, args.chart); + orderedDateAxis(args.chart); expect(moment.isMoment(args.chart.ordered.min)).to.be(true); expect(moment.isMoment(args.chart.ordered.max)).to.be(true); }); it('does not set the min/max when the buckets are unbounded', function () { const args = _.cloneDeep(baseArgs); - args.chart.aspects.x.agg.buckets.getBounds = _.constant(); - orderedDateAxis(args.vis, args.chart); + args.chart.aspects.x.aggConfig.buckets.getBounds = _.constant(); + orderedDateAxis(args.chart); expect(args.chart.ordered).to.not.have.property('min'); expect(args.chart.ordered).to.not.have.property('max'); }); diff --git a/src/ui/public/agg_response/point_series/__tests__/point_series.js b/src/ui/public/agg_response/point_series/__tests__/point_series.js index ff56ce1f12bca8..a649b7f38fc915 100644 --- a/src/ui/public/agg_response/point_series/__tests__/point_series.js +++ b/src/ui/public/agg_response/point_series/__tests__/point_series.js @@ -17,15 +17,16 @@ * under the License. */ -import './_main'; -import './_add_to_siri'; -import './_fake_x_aspect'; -import './_get_aspects'; -import './_get_point'; -import './_get_series'; -import './_init_x_axis'; -import './_init_y_axis'; -import './_ordered_date_axis'; -import './_tooltip_formatter'; + describe('Point Series Agg Response', function () { + require ('./_main'); + require('./_add_to_siri'); + require('./_fake_x_aspect'); + require('./_get_aspects'); + require('./_get_point'); + require('./_get_series'); + require('./_init_x_axis'); + require('./_init_y_axis'); + require('./_ordered_date_axis'); + require('./_tooltip_formatter'); }); diff --git a/src/ui/public/agg_response/point_series/_add_to_siri.js b/src/ui/public/agg_response/point_series/_add_to_siri.js index 855c11963014b9..34372819c2413c 100644 --- a/src/ui/public/agg_response/point_series/_add_to_siri.js +++ b/src/ui/public/agg_response/point_series/_add_to_siri.js @@ -17,21 +17,19 @@ * under the License. */ -export function PointSeriesAddToSiriProvider() { - return function addToSiri(series, point, id, label, agg) { - id = id == null ? '' : id + ''; +export function addToSiri(series, point, id, label, agg) { + id = id == null ? '' : id + ''; - if (series.has(id)) { - series.get(id).values.push(point); - return; - } + if (series.has(id)) { + series.get(id).values.push(point); + return; + } - series.set(id, { - label: label == null ? id : label, - aggLabel: agg.type ? agg.type.makeLabel(agg) : label, - aggId: agg.parentId ? agg.parentId : agg.id, - count: 0, - values: [point] - }); - }; + series.set(id, { + label: label == null ? id : label, + aggLabel: agg.type ? agg.type.makeLabel(agg) : label, + aggId: agg.parentId ? agg.parentId : agg.id, + count: 0, + values: [point] + }); } diff --git a/src/ui/public/agg_response/point_series/_fake_x_aspect.js b/src/ui/public/agg_response/point_series/_fake_x_aspect.js index a6088ec45a71da..0020778ef434db 100644 --- a/src/ui/public/agg_response/point_series/_fake_x_aspect.js +++ b/src/ui/public/agg_response/point_series/_fake_x_aspect.js @@ -17,31 +17,25 @@ * under the License. */ -import { AggConfig } from '../../vis/agg_config'; import { AggType } from '../../agg_types/agg_type'; -export function PointSeriesFakeXAxisProvider() { +const allAgg = new AggType({ + name: 'all', + title: 'All docs', + ordered: false, + hasNoDsl: true +}); - const allAgg = new AggType({ - name: 'all', - title: 'All docs', - ordered: false, - hasNoDsl: true - }); - - return function makeFakeXAxis(vis) { - const fake = new AggConfig(vis, { - type: allAgg, - schema: vis.type.schemas.all.byName.segment - }); +export function makeFakeXAspect() { + const fake = { + makeLabel: () => 'all', + fieldFormatter: () => '', + type: allAgg + }; - return { - i: -1, - agg: fake, - col: { - aggConfig: fake, - label: fake.makeLabel() - } - }; + return { + i: -1, + aggConfig: fake, + title: fake.makeLabel(), }; } diff --git a/src/ui/public/agg_response/point_series/_get_aspects.js b/src/ui/public/agg_response/point_series/_get_aspects.js index b1f8539618686c..de5cc4e9f403d3 100644 --- a/src/ui/public/agg_response/point_series/_get_aspects.js +++ b/src/ui/public/agg_response/point_series/_get_aspects.js @@ -18,61 +18,57 @@ */ import _ from 'lodash'; -import { PointSeriesFakeXAxisProvider } from './_fake_x_aspect'; +import { makeFakeXAspect } from './_fake_x_aspect'; -export function PointSeriesGetAspectsProvider(Private) { - const fakeXAspect = Private(PointSeriesFakeXAxisProvider); +const map = { + segment: 'x', + metric: 'y', + radius: 'z', + width: 'width', + group: 'series' +}; - const map = { - segment: 'x', - metric: 'y', - radius: 'z', - width: 'width', - group: 'series' - }; - - function columnToAspect(aspects, col, i) { - const schema = col.aggConfig.schema.name; +function columnToAspect(aspects, col, i) { + const schema = col.aggConfig.schema.name; - const name = map[schema]; - if (!name) throw new TypeError('unknown schema name "' + schema + '"'); + const name = map[schema]; + if (!name) throw new TypeError('unknown schema name "' + schema + '"'); - const aspect = { - i: i, - col: col, - agg: col.aggConfig - }; + const aspect = { + i: i, + title: col.title, + aggConfig: col.aggConfig + }; - if (!aspects[name]) aspects[name] = []; - aspects[name].push(aspect); - } + if (!aspects[name]) aspects[name] = []; + aspects[name].push(aspect); +} - /** - * Identify and group the columns based on the aspect of the pointSeries - * they represent. - * - * @param {array} columns - the list of columns - * @return {object} - an object with a key for each aspect (see map). The values - * may be undefined, a single aspect, or an array of aspects. - */ - return function getAspects(vis, table) { - const aspects = _(table.columns) - // write each column into the aspects under it's group - .transform(columnToAspect, {}) - // unwrap groups that only have one value, and validate groups that have more - .transform(function (aspects, group, name) { - if ((name !== 'y' && name !== 'series') && group.length > 1) { - throw new TypeError('Only multiple metrics and series are supported in point series'); - } +/** + * Identify and group the columns based on the aspect of the pointSeries + * they represent. + * + * @param {array} columns - the list of columns + * @return {object} - an object with a key for each aspect (see map). The values + * may be undefined, a single aspect, or an array of aspects. + */ +export function getAspects(table) { + const aspects = _(table.columns) + // write each column into the aspects under it's group + .transform(columnToAspect, {}) + // unwrap groups that only have one value, and validate groups that have more + .transform(function (aspects, group, name) { + if ((name !== 'y' && name !== 'series') && group.length > 1) { + throw new TypeError('Only multiple metrics and series are supported in point series'); + } - aspects[name] = group.length > 1 ? group : group[0]; - }) - .value(); + aspects[name] = group.length > 1 ? group : group[0]; + }) + .value(); - if (!aspects.x) { - aspects.x = fakeXAspect(vis); - } + if (!aspects.x) { + aspects.x = makeFakeXAspect(); + } - return aspects; - }; + return aspects; } diff --git a/src/ui/public/agg_response/point_series/_get_point.js b/src/ui/public/agg_response/point_series/_get_point.js index aa914efa7e0a3e..cd46cc7e1217d4 100644 --- a/src/ui/public/agg_response/point_series/_get_point.js +++ b/src/ui/public/agg_response/point_series/_get_point.js @@ -19,45 +19,43 @@ import _ from 'lodash'; -export function PointSeriesGetPointProvider() { - function unwrap(aggConfigResult, def) { - return aggConfigResult ? aggConfigResult.value : def; - } +function unwrap(aggConfigResult, def) { + return aggConfigResult ? aggConfigResult.value : def; +} - return function getPoint(x, series, yScale, row, y, z) { - const zRow = z && row[z.i]; - const xRow = row[x.i]; - - const point = { - x: unwrap(xRow, '_all'), - y: unwrap(row[y.i]), - z: zRow && unwrap(zRow), - aggConfigResult: row[y.i], - extraMetrics: _.compact([zRow]), - yScale: yScale - }; - - if (point.y === 'NaN') { - // filter out NaN from stats - // from metrics that are not based at zero - return; - } - - if (series) { - const seriesArray = series.length ? series : [ series ]; - point.aggConfig = seriesArray[0].agg; - point.series = seriesArray.map(s => s.agg.fieldFormatter()(unwrap(row[s.i]))).join(' - '); - } else if (y) { - // If the data is not split up with a series aspect, then - // each point's "series" becomes the y-agg that produced it - point.aggConfig = y.col.aggConfig; - point.series = y.col.title; - } - - if (yScale) { - point.y *= yScale; - } - - return point; +export function getPoint(x, series, yScale, row, y, z) { + const zRow = z && row[z.i]; + const xRow = row[x.i]; + + const point = { + x: unwrap(xRow, '_all'), + y: unwrap(row[y.i]), + z: zRow && unwrap(zRow), + aggConfigResult: row[y.i], + extraMetrics: _.compact([zRow]), + yScale: yScale }; + + if (point.y === 'NaN') { + // filter out NaN from stats + // from metrics that are not based at zero + return; + } + + if (series) { + const seriesArray = series.length ? series : [ series ]; + point.aggConfig = seriesArray[0].aggConfig; + point.series = seriesArray.map(s => s.aggConfig.fieldFormatter()(unwrap(row[s.i]))).join(' - '); + } else if (y) { + // If the data is not split up with a series aspect, then + // each point's "series" becomes the y-agg that produced it + point.aggConfig = y.aggConfig; + point.series = y.title; + } + + if (yScale) { + point.y *= yScale; + } + + return point; } diff --git a/src/ui/public/agg_response/point_series/_get_series.js b/src/ui/public/agg_response/point_series/_get_series.js index 24ac1a589f53ba..8b516f6a0a4210 100644 --- a/src/ui/public/agg_response/point_series/_get_series.js +++ b/src/ui/public/agg_response/point_series/_get_series.js @@ -18,66 +18,61 @@ */ import _ from 'lodash'; -import { PointSeriesGetPointProvider } from './_get_point'; -import { PointSeriesAddToSiriProvider } from './_add_to_siri'; +import { getPoint } from './_get_point'; +import { addToSiri } from './_add_to_siri'; -export function PointSeriesGetSeriesProvider(Private) { - const getPoint = Private(PointSeriesGetPointProvider); - const addToSiri = Private(PointSeriesAddToSiriProvider); +export function getSeries(rows, chart) { + const aspects = chart.aspects; + const multiY = Array.isArray(aspects.y); + const yScale = chart.yScale; + const partGetPoint = _.partial(getPoint, aspects.x, aspects.series, yScale); - return function getSeries(rows, chart) { - const aspects = chart.aspects; - const multiY = Array.isArray(aspects.y); - const yScale = chart.yScale; - const partGetPoint = _.partial(getPoint, aspects.x, aspects.series, yScale); + let series = _(rows) + .transform(function (series, row) { + if (!multiY) { + const point = partGetPoint(row, aspects.y, aspects.z); + if (point) addToSiri(series, point, point.series, point.series, aspects.y.aggConfig); + return; + } - let series = _(rows) - .transform(function (series, row) { - if (!multiY) { - const point = partGetPoint(row, aspects.y, aspects.z); - if (point) addToSiri(series, point, point.series, point.series, aspects.y.agg); - return; - } - - aspects.y.forEach(function (y) { - const point = partGetPoint(row, y, aspects.z); - if (!point) return; + aspects.y.forEach(function (y) { + const point = partGetPoint(row, y, aspects.z); + if (!point) return; - // use the point's y-axis as it's series by default, - // but augment that with series aspect if it's actually - // available - let seriesId = y.agg.id; - let seriesLabel = y.col.title; + // use the point's y-axis as it's series by default, + // but augment that with series aspect if it's actually + // available + let seriesId = y.aggConfig.id; + let seriesLabel = y.title; - if (aspects.series) { - const prefix = point.series ? point.series + ': ' : ''; - seriesId = prefix + seriesId; - seriesLabel = prefix + seriesLabel; - } + if (aspects.series) { + const prefix = point.series ? point.series + ': ' : ''; + seriesId = prefix + seriesId; + seriesLabel = prefix + seriesLabel; + } - addToSiri(series, point, seriesId, seriesLabel, y.agg); - }); + addToSiri(series, point, seriesId, seriesLabel, y.aggConfig); + }); - }, new Map()) - .thru(series => [...series.values()]) - .value(); + }, new Map()) + .thru(series => [...series.values()]) + .value(); - if (multiY) { - series = _.sortBy(series, function (siri) { - const firstVal = siri.values[0]; - let y; + if (multiY) { + series = _.sortBy(series, function (siri) { + const firstVal = siri.values[0]; + let y; - if (firstVal) { - const agg = firstVal.aggConfigResult.aggConfig; - y = _.find(aspects.y, function (y) { - return y.agg === agg; - }); - } + if (firstVal) { + const agg = firstVal.aggConfigResult.aggConfig; + y = _.find(aspects.y, function (y) { + return y.aggConfig === agg; + }); + } - return y ? y.i : series.length; - }); - } + return y ? y.i : series.length; + }); + } - return series; - }; + return series; } diff --git a/src/ui/public/agg_response/point_series/_init_x_axis.js b/src/ui/public/agg_response/point_series/_init_x_axis.js index d07cbc5ba49c37..2a37bbd1519871 100644 --- a/src/ui/public/agg_response/point_series/_init_x_axis.js +++ b/src/ui/public/agg_response/point_series/_init_x_axis.js @@ -18,21 +18,19 @@ */ -export function PointSeriesInitXAxisProvider() { - return function initXAxis(chart) { - const x = chart.aspects.x; - chart.xAxisFormatter = x.agg ? x.agg.fieldFormatter() : String; - chart.xAxisLabel = x.col.title; +export function initXAxis(chart) { + const x = chart.aspects.x; + chart.xAxisFormatter = x.aggConfig ? x.aggConfig.fieldFormatter() : String; + chart.xAxisLabel = x.title; - if (!x.agg || !x.agg.type.ordered) return; + if (!x.aggConfig || !x.aggConfig.type.ordered) return; - chart.indexPattern = x.agg.vis.indexPattern; - chart.xAxisField = x.agg.params.field; + chart.indexPattern = x.aggConfig.getIndexPattern(); + chart.xAxisField = x.aggConfig.params.field; - chart.ordered = {}; - const xAggOutput = x.agg.write(); - if (xAggOutput.params.interval) { - chart.ordered.interval = xAggOutput.params.interval; - } - }; + chart.ordered = {}; + const xAggOutput = x.aggConfig.write(); + if (xAggOutput.params.interval && xAggOutput.params.interval !== '0ms') { + chart.ordered.interval = xAggOutput.params.interval; + } } diff --git a/src/ui/public/agg_response/point_series/_init_y_axis.js b/src/ui/public/agg_response/point_series/_init_y_axis.js index dd3165041547dc..e9033e74a71f28 100644 --- a/src/ui/public/agg_response/point_series/_init_y_axis.js +++ b/src/ui/public/agg_response/point_series/_init_y_axis.js @@ -17,29 +17,26 @@ * under the License. */ -export function PointSeriesInitYAxisProvider() { +export function initYAxis(chart) { + const y = chart.aspects.y; - return function initYAxis(chart) { - const y = chart.aspects.y; + if (Array.isArray(y)) { + // TODO: vis option should allow choosing this format + chart.yAxisFormatter = y[0].aggConfig.fieldFormatter(); + chart.yAxisLabel = ''; // use the legend + } else { + chart.yAxisFormatter = y.aggConfig.fieldFormatter(); + chart.yAxisLabel = y.title; + } - if (Array.isArray(y)) { - // TODO: vis option should allow choosing this format - chart.yAxisFormatter = y[0].agg.fieldFormatter(); - chart.yAxisLabel = ''; // use the legend + const z = chart.aspects.series; + if (z) { + if (Array.isArray(z)) { + chart.zAxisFormatter = z[0].aggConfig.fieldFormatter(); + chart.zAxisLabel = ''; // use the legend } else { - chart.yAxisFormatter = y.agg.fieldFormatter(); - chart.yAxisLabel = y.col.title; + chart.zAxisFormatter = z.aggConfig.fieldFormatter(); + chart.zAxisLabel = z.title; } - - const z = chart.aspects.series; - if (z) { - if (Array.isArray(z)) { - chart.zAxisFormatter = z[0].agg.fieldFormatter(); - chart.zAxisLabel = ''; // use the legend - } else { - chart.zAxisFormatter = z.agg.fieldFormatter(); - chart.zAxisLabel = z.col.title; - } - } - }; + } } diff --git a/src/ui/public/agg_response/point_series/_ordered_date_axis.js b/src/ui/public/agg_response/point_series/_ordered_date_axis.js index e5d5f46883ec01..933e93aca2db81 100644 --- a/src/ui/public/agg_response/point_series/_ordered_date_axis.js +++ b/src/ui/public/agg_response/point_series/_ordered_date_axis.js @@ -19,29 +19,26 @@ import moment from 'moment'; -export function PointSeriesOrderedDateAxisProvider() { +export function orderedDateAxis(chart) { + const xAgg = chart.aspects.x.aggConfig; + const buckets = xAgg.buckets; + const format = buckets.getScaledDateFormat(); - return function orderedDateAxis(vis, chart) { - const xAgg = chart.aspects.x.agg; - const buckets = xAgg.buckets; - const format = buckets.getScaledDateFormat(); - - chart.xAxisFormatter = function (val) { - return moment(val).format(format); - }; - - chart.ordered = { - date: true, - interval: buckets.getInterval(), - }; + chart.xAxisFormatter = function (val) { + return moment(val).format(format); + }; - const axisOnTimeField = xAgg.fieldIsTimeField(); - const bounds = buckets.getBounds(); - if (bounds && axisOnTimeField) { - chart.ordered.min = bounds.min; - chart.ordered.max = bounds.max; - } else { - chart.ordered.endzones = false; - } + chart.ordered = { + date: true, + interval: buckets.getInterval(), }; + + const axisOnTimeField = xAgg.fieldIsTimeField(); + const bounds = buckets.getBounds(); + if (bounds && axisOnTimeField) { + chart.ordered.min = bounds.min; + chart.ordered.max = bounds.max; + } else { + chart.ordered.endzones = false; + } } diff --git a/src/ui/public/agg_response/point_series/point_series.js b/src/ui/public/agg_response/point_series/point_series.js index 6dce54ee9b3c94..10cbd4726034e1 100644 --- a/src/ui/public/agg_response/point_series/point_series.js +++ b/src/ui/public/agg_response/point_series/point_series.js @@ -17,34 +17,29 @@ * under the License. */ -import { PointSeriesGetSeriesProvider } from './_get_series'; -import { PointSeriesGetAspectsProvider } from './_get_aspects'; -import { PointSeriesInitYAxisProvider } from './_init_y_axis'; -import { PointSeriesInitXAxisProvider } from './_init_x_axis'; -import { PointSeriesOrderedDateAxisProvider } from './_ordered_date_axis'; +import { getSeries } from './_get_series'; +import { getAspects } from './_get_aspects'; +import { initYAxis } from './_init_y_axis'; +import { initXAxis } from './_init_x_axis'; +import { orderedDateAxis } from './_ordered_date_axis'; import { PointSeriesTooltipFormatter } from './_tooltip_formatter'; export function AggResponsePointSeriesProvider(Private) { - const getSeries = Private(PointSeriesGetSeriesProvider); - const getAspects = Private(PointSeriesGetAspectsProvider); - const initYAxis = Private(PointSeriesInitYAxisProvider); - const initXAxis = Private(PointSeriesInitXAxisProvider); - const setupOrderedDateXAxis = Private(PointSeriesOrderedDateAxisProvider); const tooltipFormatter = Private(PointSeriesTooltipFormatter); - return function pointSeriesChartDataFromTable(vis, table) { + return function pointSeriesChartDataFromTable(table) { const chart = {}; - const aspects = chart.aspects = getAspects(vis, table); + const aspects = chart.aspects = getAspects(table); chart.tooltipFormatter = tooltipFormatter; initXAxis(chart); initYAxis(chart); - const datedX = aspects.x.agg.type.ordered && aspects.x.agg.type.ordered.date; + const datedX = aspects.x.aggConfig.type.ordered && aspects.x.aggConfig.type.ordered.date; if (datedX) { - setupOrderedDateXAxis(vis, chart); + orderedDateAxis(chart); } chart.series = getSeries(table.rows, chart); diff --git a/src/ui/public/agg_response/tabify/__tests__/_buckets.js b/src/ui/public/agg_response/tabify/__tests__/_buckets.js index 785a62ff82657e..8be13c1ad6c129 100644 --- a/src/ui/public/agg_response/tabify/__tests__/_buckets.js +++ b/src/ui/public/agg_response/tabify/__tests__/_buckets.js @@ -80,4 +80,79 @@ describe('Buckets wrapper', function () { expect(buckets).to.have.length(1); }); }); + + describe('drop_partial option', function () { + const aggResp = { + buckets: [ + { key: 0, value: {} }, + { key: 100, value: {} }, + { key: 200, value: {} }, + { key: 300, value: {} } + ] + }; + + it('drops partial buckets when enabled', function () { + const aggParams = { + drop_partials: true, + field: { + name: 'date' + } + }; + const timeRange = { + gte: 150, + lte: 350, + name: 'date' + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + expect(buckets).to.have.length(1); + }); + + it('keeps partial buckets when disabled', function () { + const aggParams = { + drop_partials: false, + field: { + name: 'date' + } + }; + const timeRange = { + gte: 150, + lte: 350, + name: 'date' + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + expect(buckets).to.have.length(4); + }); + + it('keeps aligned buckets when enabled', function () { + const aggParams = { + drop_partials: true, + field: { + name: 'date' + } + }; + const timeRange = { + gte: 100, + lte: 400, + name: 'date' + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + expect(buckets).to.have.length(3); + }); + + it('does not drop buckets for non-timerange fields', function () { + const aggParams = { + drop_partials: true, + field: { + name: 'other_time' + } + }; + const timeRange = { + gte: 150, + lte: 350, + name: 'date' + }; + const buckets = new TabifyBuckets(aggResp, aggParams, timeRange); + expect(buckets).to.have.length(4); + }); + }); }); diff --git a/src/ui/public/agg_response/tabify/__tests__/_get_columns.js b/src/ui/public/agg_response/tabify/__tests__/_get_columns.js index fe01fe0913782d..fbc42aaa04c377 100644 --- a/src/ui/public/agg_response/tabify/__tests__/_get_columns.js +++ b/src/ui/public/agg_response/tabify/__tests__/_get_columns.js @@ -52,7 +52,7 @@ describe('get columns', function () { ] }); - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), null, vis.isHierarchical()); + const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); expect(columns).to.have.length(2); expect(columns[1]).to.have.property('aggConfig'); @@ -70,7 +70,7 @@ describe('get columns', function () { ] }); - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), null, vis.isHierarchical()); + const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); expect(columns).to.have.length(8); columns.forEach(function (column, i) { @@ -92,7 +92,7 @@ describe('get columns', function () { ] }); - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), null, vis.isHierarchical()); + const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); function checkColumns(column, i) { expect(column).to.have.property('aggConfig'); @@ -128,7 +128,7 @@ describe('get columns', function () { ] }); - const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), null, vis.isHierarchical()); + const columns = tabifyGetColumns(vis.getAggConfig().getResponseAggs(), !vis.isHierarchical()); expect(columns).to.have.length(6); // sum should be last diff --git a/src/ui/public/agg_response/tabify/__tests__/_integration.js b/src/ui/public/agg_response/tabify/__tests__/_integration.js index b06293fa480934..e9a99bc33d2015 100644 --- a/src/ui/public/agg_response/tabify/__tests__/_integration.js +++ b/src/ui/public/agg_response/tabify/__tests__/_integration.js @@ -49,16 +49,14 @@ describe('tabifyAggResponse Integration', function () { normalizeIds(vis); const resp = tabifyAggResponse(vis.getAggConfig(), fixtures.metricOnly, { - canSplit: false, - isHierarchical: vis.isHierarchical() + metricsAtAllLevels: vis.isHierarchical() }); - expect(resp).to.not.have.property('tables'); expect(resp).to.have.property('rows').and.property('columns'); expect(resp.rows).to.have.length(1); expect(resp.columns).to.have.length(1); - expect(resp.rows[0]).to.eql([1000]); + expect(resp.rows[0]).to.eql({ 'col-0-agg_1': 1000 }); expect(resp.columns[0]).to.have.property('aggConfig', vis.aggs[0]); }); @@ -94,33 +92,6 @@ describe('tabifyAggResponse Integration', function () { esResp.aggregations.agg_2.buckets[1].agg_3.buckets[0].agg_4.buckets = []; }); - // check that the root table group is formed properly, then pass - // each table to expectExtensionSplit, along with the expectInnerTables() - // function. - function expectRootGroup(rootTableGroup, expectInnerTables) { - expect(rootTableGroup).to.have.property('tables'); - - const tables = rootTableGroup.tables; - expect(tables).to.be.an('array').and.have.length(3); - expectExtensionSplit(tables[0], 'png', expectInnerTables); - expectExtensionSplit(tables[1], 'css', expectInnerTables); - expectExtensionSplit(tables[2], 'html', expectInnerTables); - } - - // check that the tableGroup for the extension agg was formed properly - // then call expectTable() on each table inside. it should validate that - // each table is formed properly - function expectExtensionSplit(tableGroup, key, expectTable) { - expect(tableGroup).to.have.property('tables'); - expect(tableGroup).to.have.property('aggConfig', ext); - expect(tableGroup).to.have.property('key', key); - expect(tableGroup.tables).to.be.an('array').and.have.length(1); - - tableGroup.tables.forEach(function (table) { - expectTable(table, key); - }); - } - // check that the columns of a table are formed properly function expectColumns(table, aggs) { expect(table.columns).to.be.an('array').and.have.length(aggs.length); @@ -131,10 +102,11 @@ describe('tabifyAggResponse Integration', function () { // check that a row has expected values function expectRow(row, asserts) { - expect(row).to.be.an('array'); - expect(row).to.have.length(asserts.length); + expect(row).to.be.an('object'); asserts.forEach(function (assert, i) { - assert(row[i]); + if (row[`col-${i}`]) { + assert(row[`col-${i}`]); + } }); } @@ -144,10 +116,10 @@ describe('tabifyAggResponse Integration', function () { expect(val).to.have.length(2); } - // check for an empty cell - function expectEmpty(val) { + // check for an OS term + function expectExtension(val) { expect(val) - .to.be(''); + .to.match(/^(js|png|html|css|jpg)$/); } // check for an OS term @@ -162,127 +134,44 @@ describe('tabifyAggResponse Integration', function () { expect(val === 0 || val > 1000).to.be.ok(); } - // create an assert that checks for an expected value - function expectVal(expected) { - return function (val) { - expect(val).to.be(expected); - }; - } - it('for non-hierarchical vis', function () { // the default for a non-hierarchical vis is to display // only complete rows, and only put the metrics at the end. - vis.isHierarchical = _.constant(false); - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { isHierarchical: vis.isHierarchical() }); + const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { minimalColumns: true }); - expectRootGroup(tabbed, function expectTable(table, splitKey) { - expectColumns(table, [src, os, avg]); + expectColumns(tabbed, [ext, src, os, avg]); - table.rows.forEach(function (row) { - if (splitKey === 'css' && row[0] === 'MX') { - throw new Error('expected the MX row in the css table to be removed'); - } else { - expectRow(row, [ - expectCountry, - expectOS, - expectAvgBytes - ]); - } - }); + tabbed.rows.forEach(function (row) { + expectRow(row, [ + expectExtension, + expectCountry, + expectOS, + expectAvgBytes + ]); }); }); - it('for hierarchical vis, with partial rows', function () { + it('for hierarchical vis', function () { // since we have partialRows we expect that one row will have some empty // values, and since the vis is hierarchical and we are NOT using // minimalColumns we should expect the partial row to be completely after // the existing bucket and it's metric vis.isHierarchical = _.constant(true); - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { - partialRows: true, - isHierarchical: vis.isHierarchical() - }); - - expectRootGroup(tabbed, function expectTable(table, splitKey) { - expectColumns(table, [src, avg, os, avg]); - - table.rows.forEach(function (row) { - if (splitKey === 'css' && row[0] === 'MX') { - expectRow(row, [ - expectCountry, - expectAvgBytes, - expectEmpty, - expectEmpty - ]); - } else { - expectRow(row, [ - expectCountry, - expectAvgBytes, - expectOS, - expectAvgBytes - ]); - } - }); - }); - }); - - it('for hierarchical vis, with partial rows, and minimal columns', function () { - // since we have partialRows we expect that one row has some empty - // values, and since the vis is hierarchical and we are displaying using - // minimalColumns, we should expect the partial row to have a metric at - // the end - - vis.isHierarchical = _.constant(true); - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { - partialRows: true, - minimalColumns: true, - isHierarchical: vis.isHierarchical() - }); - - expectRootGroup(tabbed, function expectTable(table, splitKey) { - expectColumns(table, [src, os, avg]); - - table.rows.forEach(function (row) { - if (splitKey === 'css' && row[0] === 'MX') { - expectRow(row, [ - expectCountry, - expectEmpty, - expectVal(9299) - ]); - } else { - expectRow(row, [ - expectCountry, - expectOS, - expectAvgBytes - ]); - } - }); - }); - }); - - it('for non-hierarchical vis, minimal columns set to false', function () { - // the reason for this test is mainly to check that setting - // minimalColumns = false on a non-hierarchical vis doesn't - // create metric columns after each bucket - - vis.isHierarchical = _.constant(false); - const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { - minimalColumns: false, - isHierarchical: vis.isHierarchical() - }); - - expectRootGroup(tabbed, function expectTable(table) { - expectColumns(table, [src, os, avg]); - - table.rows.forEach(function (row) { - expectRow(row, [ - expectCountry, - expectOS, - expectAvgBytes - ]); - }); + const tabbed = tabifyAggResponse(vis.getAggConfig(), esResp, { metricsAtAllLevels: true }); + + expectColumns(tabbed, [ext, avg, src, avg, os, avg]); + + tabbed.rows.forEach(function (row) { + expectRow(row, [ + expectExtension, + expectAvgBytes, + expectCountry, + expectAvgBytes, + expectOS, + expectAvgBytes + ]); }); }); }); diff --git a/src/ui/public/agg_response/tabify/__tests__/_response_writer.js b/src/ui/public/agg_response/tabify/__tests__/_response_writer.js index 3835e4ceb33985..b395467146762b 100644 --- a/src/ui/public/agg_response/tabify/__tests__/_response_writer.js +++ b/src/ui/public/agg_response/tabify/__tests__/_response_writer.js @@ -17,13 +17,9 @@ * under the License. */ -import _ from 'lodash'; -import sinon from 'sinon'; import expect from 'expect.js'; import ngMock from 'ng_mock'; import { TabbedAggResponseWriter } from '../_response_writer'; -import { TabifyTableGroup } from '../_table_group'; -import { TabifyBuckets } from '../_buckets'; import { VisProvider } from '../../../vis'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; @@ -32,363 +28,152 @@ describe('TabbedAggResponseWriter class', function () { let Private; let indexPattern; - function defineSetup() { - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function ($injector) { - Private = $injector.get('Private'); - - Vis = Private(VisProvider); - indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - })); - } + beforeEach(ngMock.module('kibana')); + beforeEach(ngMock.inject(function ($injector) { + Private = $injector.get('Private'); + + Vis = Private(VisProvider); + indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + })); + + const splitAggConfig = [ { + type: 'terms', + params: { + field: 'geo.src', + } + }]; + + const twoSplitsAggConfig = [{ + type: 'terms', + params: { + field: 'geo.src', + } + }, { + type: 'terms', + params: { + field: 'machine.os.raw', + } + }]; + + const createResponseWritter = (aggs = [], opts = {}) => { + const vis = new Vis(indexPattern, { type: 'histogram', aggs: aggs }); + return new TabbedAggResponseWriter(vis.getAggConfig(), opts); + }; describe('Constructor', function () { - defineSetup(); - - it('sets canSplit=true by default', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - expect(writer).to.have.property('canSplit', true); + let responseWriter; + beforeEach(() => { + responseWriter = createResponseWritter(twoSplitsAggConfig); }); - it('sets canSplit=false when config says to', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - canSplit: false, - isHierarchical: vis.isHierarchical() - }); - expect(writer).to.have.property('canSplit', false); + it('creates aggStack', () => { + expect(responseWriter.aggStack.length).to.eql(3); }); - describe('sets partialRows', function () { - it('to the value of the config if set', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const partial = Boolean(Math.round(Math.random())); - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical(), - partialRows: partial - }); - expect(writer).to.have.property('partialRows', partial); - }); - - it('to the value of vis.isHierarchical if no config', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const hierarchical = Boolean(Math.round(Math.random())); - sinon.stub(vis, 'isHierarchical').returns(hierarchical); - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - expect(writer).to.have.property('partialRows', hierarchical); - }); + it('generates columns', () => { + expect(responseWriter.columns.length).to.eql(3); }); - it('starts off with a root TabifyTableGroup', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - expect(writer.root).to.be.a(TabifyTableGroup); - expect(writer.splitStack).to.be.an('array'); - expect(writer.splitStack).to.have.length(1); - expect(writer.splitStack[0]).to.be(writer.root); + it('correctly generates columns with metricsAtAllLevels set to true', () => { + const minimalColumnsResponseWriter = createResponseWritter(twoSplitsAggConfig, { metricsAtAllLevels: true }); + expect(minimalColumnsResponseWriter.columns.length).to.eql(4); }); - }); - - describe('', function () { - defineSetup(); - describe('#response()', function () { - it('returns the root TabifyTableGroup if splitting', function () { + describe('sets timeRange', function () { + it('to the first nested object\'s range', function () { const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - expect(writer.response()).to.be(writer.root); - }); + const range = { + gte: 0, + lte: 100 + }; - it('returns the first table if not splitting', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical(), - canSplit: false - }); - const table = writer._table(); - expect(writer.response()).to.be(table); - }); - - it('adds columns to all of the tables', function () { - const vis = new Vis(indexPattern, { - type: 'histogram', - aggs: [ - { type: 'terms', params: { field: '_type' }, schema: 'split' }, - { type: 'count', schema: 'metric' } - ] - }); - const buckets = new TabifyBuckets({ buckets: [ { key: 'nginx' }, { key: 'apache' } ] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - const tables = []; - - writer.split(vis.aggs[0], buckets, function () { - writer.cell(vis.aggs[1], 100, function () { - tables.push(writer.row()); - }); - }); - - tables.forEach(function (table) { - expect(table.columns == null).to.be(true); - }); - - const resp = writer.response(); - expect(resp).to.be.a(TabifyTableGroup); - expect(resp.tables).to.have.length(2); - - const nginx = resp.tables.shift(); - expect(nginx).to.have.property('aggConfig', vis.aggs[0]); - expect(nginx).to.have.property('key', 'nginx'); - expect(nginx.tables).to.have.length(1); - nginx.tables.forEach(function (table) { - expect(_.contains(tables, table)).to.be(true); - }); - - const apache = resp.tables.shift(); - expect(apache).to.have.property('aggConfig', vis.aggs[0]); - expect(apache).to.have.property('key', 'apache'); - expect(apache.tables).to.have.length(1); - apache.tables.forEach(function (table) { - expect(_.contains(tables, table)).to.be(true); - }); - - tables.forEach(function (table) { - expect(table.columns).to.be.an('array'); - expect(table.columns).to.have.length(1); - expect(table.columns[0].aggConfig.type.name).to.be('count'); - }); - }); - }); - - describe('#split()', function () { - it('with break if the user has specified that splitting is to be disabled', function () { - const vis = new Vis(indexPattern, { - type: 'histogram', - aggs: [ - { type: 'terms', schema: 'split', params: { field: '_type' } }, - { type: 'count', schema: 'metric' } - ] - }); - const agg = vis.aggs.bySchemaName.split[0]; - const buckets = new TabifyBuckets({ buckets: [ { key: 'apache' } ] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical(), - canSplit: false + timeRange: { + '@timestamp': range + } }); - expect(function () { - writer.split(agg, buckets, _.noop); - }).to.throwException(/splitting is disabled/); + expect(writer.timeRange.gte).to.be(range.gte); + expect(writer.timeRange.lte).to.be(range.lte); + expect(writer.timeRange.name).to.be('@timestamp'); }); - it('forks the acrStack and rewrites the parents', function () { - const vis = new Vis(indexPattern, { - type: 'histogram', - aggs: [ - { type: 'terms', params: { field: 'extension' }, schema: 'segment' }, - { type: 'terms', params: { field: '_type' }, schema: 'split' }, - { type: 'terms', params: { field: 'machine.os' }, schema: 'segment' }, - { type: 'count', schema: 'metric' } - ] - }); + it('to undefined if no nested object', function () { + const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical(), - asAggConfigResults: true - }); - const extensions = new TabifyBuckets({ buckets: [ { key: 'jpg' }, { key: 'png' } ] }); - const types = new TabifyBuckets({ buckets: [ { key: 'nginx' }, { key: 'apache' } ] }); - const os = new TabifyBuckets({ buckets: [ { key: 'window' }, { key: 'osx' } ] }); - - extensions.forEach(function (b, extension) { - writer.cell(vis.aggs[0], extension, function () { - writer.split(vis.aggs[1], types, function () { - os.forEach(function (b, os) { - writer.cell(vis.aggs[2], os, function () { - writer.cell(vis.aggs[3], 200, function () { - writer.row(); - }); - }); - }); - }); - }); - }); - - const tables = _.flattenDeep(_.pluck(writer.response().tables, 'tables')); - expect(tables.length).to.be(types.length); - - // collect the far left acr from each table - const leftAcrs = _.pluck(tables, 'rows[0][0]'); - - leftAcrs.forEach(function (acr, i, acrs) { - expect(acr.aggConfig).to.be(vis.aggs[0]); - expect(acr.$parent.aggConfig).to.be(vis.aggs[1]); - expect(acr.$parent.$parent).to.be(void 0); - - // for all but the last acr, compare to the next - if (i + 1 >= acrs.length) return; - const acr2 = leftAcrs[i + 1]; - - expect(acr.key).to.be(acr2.key); - expect(acr.value).to.be(acr2.value); - expect(acr.aggConfig).to.be(acr2.aggConfig); - expect(acr.$parent).to.not.be(acr2.$parent); + timeRange: {} }); + expect(writer).to.have.property('timeRange', undefined); }); - - }); + }); - describe('#cell()', function () { - it('logs a cell in the TabbedAggResponseWriters row buffer, calls the block arg, then removes the value from the buffer', - function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); + describe('row()', function () { + let responseWriter; - expect(writer.rowBuffer).to.have.length(0); - writer.cell({}, 500, function () { - expect(writer.rowBuffer).to.have.length(1); - expect(writer.rowBuffer[0]).to.be(500); - }); - expect(writer.rowBuffer).to.have.length(0); - }); + beforeEach(() => { + responseWriter = createResponseWritter(splitAggConfig, { partialRows: true }); }); - describe('#row()', function () { - it('writes the TabbedAggResponseWriters internal rowBuffer into a table', function () { - const vis = new Vis(indexPattern, { type: 'histogram', aggs: [] }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - - const table = writer._table(); - writer.cell({}, 1, function () { - writer.cell({}, 2, function () { - writer.cell({}, 3, function () { - writer.row(); - }); - }); - }); - - expect(table.rows).to.have.length(1); - expect(table.rows[0]).to.eql([1, 2, 3]); - }); - - it('always writes to the table group at the top of the split stack', function () { - const vis = new Vis(indexPattern, { - type: 'histogram', - aggs: [ - { type: 'terms', schema: 'split', params: { field: '_type' } }, - { type: 'terms', schema: 'split', params: { field: 'extension' } }, - { type: 'terms', schema: 'split', params: { field: 'machine.os' } }, - { type: 'count', schema: 'metric' } - ] - }); - const splits = vis.aggs.bySchemaName.split; - - const type = splits[0]; - const typeTabifyBuckets = new TabifyBuckets({ buckets: [ { key: 'nginx' }, { key: 'apache' } ] }); - - const ext = splits[1]; - const extTabifyBuckets = new TabifyBuckets({ buckets: [ { key: 'jpg' }, { key: 'png' } ] }); - - const os = splits[2]; - const osTabifyBuckets = new TabifyBuckets({ buckets: [ { key: 'windows' }, { key: 'mac' } ] }); - - const count = vis.aggs[3]; - - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - writer.split(type, typeTabifyBuckets, function () { - writer.split(ext, extTabifyBuckets, function () { - writer.split(os, osTabifyBuckets, function (bucket, key) { - writer.cell(count, key === 'windows' ? 1 : 2, function () { - writer.row(); - }); - }); - }); - }); - - const resp = writer.response(); - let sum = 0; - let tables = 0; - (function recurse(t) { - if (t.tables) { - // table group - t.tables.forEach(function (tt) { - recurse(tt); - }); - } else { - tables += 1; - // table - t.rows.forEach(function (row) { - row.forEach(function (cell) { - sum += cell; - }); - }); - } - }(resp)); - - expect(tables).to.be(8); - expect(sum).to.be(12); - }); + it('adds the row to the array', () => { + responseWriter.rowBuffer['col-0'] = 'US'; + responseWriter.rowBuffer['col-1'] = 5; + responseWriter.row(); + expect(responseWriter.rows.length).to.eql(1); + expect(responseWriter.rows[0]).to.eql({ 'col-0': 'US', 'col-1': 5 }); + }); - it('writes partial rows for hierarchical vis', function () { - const vis = new Vis(indexPattern, { - type: 'pie', - aggs: [ - { type: 'terms', schema: 'segment', params: { field: '_type' } }, - { type: 'count', schema: 'metric' } - ] - }); + it('correctly handles bucketBuffer', () => { + responseWriter.bucketBuffer.push({ id: 'col-0', value: 'US' }); + responseWriter.rowBuffer['col-1'] = 5; + responseWriter.row(); + expect(responseWriter.rows.length).to.eql(1); + expect(responseWriter.rows[0]).to.eql({ 'col-0': 'US', 'col-1': 5 }); + }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - const table = writer._table(); - writer.cell(vis.aggs[0], 'apache', function () { - writer.row(); - }); + it('doesn\'t add an empty row', () => { + responseWriter.row(); + expect(responseWriter.rows.length).to.eql(0); + }); + }); - expect(table.rows).to.have.length(1); - expect(table.rows[0]).to.eql(['apache', '']); - }); + describe('response()', () => { + let responseWriter; - it('skips partial rows for non-hierarchical vis', function () { - const vis = new Vis(indexPattern, { - type: 'histogram', - aggs: [ - { type: 'terms', schema: 'segment', params: { field: '_type' } }, - { type: 'count', schema: 'metric' } - ] - }); + beforeEach(() => { + responseWriter = createResponseWritter(splitAggConfig); + }); - const writer = new TabbedAggResponseWriter(vis.getAggConfig(), { - isHierarchical: vis.isHierarchical() - }); - const table = writer._table(); - writer.cell(vis.aggs[0], 'apache', function () { - writer.row(); - }); + it('produces correct response', () => { + responseWriter.rowBuffer['col-0-1'] = 'US'; + responseWriter.rowBuffer['col-1-2'] = 5; + responseWriter.row(); + const response = responseWriter.response(); + expect(response).to.have.property('rows'); + expect(response.rows).to.eql([{ 'col-0-1': 'US', 'col-1-2': 5 }]); + expect(response).to.have.property('columns'); + expect(response.columns.length).to.equal(2); + expect(response.columns[0]).to.have.property('id', 'col-0-1'); + expect(response.columns[0]).to.have.property('name', 'geo.src: Descending'); + expect(response.columns[0]).to.have.property('aggConfig'); + expect(response.columns[1]).to.have.property('id', 'col-1-2'); + expect(response.columns[1]).to.have.property('name', 'Count'); + expect(response.columns[1]).to.have.property('aggConfig'); + }); - expect(table.rows).to.have.length(0); - }); + it('produces correct response for no data', () => { + const response = responseWriter.response(); + expect(response).to.have.property('rows'); + expect(response.rows.length).to.be(0); + expect(response).to.have.property('columns'); + expect(response.columns.length).to.equal(2); + expect(response.columns[0]).to.have.property('id', 'col-0-1'); + expect(response.columns[0]).to.have.property('name', 'geo.src: Descending'); + expect(response.columns[0]).to.have.property('aggConfig'); + expect(response.columns[1]).to.have.property('id', 'col-1-2'); + expect(response.columns[1]).to.have.property('name', 'Count'); + expect(response.columns[1]).to.have.property('aggConfig'); }); }); }); diff --git a/src/ui/public/agg_response/tabify/__tests__/_table.js b/src/ui/public/agg_response/tabify/__tests__/_table.js deleted file mode 100644 index 6248c6cb77af8c..00000000000000 --- a/src/ui/public/agg_response/tabify/__tests__/_table.js +++ /dev/null @@ -1,94 +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 expect from 'expect.js'; -import { TabifyTable } from '../_table'; - -describe('TabifyTable class', function () { - it('exposes rows array, but not the columns', function () { - const table = new TabifyTable(); - expect(table.rows).to.be.an('array'); - expect(table.columns == null).to.be.ok(); - }); - - describe('#aggConfig', function () { - it('accepts a column from the table and returns its agg config', function () { - const table = new TabifyTable(); - const football = {}; - const column = { - aggConfig: football - }; - - expect(table.aggConfig(column)).to.be(football); - }); - - it('throws a TypeError if the column is malformed', function () { - expect(function () { - const notAColumn = {}; - (new TabifyTable()).aggConfig(notAColumn); - }).to.throwException(TypeError); - }); - }); - - describe('#title', function () { - it('returns nothing if the table is not part of a table group', function () { - const table = new TabifyTable(); - expect(table.title()).to.be(''); - }); - - it('returns the title of the TabifyTableGroup if the table is part of one', function () { - const table = new TabifyTable(); - table.$parent = { - title: 'TabifyTableGroup Title', - tables: [table] - }; - - expect(table.title()).to.be('TabifyTableGroup Title'); - }); - }); - - describe('#field', function () { - it('calls the columns aggConfig#getField() method', function () { - const table = new TabifyTable(); - const football = {}; - const column = { - aggConfig: { - getField: _.constant(football) - } - }; - - expect(table.field(column)).to.be(football); - }); - }); - - describe('#fieldFormatter', function () { - it('calls the columns aggConfig#fieldFormatter() method', function () { - const table = new TabifyTable(); - const football = {}; - const column = { - aggConfig: { - fieldFormatter: _.constant(football) - } - }; - - expect(table.fieldFormatter(column)).to.be(football); - }); - }); -}); diff --git a/src/ui/public/agg_response/tabify/__tests__/tabify.js b/src/ui/public/agg_response/tabify/__tests__/tabify.js index 7f82817470c934..2e66d583fdeeb8 100644 --- a/src/ui/public/agg_response/tabify/__tests__/tabify.js +++ b/src/ui/public/agg_response/tabify/__tests__/tabify.js @@ -19,8 +19,6 @@ import './_get_columns'; import './_buckets'; -import './_table'; -import './_table_group'; import './_response_writer'; import './_integration'; describe('Tabify Agg Response', function () { diff --git a/src/ui/public/agg_response/tabify/_buckets.js b/src/ui/public/agg_response/tabify/_buckets.js index 6beadefe5e5b91..846536b9831f94 100644 --- a/src/ui/public/agg_response/tabify/_buckets.js +++ b/src/ui/public/agg_response/tabify/_buckets.js @@ -19,7 +19,7 @@ import _ from 'lodash'; -function TabifyBuckets(aggResp, aggParams) { +function TabifyBuckets(aggResp, aggParams, timeRange) { if (_.has(aggResp, 'buckets')) { this.buckets = aggResp.buckets; } else if (aggResp) { @@ -38,7 +38,12 @@ function TabifyBuckets(aggResp, aggParams) { this.length = this.buckets.length; } - if (this.length && aggParams) this._orderBucketsAccordingToParams(aggParams); + if (this.length && aggParams) { + this._orderBucketsAccordingToParams(aggParams); + if (aggParams.drop_partials) { + this._dropPartials(aggParams, timeRange); + } + } } TabifyBuckets.prototype.forEach = function (fn) { @@ -75,10 +80,37 @@ TabifyBuckets.prototype._orderBucketsAccordingToParams = function (params) { ranges = params.ipRangeType === 'mask' ? ranges.mask : ranges.fromTo; } this.buckets = ranges.map(range => { - if (range.mask) return this.buckets.find(el => el.key === range.mask); + if (range.mask) { + return this.buckets.find(el => el.key === range.mask); + } return this.buckets.find(el => this._isRangeEqual(el, range)); }); } }; +// dropPartials should only be called if the aggParam setting is enabled, +// and the agg field is the same as the Time Range. +TabifyBuckets.prototype._dropPartials = function (params, timeRange) { + if (!timeRange || + this.buckets.length <= 1 || + this.objectMode || + params.field.name !== timeRange.name) { + return; + } + + const interval = this.buckets[1].key - this.buckets[0].key; + + this.buckets = this.buckets.filter(bucket => { + if (bucket.key < timeRange.gte) { + return false; + } + if (bucket.key + interval > timeRange.lte) { + return false; + } + return true; + }); + + this.length = this.buckets.length; +}; + export { TabifyBuckets }; diff --git a/src/ui/public/agg_response/tabify/_get_columns.js b/src/ui/public/agg_response/tabify/_get_columns.js index e5010f0f0358ac..68df7e1a033333 100644 --- a/src/ui/public/agg_response/tabify/_get_columns.js +++ b/src/ui/public/agg_response/tabify/_get_columns.js @@ -19,15 +19,19 @@ import _ from 'lodash'; -export function tabifyGetColumns(aggs, minimal, hierarchical) { +const getColumn = (agg, i) => { + return { + aggConfig: agg, + id: `col-${i}-${agg.id}`, + name: agg.makeLabel() + }; +}; - if (minimal == null) minimal = !hierarchical; +export function tabifyGetColumns(aggs, minimal) { // pick the columns if (minimal) { - return aggs.map(function (agg) { - return { aggConfig: agg }; - }); + return aggs.map((agg, i) => getColumn(agg, i)); } // supposed to be bucket,...metrics,bucket,...metrics @@ -40,16 +44,15 @@ export function tabifyGetColumns(aggs, minimal, hierarchical) { if (!grouped.buckets) { // return just the metrics, in column format - return grouped.metrics.map(function (agg) { - return { aggConfig: agg }; - }); + return grouped.metrics.map((agg, i) => getColumn(agg, i)); } + let columnIndex = 0; // return the buckets, and after each place all of the metrics grouped.buckets.forEach(function (agg) { - columns.push({ aggConfig: agg }); + columns.push(getColumn(agg, columnIndex++)); grouped.metrics.forEach(function (metric) { - columns.push({ aggConfig: metric }); + columns.push(getColumn(metric, columnIndex++)); }); }); diff --git a/src/ui/public/agg_response/tabify/_response_writer.js b/src/ui/public/agg_response/tabify/_response_writer.js index 1d0bd1c23863cb..e71fe842e0bc6c 100644 --- a/src/ui/public/agg_response/tabify/_response_writer.js +++ b/src/ui/public/agg_response/tabify/_response_writer.js @@ -17,285 +17,74 @@ * under the License. */ -import _ from 'lodash'; -import AggConfigResult from '../../vis/agg_config_result'; -import { TabifyTable } from './_table'; -import { TabifyTableGroup } from './_table_group'; +import { toArray } from 'lodash'; import { tabifyGetColumns } from './_get_columns'; -import { createLegacyClass } from '../../utils/legacy_class'; - -createLegacyClass(SplitAcr).inherits(AggConfigResult); -function SplitAcr(agg, parent, key) { - SplitAcr.Super.call(this, agg, parent, key, key); -} /** * Writer class that collects information about an aggregation response and * produces a table, or a series of tables. * - * @param {Vis} vis - the vis object to which the aggregation response correlates + * @param {AggConfigs} aggs - the agg configs object to which the aggregation response correlates + * @param {boolean} metricsAtAllLevels - setting to true will produce metrics for every bucket + * @param {boolean} partialRows - setting to true will not remove rows with missing values */ -function TabbedAggResponseWriter(aggs, opts) { - this.opts = opts || {}; - this.rowBuffer = []; - - const visIsHier = opts.isHierarchical; - - // do the options allow for splitting? we will only split if true and - // tabify calls the split method. - this.canSplit = this.opts.canSplit !== false; - - // should we allow partial rows to be included in the tables? if a - // partial row is found, it is filled with empty strings '' - this.partialRows = this.opts.partialRows == null ? visIsHier : this.opts.partialRows; - - // if true, we will not place metric columns after every bucket - // even if the vis is hierarchical. if false, and the vis is - // hierarchical, then we will display metric columns after - // every bucket col - this.minimalColumns = visIsHier ? !!this.opts.minimalColumns : true; - - // true if we can expect metrics to have been calculated - // for every bucket - this.metricsForAllBuckets = visIsHier; - - // if true, values will be wrapped in aggConfigResult objects which link them - // to their aggConfig and enable the filterbar and tooltip formatters - this.asAggConfigResults = !!this.opts.asAggConfigResults; +function TabbedAggResponseWriter(aggs, { metricsAtAllLevels = false, partialRows = false, timeRange } = {}) { + this.rowBuffer = {}; + this.bucketBuffer = []; + this.metricBuffer = []; + this.metricsForAllBuckets = metricsAtAllLevels; + this.partialRows = partialRows; this.aggs = aggs; - this.columns = tabifyGetColumns(aggs.getResponseAggs(), this.minimalColumns); - this.aggStack = _.pluck(this.columns, 'aggConfig'); + this.columns = tabifyGetColumns(aggs.getResponseAggs(), !metricsAtAllLevels); + this.aggStack = [...this.columns]; - this.root = new TabifyTableGroup(); - this.acrStack = []; - this.splitStack = [this.root]; -} + this.rows = []; -/** - * Create a Table of TableGroup object, link it to it's parent (if any), and determine if - * it's the root - * - * @param {boolean} group - is this a TableGroup or just a normal Table - * @param {AggConfig} agg - the aggregation that create this table, only applies to groups - * @param {any} key - the bucketKey that this table relates to - * @return {Table/TableGroup} table - the created table - */ -TabbedAggResponseWriter.prototype._table = function (group, agg, key) { - const Class = (group) ? TabifyTableGroup : TabifyTable; - const table = new Class(); - const parent = this.splitStack[0]; - - if (group) { - table.aggConfig = agg; - table.key = key; - table.title = (table.fieldFormatter()(key)); - // aggs that don't implement makeLabel should not add to title - if (agg.makeLabel() !== agg.name) { - table.title += ': ' + agg.makeLabel(); + // Extract the time range object if provided + if (timeRange) { + const timeRangeKey = Object.keys(timeRange)[0]; + this.timeRange = timeRange[timeRangeKey]; + if (this.timeRange) { + this.timeRange.name = timeRangeKey; } } +} - // link the parent and child - table.$parent = parent; - parent.tables.push(table); - - return table; +TabbedAggResponseWriter.prototype.isPartialRow = function (row) { + return !this.columns.map(column => row.hasOwnProperty(column.id)).every(c => (c === true)); }; /** - * Enter into a split table, called for each bucket of a splitting agg. The new table - * is either created or located using the agg and key arguments, and then the block is - * executed with the table as it's this context. Within this function, you should - * walk into the remaining branches and end up writing some rows to the table. - * - * @param {aggConfig} agg - the aggConfig that created this split - * @param {Buckets} buckets - the buckets produces by the agg - * @param {function} block - a function to execute for each sub bucket + * Create a new row by reading the row buffer and bucketBuffer */ -TabbedAggResponseWriter.prototype.split = function (agg, buckets, block) { - const self = this; - - if (!self.canSplit) { - throw new Error('attempted to split when splitting is disabled'); - } - - self._removeAggFromColumns(agg); - - buckets.forEach(function (bucket, key) { - // find the existing split that we should extend - let tableGroup = _.find(self.splitStack[0].tables, { aggConfig: agg, key: key }); - // create the split if it doesn't exist yet - if (!tableGroup) tableGroup = self._table(true, agg, key); - - let splitAcr = false; - if (self.asAggConfigResults) { - splitAcr = self._injectParentSplit(agg, key); - } - - // push the split onto the stack so that it will receive written tables - self.splitStack.unshift(tableGroup); - - // call the block - if (_.isFunction(block)) block.call(self, bucket, key); - - // remove the split from the stack - self.splitStack.shift(); - splitAcr && _.pull(self.acrStack, splitAcr); - }); -}; - -TabbedAggResponseWriter.prototype._removeAggFromColumns = function (agg) { - const i = _.findIndex(this.columns, function (col) { - return col.aggConfig === agg; +TabbedAggResponseWriter.prototype.row = function () { + this.bucketBuffer.forEach(bucket => { + this.rowBuffer[bucket.id] = bucket.value; }); - // we must have already removed this column - if (i === -1) return; - - this.columns.splice(i, 1); - - if (this.minimalColumns) return; - - // hierarchical vis creates additional columns for each bucket - // we will remove those too - const mCol = this.columns.splice(i, 1).pop(); - const mI = _.findIndex(this.aggStack, function (agg) { - return agg === mCol.aggConfig; + this.metricBuffer.forEach(metric => { + this.rowBuffer[metric.id] = metric.value; }); - if (mI > -1) this.aggStack.splice(mI, 1); -}; - -/** - * When a split is found while building the aggConfigResult tree, we - * want to push the split into the tree at another point. Since each - * branch in the tree is a double-linked list we need do some special - * shit to pull this off. - * - * @private - * @param {AggConfig} - The agg which produced the split bucket - * @param {any} - The value which identifies the bucket - * @return {SplitAcr} - the AggConfigResult created for the split bucket - */ -TabbedAggResponseWriter.prototype._injectParentSplit = function (agg, key) { - const oldList = this.acrStack; - const newList = this.acrStack = []; - - // walk from right to left through the old stack - // and move things to the new stack - let injected = false; - - if (!oldList.length) { - injected = new SplitAcr(agg, null, key); - newList.unshift(injected); - return injected; - } - - // walk from right to left, emptying the previous list - while (oldList.length) { - const acr = oldList.pop(); - - // ignore other splits - if (acr instanceof SplitAcr) { - newList.unshift(acr); - continue; - } - - // inject the split - if (!injected) { - injected = new SplitAcr(agg, newList[0], key); - newList.unshift(injected); - } - - const newAcr = new AggConfigResult(acr.aggConfig, newList[0], acr.value, acr.aggConfig.getKey(acr), acr.filters); - newList.unshift(newAcr); - - // and replace the acr in the row buffer if its there - const rowI = this.rowBuffer.indexOf(acr); - if (rowI > -1) { - this.rowBuffer[rowI] = newAcr; - } - } - - return injected; -}; - -/** - * Push a value into the row, then run a block. Once the block is - * complete the value is pulled from the stack. - * - * @param {any} value - the value that should be added to the row - * @param {function} block - the function to run while this value is in the row - * @return {any} - the value that was added - */ -TabbedAggResponseWriter.prototype.cell = function (agg, value, block, filters) { - if (this.asAggConfigResults) { - value = new AggConfigResult(agg, this.acrStack[0], value, value, filters); - } - - const stackResult = this.asAggConfigResults && value.type === 'bucket'; - - this.rowBuffer.push(value); - if (stackResult) this.acrStack.unshift(value); - - if (_.isFunction(block)) block.call(this); - - this.rowBuffer.pop(value); - if (stackResult) this.acrStack.shift(); - - return value; -}; - -/** - * Create a new row by reading the row buffer. This will do nothing if - * the row is incomplete and the vis this data came from is NOT flagged as - * hierarchical. - * - * @param {array} [buffer] - optional buffer to use in place of the stored rowBuffer - * @return {undefined} - */ -TabbedAggResponseWriter.prototype.row = function (buffer) { - const cells = buffer || this.rowBuffer.slice(0); - - if (!this.partialRows && cells.length < this.columns.length) { + if (!toArray(this.rowBuffer).length || (!this.partialRows && this.isPartialRow(this.rowBuffer))) { return; } - const split = this.splitStack[0]; - const table = split.tables[0] || this._table(false); - - while (cells.length < this.columns.length) cells.push(''); - table.rows.push(cells); - return table; + this.rows.push(this.rowBuffer); + this.rowBuffer = {}; }; /** * Get the actual response * - * @return {object} - the final table-tree + * @return {object} - the final table */ TabbedAggResponseWriter.prototype.response = function () { - const columns = this.columns; - - // give the columns some metadata - columns.map(function (col) { - col.title = col.aggConfig.makeLabel(); - }); - - // walk the tree and write the columns to each table - ((function step(table) { - if (table.tables) table.tables.forEach(step); - else table.columns = columns.slice(0); - })(this.root)); - - if (this.canSplit) return this.root; - - const table = this.root.tables[0]; - if (!table) return; - - delete table.$parent; - return table; + return { + columns: this.columns, + rows: this.rows + }; }; export { TabbedAggResponseWriter }; diff --git a/src/ui/public/agg_response/tabify/_table.js b/src/ui/public/agg_response/tabify/_table.js deleted file mode 100644 index 80b84407c65846..00000000000000 --- a/src/ui/public/agg_response/tabify/_table.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. - */ - -/** - * Simple table class that is used to contain the rows and columns that create - * a table. This is usually found at the root of the response or within a TableGroup - */ -function TabifyTable() { - this.columns = null; // written with the first row - this.rows = []; -} - -TabifyTable.prototype.title = function () { - if (this.$parent) { - return this.$parent.title; - } else { - return ''; - } -}; - -TabifyTable.prototype.aggConfig = function (col) { - if (!col.aggConfig) { - throw new TypeError('Column is missing the aggConfig property'); - } - return col.aggConfig; -}; - -TabifyTable.prototype.field = function (col) { - return this.aggConfig(col).getField(); -}; - -TabifyTable.prototype.fieldFormatter = function (col) { - return this.aggConfig(col).fieldFormatter(); -}; - - -export { TabifyTable }; diff --git a/src/ui/public/agg_response/tabify/_table_group.js b/src/ui/public/agg_response/tabify/_table_group.js deleted file mode 100644 index 6306a783e1ecfb..00000000000000 --- a/src/ui/public/agg_response/tabify/_table_group.js +++ /dev/null @@ -1,39 +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. - */ - -/** - * Simple object that wraps multiple tables. It contains information about the aggConfig - * and bucket that created this group and a list of the tables within it. - */ -function TabifyTableGroup() { - this.aggConfig = null; - this.key = null; - this.title = null; - this.tables = []; -} - -TabifyTableGroup.prototype.field = function () { - if (this.aggConfig) return this.aggConfig.getField(); -}; - -TabifyTableGroup.prototype.fieldFormatter = function () { - if (this.aggConfig) return this.aggConfig.fieldFormatter(); -}; - -export { TabifyTableGroup }; diff --git a/src/ui/public/agg_response/tabify/tabify.js b/src/ui/public/agg_response/tabify/tabify.js index 05ec560d5214a3..08abe3fbb35964 100644 --- a/src/ui/public/agg_response/tabify/tabify.js +++ b/src/ui/public/agg_response/tabify/tabify.js @@ -43,31 +43,33 @@ export function tabifyAggResponse(aggs, esResponse, respOpts = {}) { * @returns {undefined} */ function collectBucket(write, bucket, key, aggScale) { - const agg = write.aggStack.shift(); + const column = write.aggStack.shift(); + const agg = column.aggConfig; const aggInfo = agg.write(write.aggs); aggScale *= aggInfo.metricScale || 1; switch (agg.type.type) { case 'buckets': - const buckets = new TabifyBuckets(bucket[agg.id], agg.params); + const buckets = new TabifyBuckets(bucket[agg.id], agg.params, write.timeRange); if (buckets.length) { - const splitting = write.canSplit && agg.schema.name === 'split'; - if (splitting) { - write.split(agg, buckets, function forEachBucket(subBucket, key) { - collectBucket(write, subBucket, agg.getKey(subBucket, key), aggScale); - }); - } else { - buckets.forEach(function (subBucket, key) { - write.cell(agg, agg.getKey(subBucket, key), function () { - collectBucket(write, subBucket, agg.getKey(subBucket, key), aggScale); - }, subBucket.filters); - }); - } - } else if (write.partialRows && write.metricsForAllBuckets && write.minimalColumns) { + buckets.forEach(function (subBucket, key) { + // if the bucket doesn't have value don't add it to the row + // we don't want rows like: { column1: undefined, column2: 10 } + const bucketValue = agg.getKey(subBucket, key); + const hasBucketValue = typeof bucketValue !== 'undefined'; + if (hasBucketValue) { + write.bucketBuffer.push({ id: column.id, value: bucketValue }); + } + collectBucket(write, subBucket, agg.getKey(subBucket, key), aggScale); + if (hasBucketValue) { + write.bucketBuffer.pop(); + } + }); + } else if (write.partialRows) { // we don't have any buckets, but we do have metrics at this // level, then pass all the empty buckets and jump back in for // the metrics. - write.aggStack.unshift(agg); + write.aggStack.unshift(column); passEmptyBuckets(write, bucket, key, aggScale); write.aggStack.shift(); } else { @@ -83,39 +85,41 @@ function collectBucket(write, bucket, key, aggScale) { if (aggScale !== 1) { value *= aggScale; } - write.cell(agg, value, function () { - if (!write.aggStack.length) { - // row complete - write.row(); - } else { - // process the next agg at this same level - collectBucket(write, bucket, key, aggScale); - } - }); + write.metricBuffer.push({ id: column.id, value: value }); + + if (!write.aggStack.length) { + // row complete + write.row(); + } else { + // process the next agg at this same level + collectBucket(write, bucket, key, aggScale); + } + + write.metricBuffer.pop(); + break; } - write.aggStack.unshift(agg); + write.aggStack.unshift(column); } // write empty values for each bucket agg, then write // the metrics from the initial bucket using collectBucket() function passEmptyBuckets(write, bucket, key, aggScale) { - const agg = write.aggStack.shift(); + const column = write.aggStack.shift(); + const agg = column.aggConfig; switch (agg.type.type) { case 'metrics': // pass control back to collectBucket() - write.aggStack.unshift(agg); + write.aggStack.unshift(column); collectBucket(write, bucket, key, aggScale); return; case 'buckets': - write.cell(agg, '', function () { - passEmptyBuckets(write, bucket, key, aggScale); - }); + passEmptyBuckets(write, bucket, key, aggScale); } - write.aggStack.unshift(agg); + write.aggStack.unshift(column); } diff --git a/src/ui/public/agg_table/__tests__/_group.js b/src/ui/public/agg_table/__tests__/_group.js index 5bf8741362ee0f..2c6c988f674c49 100644 --- a/src/ui/public/agg_table/__tests__/_group.js +++ b/src/ui/public/agg_table/__tests__/_group.js @@ -21,7 +21,7 @@ import $ from 'jquery'; import ngMock from 'ng_mock'; import expect from 'expect.js'; import fixtures from 'fixtures/fake_hierarchical_data'; -import { tabifyAggResponse } from '../../agg_response/tabify/tabify'; +import { LegacyResponseHandlerProvider } from '../../vis/response_handlers/legacy'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { VisProvider } from '../../vis'; describe('AggTableGroup Directive', function () { @@ -30,9 +30,11 @@ describe('AggTableGroup Directive', function () { let $compile; let Vis; let indexPattern; + let tableAggResponse; beforeEach(ngMock.module('kibana')); beforeEach(ngMock.inject(function ($injector, Private) { + tableAggResponse = Private(LegacyResponseHandlerProvider).handler; indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); Vis = Private(VisProvider); @@ -49,9 +51,9 @@ describe('AggTableGroup Directive', function () { }); - it('renders a simple split response properly', function () { + it('renders a simple split response properly', async function () { const vis = new Vis(indexPattern, 'table'); - $scope.group = tabifyAggResponse(vis.getAggConfig(), fixtures.metricOnly); + $scope.group = await tableAggResponse(vis, fixtures.metricOnly); $scope.sort = { columnIndex: null, direction: null @@ -79,7 +81,7 @@ describe('AggTableGroup Directive', function () { expect($subTables.length).to.be(0); }); - it('renders a complex response properly', function () { + it('renders a complex response properly', async function () { const vis = new Vis(indexPattern, { type: 'pie', aggs: [ @@ -93,7 +95,7 @@ describe('AggTableGroup Directive', function () { agg.id = 'agg_' + (i + 1); }); - const group = $scope.group = tabifyAggResponse(vis.getAggConfig(), fixtures.threeTermBuckets); + const group = $scope.group = await tableAggResponse(vis, fixtures.threeTermBuckets); const $el = $(''); $compile($el)($scope); $scope.$digest(); diff --git a/src/ui/public/agg_table/__tests__/_table.js b/src/ui/public/agg_table/__tests__/_table.js index 4d556bfafe9fc5..9e67c6bf42a176 100644 --- a/src/ui/public/agg_table/__tests__/_table.js +++ b/src/ui/public/agg_table/__tests__/_table.js @@ -17,14 +17,13 @@ * under the License. */ -import _ from 'lodash'; import $ from 'jquery'; import moment from 'moment'; import ngMock from 'ng_mock'; import expect from 'expect.js'; import fixtures from 'fixtures/fake_hierarchical_data'; import sinon from 'sinon'; -import { tabifyAggResponse } from '../../agg_response/tabify/tabify'; +import { LegacyResponseHandlerProvider } from '../../vis/response_handlers/legacy'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import { VisProvider } from '../../vis'; describe('AggTable Directive', function () { @@ -34,9 +33,11 @@ describe('AggTable Directive', function () { let Vis; let indexPattern; let settings; + let tableAggResponse; beforeEach(ngMock.module('kibana')); beforeEach(ngMock.inject(function ($injector, Private, config) { + tableAggResponse = Private(LegacyResponseHandlerProvider).handler; indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); Vis = Private(VisProvider); settings = config; @@ -54,20 +55,19 @@ describe('AggTable Directive', function () { }); - it('renders a simple response properly', function () { + it('renders a simple response properly', async function () { const vis = new Vis(indexPattern, 'table'); - $scope.table = tabifyAggResponse( - vis.getAggConfig(), - fixtures.metricOnly, - { canSplit: false, hierarchical: vis.isHierarchical() } - ); + $scope.table = (await tableAggResponse( + vis, + fixtures.metricOnly + )).tables[0]; const $el = $compile('')($scope); $scope.$digest(); expect($el.find('tbody').length).to.be(1); expect($el.find('td').length).to.be(1); - expect($el.find('td').text()).to.eql(1000); + expect($el.find('td').text()).to.eql('1,000'); }); it('renders nothing if the table is empty', function () { @@ -78,24 +78,24 @@ describe('AggTable Directive', function () { expect($el.find('tbody').length).to.be(0); }); - it('renders a complex response properly', function () { + it('renders a complex response properly', async function () { const vis = new Vis(indexPattern, { - type: 'pie', + type: 'table', + params: { + showMetricsAtAllLevels: true + }, aggs: [ { type: 'avg', schema: 'metric', params: { field: 'bytes' } }, - { type: 'terms', schema: 'split', params: { field: 'extension' } }, - { type: 'terms', schema: 'segment', params: { field: 'geo.src' } }, - { type: 'terms', schema: 'segment', params: { field: 'machine.os' } } + { type: 'terms', schema: 'bucket', params: { field: 'extension' } }, + { type: 'terms', schema: 'bucket', params: { field: 'geo.src' } }, + { type: 'terms', schema: 'bucket', params: { field: 'machine.os' } } ] }); vis.aggs.forEach(function (agg, i) { agg.id = 'agg_' + (i + 1); }); - $scope.table = tabifyAggResponse(vis.getAggConfig(), fixtures.threeTermBuckets, { - canSplit: false, - isHierarchical: vis.isHierarchical() - }); + $scope.table = (await tableAggResponse(vis, fixtures.threeTermBuckets)).tables[0]; const $el = $(''); $compile($el)($scope); $scope.$digest(); @@ -106,9 +106,10 @@ describe('AggTable Directive', function () { expect($rows.length).to.be.greaterThan(0); function validBytes(str) { - expect(str).to.match(/^\d+$/); - const bytesAsNum = _.parseInt(str); - expect(bytesAsNum === 0 || bytesAsNum > 1000).to.be.ok(); + const num = str.replace(/,/g, ''); + if (num !== '-') { + expect(num).to.match(/^\d+$/); + } } $rows.each(function () { @@ -135,7 +136,7 @@ describe('AggTable Directive', function () { }); describe('renders totals row', function () { - function totalsRowTest(totalFunc, expected) { + async function totalsRowTest(totalFunc, expected) { const vis = new Vis(indexPattern, { type: 'table', aggs: [ @@ -158,10 +159,10 @@ describe('AggTable Directive', function () { const oldTimezoneSetting = settings.get('dateFormat:tz'); settings.set('dateFormat:tz', 'UTC'); - $scope.table = tabifyAggResponse(vis.getAggConfig(), + $scope.table = (await tableAggResponse(vis, fixtures.oneTermOneHistogramBucketWithTwoMetricsOneTopHitOneDerivative, { canSplit: false, minimalColumns: true, asAggConfigResults: true } - ); + )).tables[0]; $scope.showTotal = true; $scope.totalFunc = totalFunc; const $el = $(''); @@ -182,11 +183,11 @@ describe('AggTable Directive', function () { settings.set('dateFormat:tz', oldTimezoneSetting); off(); } - it('as count', function () { - totalsRowTest('count', ['18', '18', '18', '18', '18', '18']); + it('as count', async function () { + await totalsRowTest('count', ['18', '18', '18', '18', '18', '18']); }); - it('as min', function () { - totalsRowTest('min', [ + it('as min', async function () { + await totalsRowTest('min', [ '', '2014-09-28', '9,283', @@ -195,8 +196,8 @@ describe('AggTable Directive', function () { '11' ]); }); - it('as max', function () { - totalsRowTest('max', [ + it('as max', async function () { + await totalsRowTest('max', [ '', '2014-10-03', '220,943', @@ -205,8 +206,8 @@ describe('AggTable Directive', function () { '837' ]); }); - it('as avg', function () { - totalsRowTest('avg', [ + it('as avg', async function () { + await totalsRowTest('avg', [ '', '', '87,221.5', @@ -215,8 +216,8 @@ describe('AggTable Directive', function () { '206.833' ]); }); - it('as sum', function () { - totalsRowTest('sum', [ + it('as sum', async function () { + await totalsRowTest('sum', [ '', '', '1,569,987', diff --git a/src/ui/public/agg_table/agg_table.js b/src/ui/public/agg_table/agg_table.js index dba9ebeb5d10b7..352d1152aa99df 100644 --- a/src/ui/public/agg_table/agg_table.js +++ b/src/ui/public/agg_table/agg_table.js @@ -102,10 +102,10 @@ uiModules return; } - self.csv.filename = ($scope.exportTitle || table.title() || 'table') + '.csv'; + self.csv.filename = ($scope.exportTitle || table.title || 'table') + '.csv'; $scope.rows = table.rows; $scope.formattedColumns = table.columns.map(function (col, i) { - const agg = $scope.table.aggConfig(col); + const agg = col.aggConfig; const field = agg.getField(); const formattedColumn = { title: col.title, diff --git a/src/ui/public/agg_types/__tests__/agg_params.js b/src/ui/public/agg_types/__tests__/agg_params.js index 6379535fb5ceb8..bddfa725165ad3 100644 --- a/src/ui/public/agg_types/__tests__/agg_params.js +++ b/src/ui/public/agg_types/__tests__/agg_params.js @@ -43,7 +43,7 @@ describe('AggParams class', function () { describe('AggParam creation', function () { it('Uses the FieldParamType class for params with the name "field"', function () { const params = [ - { name: 'field' } + { name: 'field', type: 'field' } ]; const aggParams = new AggParams(params); diff --git a/src/ui/public/agg_types/__tests__/buckets/_geo_hash.js b/src/ui/public/agg_types/__tests__/buckets/_geo_hash.js index 8b777d919550f8..a0736ebd2f5264 100644 --- a/src/ui/public/agg_types/__tests__/buckets/_geo_hash.js +++ b/src/ui/public/agg_types/__tests__/buckets/_geo_hash.js @@ -30,6 +30,17 @@ describe('Geohash Agg', () => { top_left: { lat: 1.0, lon: -1.0 }, bottom_right: { lat: -1.0, lon: 1.0 } }; + + const BucketAggTypeMock = (aggOptions) => { + return aggOptions; + }; + const AggConfigMock = (parent, aggOptions) => { + return aggOptions; + }; + const createAggregationMock = (aggOptions) => { + return new AggConfigMock(null, aggOptions); + }; + const aggMock = { getField: () => { return { @@ -41,17 +52,11 @@ describe('Geohash Agg', () => { useGeocentroid: true, mapZoom: initialZoom }, - vis: { - aggs: [] - }, + aggConfigs: {}, type: 'geohash_grid', }; - const BucketAggTypeMock = (aggOptions) => { - return aggOptions; - }; - const AggConfigMock = (vis, aggOptions) => { - return aggOptions; - }; + aggMock.aggConfigs.createAggConfig = createAggregationMock; + before(function () { sinon.stub(AggConfigModule, 'AggConfig').callsFake(AggConfigMock); diff --git a/src/ui/public/agg_types/__tests__/buckets/date_histogram/_editor.js b/src/ui/public/agg_types/__tests__/buckets/date_histogram/_editor.js index 11326c1e8a7366..afacdc522562ed 100644 --- a/src/ui/public/agg_types/__tests__/buckets/date_histogram/_editor.js +++ b/src/ui/public/agg_types/__tests__/buckets/date_histogram/_editor.js @@ -58,7 +58,10 @@ describe('editor', function () { ] }); - const $el = $(''); + const $el = $('' + + ''); const $parentScope = $injector.get('$rootScope').$new(); agg = $parentScope.agg = vis.aggs.bySchemaName.segment[0]; diff --git a/src/ui/public/agg_types/__tests__/buckets/date_histogram/_params.js b/src/ui/public/agg_types/__tests__/buckets/date_histogram/_params.js index ba4528110546e7..394ff6e526907c 100644 --- a/src/ui/public/agg_types/__tests__/buckets/date_histogram/_params.js +++ b/src/ui/public/agg_types/__tests__/buckets/date_histogram/_params.js @@ -36,7 +36,7 @@ describe('params', function () { let paramWriter; let writeInterval; - let setTimeBounds; + let getTimeBounds; let timeField; beforeEach(ngMock.module('kibana')); @@ -47,19 +47,17 @@ describe('params', function () { timeField = indexPattern.timeFieldName; paramWriter = new AggParamWriter({ aggType: 'date_histogram' }); - writeInterval = function (interval) { - return paramWriter.write({ interval: interval, field: timeField }); + writeInterval = function (interval, timeRange) { + return paramWriter.write({ interval: interval, field: timeField, timeRange: timeRange }); }; const now = moment(); - setTimeBounds = function (n, units) { + getTimeBounds = function (n, units) { timefilter.enableAutoRefreshSelector(); timefilter.enableTimeRangeSelector(); - paramWriter.vis.filters = { - timeRange: { - from: now.clone().subtract(n, units), - to: now.clone() - } + return { + from: now.clone().subtract(n, units), + to: now.clone() }; }; })); @@ -76,22 +74,22 @@ describe('params', function () { }); it('automatically picks an interval', function () { - setTimeBounds(15, 'm'); - const output = writeInterval('auto'); + const timeBounds = getTimeBounds(15, 'm'); + const output = writeInterval('auto', timeBounds); expect(output.params.interval).to.be('30s'); }); it('scales up the interval if it will make too many buckets', function () { - setTimeBounds(30, 'm'); - const output = writeInterval('s'); + const timeBounds = getTimeBounds(30, 'm'); + const output = writeInterval('s', timeBounds); expect(output.params.interval).to.be('10s'); expect(output.metricScaleText).to.be('second'); expect(output.metricScale).to.be(0.1); }); it('does not scale down the interval', function () { - setTimeBounds(1, 'm'); - const output = writeInterval('h'); + const timeBounds = getTimeBounds(1, 'm'); + const output = writeInterval('h', timeBounds); expect(output.params.interval).to.be('1h'); expect(output.metricScaleText).to.be(undefined); expect(output.metricScale).to.be(undefined); @@ -109,21 +107,21 @@ describe('params', function () { const typeNames = test.slice(); it(typeNames.join(', ') + ' should ' + (should ? '' : 'not') + ' scale', function () { - setTimeBounds(1, 'y'); + const timeBounds = getTimeBounds(1, 'y'); const vis = paramWriter.vis; vis.aggs.splice(0); - const histoConfig = new AggConfig(vis, { + const histoConfig = new AggConfig(vis.aggs, { type: aggTypes.byName.date_histogram, schema: 'segment', - params: { interval: 's', field: timeField } + params: { interval: 's', field: timeField, timeRange: timeBounds } }); vis.aggs.push(histoConfig); typeNames.forEach(function (type) { - vis.aggs.push(new AggConfig(vis, { + vis.aggs.push(new AggConfig(vis.aggs, { type: aggTypes.byName[type], schema: 'metric' })); diff --git a/src/ui/public/agg_types/__tests__/metrics/median.js b/src/ui/public/agg_types/__tests__/metrics/median.js index b4db30e3d929ba..de81913494bae9 100644 --- a/src/ui/public/agg_types/__tests__/metrics/median.js +++ b/src/ui/public/agg_types/__tests__/metrics/median.js @@ -35,8 +35,7 @@ describe('AggTypeMetricMedianProvider class', function () { 'title': 'New Visualization', 'type': 'metric', 'params': { - 'fontSize': 60, - 'handleNoResults': true + 'fontSize': 60 }, 'aggs': [ { diff --git a/src/ui/public/agg_types/__tests__/metrics/parent_pipeline.js b/src/ui/public/agg_types/__tests__/metrics/parent_pipeline.js index d149fd34a2a0f0..78a9ad4d107973 100644 --- a/src/ui/public/agg_types/__tests__/metrics/parent_pipeline.js +++ b/src/ui/public/agg_types/__tests__/metrics/parent_pipeline.js @@ -59,8 +59,7 @@ describe('parent pipeline aggs', function () { title: 'New Visualization', type: 'metric', params: { - fontSize: 60, - handleNoResults: true + fontSize: 60 }, aggs: [ { diff --git a/src/ui/public/agg_types/__tests__/metrics/sibling_pipeline.js b/src/ui/public/agg_types/__tests__/metrics/sibling_pipeline.js index e3e09b886d03d7..43ae223eaf17e5 100644 --- a/src/ui/public/agg_types/__tests__/metrics/sibling_pipeline.js +++ b/src/ui/public/agg_types/__tests__/metrics/sibling_pipeline.js @@ -68,8 +68,7 @@ describe('sibling pipeline aggs', function () { title: 'New Visualization', type: 'metric', params: { - fontSize: 60, - handleNoResults: true + fontSize: 60 }, aggs: [ { diff --git a/src/ui/public/agg_types/__tests__/metrics/top_hit.js b/src/ui/public/agg_types/__tests__/metrics/top_hit.js index 5dc109422c16f6..3e616391068d48 100644 --- a/src/ui/public/agg_types/__tests__/metrics/top_hit.js +++ b/src/ui/public/agg_types/__tests__/metrics/top_hit.js @@ -49,8 +49,7 @@ describe('Top hit metric', function () { title: 'New Visualization', type: 'metric', params: { - fontSize: 60, - handleNoResults: true + fontSize: 60 }, aggs: [ { @@ -91,7 +90,7 @@ describe('Top hit metric', function () { it('requests both source and docvalues_fields for non-text aggregatable fields', function () { init({ field: 'bytes' }); expect(aggDsl.top_hits._source).to.be('bytes'); - expect(aggDsl.top_hits.docvalue_fields).to.eql([ 'bytes' ]); + expect(aggDsl.top_hits.docvalue_fields).to.eql([ { field: 'bytes', format: 'use_field_mapping' } ]); }); it('requests just source for aggregatable text fields', function () { diff --git a/src/ui/public/agg_types/__tests__/param_types/_field.js b/src/ui/public/agg_types/__tests__/param_types/_field.js index 1300835896d30c..01eab6866718b1 100644 --- a/src/ui/public/agg_types/__tests__/param_types/_field.js +++ b/src/ui/public/agg_types/__tests__/param_types/_field.js @@ -37,22 +37,20 @@ describe('Field', function () { describe('constructor', function () { it('it is an instance of BaseParamType', function () { const aggParam = new FieldParamType({ - name: 'field' + name: 'field', type: 'field' }); expect(aggParam).to.be.a(BaseParamType); }); }); - describe('getFieldOptions', function () { + describe('getAvailableFields', function () { it('should return only aggregatable fields by default', function () { const aggParam = new FieldParamType({ - name: 'field' + name: 'field', type: 'field' }); - const fields = aggParam.getFieldOptions({ - getIndexPattern: () => indexPattern - }); + const fields = aggParam.getAvailableFields(indexPattern.fields); expect(fields).to.not.have.length(0); for (const field of fields) { expect(field.aggregatable).to.be(true); @@ -61,14 +59,12 @@ describe('Field', function () { it('should return all fields if onlyAggregatable is false', function () { const aggParam = new FieldParamType({ - name: 'field' + name: 'field', type: 'field' }); aggParam.onlyAggregatable = false; - const fields = aggParam.getFieldOptions({ - getIndexPattern: () => indexPattern - }); + const fields = aggParam.getAvailableFields(indexPattern.fields); const nonAggregatableFields = reject(fields, 'aggregatable'); expect(nonAggregatableFields).to.not.be.empty(); }); diff --git a/src/ui/public/agg_types/agg_params.js b/src/ui/public/agg_types/agg_params.js index 621aef1383e1f7..bf3f72b03fd7c5 100644 --- a/src/ui/public/agg_types/agg_params.js +++ b/src/ui/public/agg_types/agg_params.js @@ -54,8 +54,7 @@ function AggParams(params) { AggParams.Super.call(this, { index: ['name'], initialSet: params.map(function (config) { - const type = config.name === 'field' ? config.name : config.type; - const Class = paramTypeMap[type] || paramTypeMap._default; + const Class = paramTypeMap[config.type] || paramTypeMap._default; return new Class(config); }) }); diff --git a/src/ui/public/agg_types/buckets/_terms_other_bucket_helper.js b/src/ui/public/agg_types/buckets/_terms_other_bucket_helper.js index 338a427674329a..2840da808b7524 100644 --- a/src/ui/public/agg_types/buckets/_terms_other_bucket_helper.js +++ b/src/ui/public/agg_types/buckets/_terms_other_bucket_helper.js @@ -18,7 +18,6 @@ */ import _ from 'lodash'; -import { AggConfig } from '../../vis/agg_config'; import { buildExistsFilter } from '../../filter_manager/lib/exists'; import { buildPhrasesFilter } from '../../filter_manager/lib/phrases'; import { buildQueryFromFilters } from '../../courier'; @@ -110,7 +109,7 @@ const buildOtherBucketAgg = (aggConfigs, aggWithOtherBucket, response) => { const indexPattern = aggWithOtherBucket.params.field.indexPattern; // create filters aggregation - const filterAgg = new AggConfig(aggConfigs[index].vis, { + const filterAgg = aggConfigs.createAggConfig({ type: 'filters', id: 'other', }); diff --git a/src/ui/public/agg_types/buckets/create_filter/date_histogram.js b/src/ui/public/agg_types/buckets/create_filter/date_histogram.js index 5431fbf7925b30..0bfefe35381e40 100644 --- a/src/ui/public/agg_types/buckets/create_filter/date_histogram.js +++ b/src/ui/public/agg_types/buckets/create_filter/date_histogram.js @@ -28,5 +28,5 @@ export function createFilterDateHistogram(agg, key) { gte: start.valueOf(), lt: start.add(interval).valueOf(), format: 'epoch_millis' - }, agg._indexPattern); + }, agg.getIndexPattern()); } diff --git a/src/ui/public/agg_types/buckets/create_filter/date_range.js b/src/ui/public/agg_types/buckets/create_filter/date_range.js index 09d027e16bef04..5acace8e53dfcd 100644 --- a/src/ui/public/agg_types/buckets/create_filter/date_range.js +++ b/src/ui/public/agg_types/buckets/create_filter/date_range.js @@ -31,5 +31,5 @@ export function createFilterDateRange(agg, key) { if (range.to) filter.lt = +range.to; if (range.to && range.from) filter.format = 'epoch_millis'; - return buildRangeFilter(agg.params.field, filter, agg._indexPattern); + return buildRangeFilter(agg.params.field, filter, agg.getIndexPattern()); } diff --git a/src/ui/public/agg_types/buckets/create_filter/filters.js b/src/ui/public/agg_types/buckets/create_filter/filters.js index 794b4f773c7ccd..23dfeb109cc1a8 100644 --- a/src/ui/public/agg_types/buckets/create_filter/filters.js +++ b/src/ui/public/agg_types/buckets/create_filter/filters.js @@ -26,6 +26,6 @@ export function createFilterFilters(aggConfig, key) { const filter = dslFilters[key]; if (filter) { - return buildQueryFilter(filter.query, aggConfig._indexPattern.id); + return buildQueryFilter(filter.query, aggConfig.getIndexPattern().id); } } diff --git a/src/ui/public/agg_types/buckets/create_filter/histogram.js b/src/ui/public/agg_types/buckets/create_filter/histogram.js index 343469e207209b..d9ddae8ae30f17 100644 --- a/src/ui/public/agg_types/buckets/create_filter/histogram.js +++ b/src/ui/public/agg_types/buckets/create_filter/histogram.js @@ -25,7 +25,7 @@ export function createFilterHistogram(aggConfig, key) { return buildRangeFilter( aggConfig.params.field, { gte: value, lt: value + aggConfig.params.interval }, - aggConfig._indexPattern, + aggConfig.getIndexPattern(), aggConfig.fieldFormatter()(key) ); } diff --git a/src/ui/public/agg_types/buckets/create_filter/ip_range.js b/src/ui/public/agg_types/buckets/create_filter/ip_range.js index 41e2af1477106d..578607edb903df 100644 --- a/src/ui/public/agg_types/buckets/create_filter/ip_range.js +++ b/src/ui/public/agg_types/buckets/create_filter/ip_range.js @@ -32,5 +32,5 @@ export function createFilterIpRange(aggConfig, key) { }; } - return buildRangeFilter(aggConfig.params.field, { gte: range.from, lte: range.to }, aggConfig._indexPattern); + return buildRangeFilter(aggConfig.params.field, { gte: range.from, lte: range.to }, aggConfig.getIndexPattern()); } diff --git a/src/ui/public/agg_types/buckets/create_filter/range.js b/src/ui/public/agg_types/buckets/create_filter/range.js index f6516f6d06c614..e344aae438d405 100644 --- a/src/ui/public/agg_types/buckets/create_filter/range.js +++ b/src/ui/public/agg_types/buckets/create_filter/range.js @@ -23,7 +23,7 @@ export function createFilterRange(aggConfig, key) { return buildRangeFilter( aggConfig.params.field, key, - aggConfig._indexPattern, + aggConfig.getIndexPattern(), aggConfig.fieldFormatter()(key) ); } diff --git a/src/ui/public/agg_types/buckets/date_histogram.js b/src/ui/public/agg_types/buckets/date_histogram.js index fe93fe41f28e9d..925e9a5ab48834 100644 --- a/src/ui/public/agg_types/buckets/date_histogram.js +++ b/src/ui/public/agg_types/buckets/date_histogram.js @@ -28,6 +28,8 @@ import { TimeBuckets } from '../../time_buckets'; import { createFilterDateHistogram } from './create_filter/date_histogram'; import { intervalOptions } from './_interval_options'; import intervalTemplate from '../controls/time_interval.html'; +import { timefilter } from '../../timefilter'; +import dropPartialTemplate from '../controls/drop_partials.html'; const config = chrome.getUiSettingsClient(); const detectedTimezone = tzDetect.determine().name(); @@ -41,16 +43,10 @@ function getInterval(agg) { return interval; } -function getBounds(vis) { - if (vis.filters && vis.filters.timeRange) { - return vis.API.timeFilter.calculateBounds(vis.filters.timeRange); - } -} - function setBounds(agg, force) { if (agg.buckets._alreadySet && !force) return; agg.buckets._alreadySet = true; - const bounds = getBounds(agg.vis); + const bounds = agg.params.timeRange ? timefilter.calculateBounds(agg.params.timeRange) : null; agg.buckets.setBounds(agg.fieldIsTimeField() && bounds); } @@ -90,9 +86,10 @@ export const dateHistogramBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: 'date', default: function (agg) { - return agg._indexPattern.timeFieldName; + return agg.getIndexPattern().timeFieldName; }, onChange: function (agg) { if (_.get(agg, 'params.interval.val') === 'auto' && !agg.fieldIsTimeField()) { @@ -102,7 +99,11 @@ export const dateHistogramBucketAgg = new BucketAggType({ setBounds(agg, true); } }, - + { + name: 'timeRange', + default: null, + write: _.noop, + }, { name: 'interval', type: 'optioned', @@ -147,6 +148,13 @@ export const dateHistogramBucketAgg = new BucketAggType({ return isDefaultTimezone ? detectedTimezone || tzOffset : config.get('dateFormat:tz'); }, }, + { + name: 'drop_partials', + default: false, + write: _.noop, + editor: dropPartialTemplate, + }, + { name: 'customInterval', default: '2h', diff --git a/src/ui/public/agg_types/buckets/date_range.js b/src/ui/public/agg_types/buckets/date_range.js index e2dd39a74444d4..c197efb68ddb77 100644 --- a/src/ui/public/agg_types/buckets/date_range.js +++ b/src/ui/public/agg_types/buckets/date_range.js @@ -41,9 +41,10 @@ export const dateRangeBucketAgg = new BucketAggType({ }, params: [{ name: 'field', + type: 'field', filterFieldTypes: 'date', default: function (agg) { - return agg._indexPattern.timeFieldName; + return agg.getIndexPattern().timeFieldName; } }, { name: 'ranges', diff --git a/src/ui/public/agg_types/buckets/geo_hash.js b/src/ui/public/agg_types/buckets/geo_hash.js index 3cc5facd3ae396..1ade0f904c3321 100644 --- a/src/ui/public/agg_types/buckets/geo_hash.js +++ b/src/ui/public/agg_types/buckets/geo_hash.js @@ -20,7 +20,6 @@ import _ from 'lodash'; import chrome from '../../chrome'; import { BucketAggType } from './_bucket_agg_type'; -import { AggConfig } from '../../vis/agg_config'; import precisionTemplate from '../controls/precision.html'; import { geohashColumns } from '../../utils/decode_geo_hash'; import { geoContains, scaleBounds } from '../../utils/geo_utils'; @@ -73,6 +72,7 @@ export const geoHashBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: 'geo_point' }, { @@ -117,7 +117,7 @@ export const geoHashBucketAgg = new BucketAggType({ ], getRequestAggs: function (agg) { const aggs = []; - const { vis, params } = agg; + const params = agg.params; if (params.isFilteredByCollar && agg.getField()) { const { mapBounds, mapZoom } = params; @@ -137,7 +137,7 @@ export const geoHashBucketAgg = new BucketAggType({ bottom_right: mapCollar.bottom_right } }; - aggs.push(new AggConfig(vis, { + aggs.push(agg.aggConfigs.createAggConfig({ type: 'filter', id: 'filter_agg', enabled: true, @@ -147,20 +147,20 @@ export const geoHashBucketAgg = new BucketAggType({ schema: { group: 'buckets' } - })); + }, { addToAggConfigs: false })); } } aggs.push(agg); if (params.useGeocentroid) { - aggs.push(new AggConfig(vis, { + aggs.push(agg.aggConfigs.createAggConfig({ type: 'geo_centroid', enabled: true, params: { field: agg.getField() } - })); + }, { addToAggConfigs: false })); } return aggs; diff --git a/src/ui/public/agg_types/buckets/histogram.js b/src/ui/public/agg_types/buckets/histogram.js index e301f3a59a265a..4bdf474602d902 100644 --- a/src/ui/public/agg_types/buckets/histogram.js +++ b/src/ui/public/agg_types/buckets/histogram.js @@ -57,6 +57,7 @@ export const histogramBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: 'number' }, { diff --git a/src/ui/public/agg_types/buckets/ip_range.js b/src/ui/public/agg_types/buckets/ip_range.js index 51043ea14ef5cb..8e8c724c875336 100644 --- a/src/ui/public/agg_types/buckets/ip_range.js +++ b/src/ui/public/agg_types/buckets/ip_range.js @@ -40,6 +40,7 @@ export const ipRangeBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: 'ip' }, { name: 'ipRangeType', diff --git a/src/ui/public/agg_types/buckets/range.js b/src/ui/public/agg_types/buckets/range.js index 66297c87d5e380..d51e315084bc8d 100644 --- a/src/ui/public/agg_types/buckets/range.js +++ b/src/ui/public/agg_types/buckets/range.js @@ -68,6 +68,7 @@ export const rangeBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: ['number'] }, { diff --git a/src/ui/public/agg_types/buckets/significant_terms.js b/src/ui/public/agg_types/buckets/significant_terms.js index 0dae5d7d87be54..544a85123cc0d1 100644 --- a/src/ui/public/agg_types/buckets/significant_terms.js +++ b/src/ui/public/agg_types/buckets/significant_terms.js @@ -31,6 +31,7 @@ export const significantTermsBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', scriptable: false, filterFieldTypes: 'string' }, diff --git a/src/ui/public/agg_types/buckets/terms.js b/src/ui/public/agg_types/buckets/terms.js index 0c1f6b71f4651b..c30444e8d64473 100644 --- a/src/ui/public/agg_types/buckets/terms.js +++ b/src/ui/public/agg_types/buckets/terms.js @@ -127,6 +127,7 @@ export const termsBucketAgg = new BucketAggType({ params: [ { name: 'field', + type: 'field', filterFieldTypes: ['number', 'boolean', 'date', 'ip', 'string'] }, { @@ -147,7 +148,7 @@ export const termsBucketAgg = new BucketAggType({ makeOrderAgg: function (termsAgg, state) { state = state || {}; state.schema = orderAggSchema; - const orderAgg = new AggConfig(termsAgg.vis, state); + const orderAgg = termsAgg.aggConfigs.createAggConfig(state, { addToAggConfigs: false }); orderAgg.id = termsAgg.id + '-orderAgg'; return orderAgg; }, diff --git a/src/ui/public/agg_types/controls/drop_partials.html b/src/ui/public/agg_types/controls/drop_partials.html new file mode 100644 index 00000000000000..3b3713db22f871 --- /dev/null +++ b/src/ui/public/agg_types/controls/drop_partials.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/src/ui/public/agg_types/controls/field.html b/src/ui/public/agg_types/controls/field.html index b6a0edc1d16303..fcac6015af69c9 100644 --- a/src/ui/public/agg_types/controls/field.html +++ b/src/ui/public/agg_types/controls/field.html @@ -33,7 +33,7 @@

- No Compatible Fields: The "{{ agg._indexPattern.title }}" index pattern does not contain any of the following field types: {{ agg.type.params.byName.field.filterFieldTypes | commaList:false }} + No Compatible Fields: The "{{ agg.getIndexPattern().title }}" index pattern does not contain any of the following field types: {{ agg.type.params.byName.field.filterFieldTypes | commaList:false }}

diff --git a/src/ui/public/agg_types/controls/number_interval.html b/src/ui/public/agg_types/controls/number_interval.html index f0283d614cf163..a281875531d114 100644 --- a/src/ui/public/agg_types/controls/number_interval.html +++ b/src/ui/public/agg_types/controls/number_interval.html @@ -6,15 +6,6 @@ position="'right'" content="'Interval will be automatically scaled in the event that the provided value creates more buckets than specified by Advanced Setting\'s histogram:maxBars'" > - - +
+ {{editorConfig.interval.help}} +
diff --git a/src/ui/public/agg_types/controls/order_agg.html b/src/ui/public/agg_types/controls/order_agg.html index fd3f80158d5263..3dc09c56dd35f3 100644 --- a/src/ui/public/agg_types/controls/order_agg.html +++ b/src/ui/public/agg_types/controls/order_agg.html @@ -10,21 +10,22 @@ - -
diff --git a/src/ui/public/agg_types/controls/sub_agg.html b/src/ui/public/agg_types/controls/sub_agg.html index 1378bc896ab2c3..9d882b9aa003cf 100644 --- a/src/ui/public/agg_types/controls/sub_agg.html +++ b/src/ui/public/agg_types/controls/sub_agg.html @@ -25,7 +25,7 @@
diff --git a/src/ui/public/agg_types/controls/sub_metric.html b/src/ui/public/agg_types/controls/sub_metric.html index d5d53902378b51..937a25cb8c7f6e 100644 --- a/src/ui/public/agg_types/controls/sub_metric.html +++ b/src/ui/public/agg_types/controls/sub_metric.html @@ -5,7 +5,7 @@ diff --git a/src/ui/public/agg_types/controls/time_interval.html b/src/ui/public/agg_types/controls/time_interval.html index 1da3e3ddcd39be..4a980f39c727c5 100644 --- a/src/ui/public/agg_types/controls/time_interval.html +++ b/src/ui/public/agg_types/controls/time_interval.html @@ -9,6 +9,7 @@ > - - -
- Add to your HTML source. Note that all clients must be able to access Kibana. -
-
- - -
- -
- - -
- - - -
-
-
- - -
- -

- Share Snapshot -

- - -
- Snapshot URLs encode the current state of the {{share.objectType}} in the URL itself. Edits to the saved {{share.objectType}} won't be visible via this URL. -
- - -
- -
- - -
- - - - - -
- Add to your HTML source. Note that all clients must be able to access Kibana. -
-
- - -
- - - - - - - -
- We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great. -
-
-
-
diff --git a/src/ui/public/state_management/state_hashing/index.d.ts b/src/ui/public/state_management/state_hashing/index.d.ts new file mode 100644 index 00000000000000..163d9ed07f2cc3 --- /dev/null +++ b/src/ui/public/state_management/state_hashing/index.d.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function unhashUrl(url: string, kbnStates: any[]): any; diff --git a/src/ui/public/styles/_styling_constants.scss b/src/ui/public/styles/_styling_constants.scss index 066890e9a1c17d..af595341fde327 100644 --- a/src/ui/public/styles/_styling_constants.scss +++ b/src/ui/public/styles/_styling_constants.scss @@ -1,9 +1,9 @@ // EUI global scope -@import '../../../../node_modules/@elastic/eui/src/themes/k6/k6_globals'; -@import '../../../../node_modules/@elastic/eui/src/themes/k6/k6_colors_light'; -@import '../../../../node_modules/@elastic/eui/src/global_styling/functions/index'; -@import '../../../../node_modules/@elastic/eui/src/global_styling/variables/index'; -@import '../../../../node_modules/@elastic/eui/src/global_styling/mixins/index'; +@import '@elastic/eui/src/themes/k6/k6_globals'; +@import '@elastic/eui/src/themes/k6/k6_colors_light'; +@import '@elastic/eui/src/global_styling/functions/index'; +@import '@elastic/eui/src/global_styling/variables/index'; +@import '@elastic/eui/src/global_styling/mixins/index'; // EUI TODO: Add this @mixin kibanaCircleLogo() { diff --git a/src/ui/public/styles/disable_animations/disable_animations.js b/src/ui/public/styles/disable_animations/disable_animations.js index 067bbd040df33e..c70aaa2165691f 100644 --- a/src/ui/public/styles/disable_animations/disable_animations.js +++ b/src/ui/public/styles/disable_animations/disable_animations.js @@ -38,7 +38,7 @@ function updateStyleSheet() { } updateStyleSheet(); -uiSettings.subscribe(({ key }) => { +uiSettings.getUpdate$().subscribe(({ key }) => { if (key === 'accessibility:disableAnimations') { updateStyleSheet(); } diff --git a/src/ui/public/styles/navbar.less b/src/ui/public/styles/navbar.less index 335bf10723cb68..bceec41b466497 100644 --- a/src/ui/public/styles/navbar.less +++ b/src/ui/public/styles/navbar.less @@ -111,3 +111,11 @@ navbar { } } } + +.navbar__popover { + height: 100%; + + .euiPopover__anchor { + height: 100%; + } +} diff --git a/src/ui/public/test_harness/test_harness.js b/src/ui/public/test_harness/test_harness.js index 3b5144145de69e..2c6203ab9e2eb6 100644 --- a/src/ui/public/test_harness/test_harness.js +++ b/src/ui/public/test_harness/test_harness.js @@ -24,7 +24,7 @@ import { parse as parseUrl } from 'url'; import sinon from 'sinon'; import { Notifier } from '../notify'; import { metadata } from '../metadata'; -import { UiSettingsClient } from '../../ui_settings/public/ui_settings_client'; +import { UiSettingsClient } from '../../../core/public/ui_settings'; import './test_harness.less'; import 'ng_mock'; @@ -46,16 +46,25 @@ before(() => { sinon.useFakeXMLHttpRequest(); }); -let stubUiSettings = new UiSettingsClient({ - defaults: metadata.uiSettings.defaults, - initialSettings: {}, - notify: new Notifier({ location: 'Config' }), - api: { - batchSet() { - return { settings: stubUiSettings.getAll() }; - } +let stubUiSettings; +function createStubUiSettings() { + if (stubUiSettings) { + stubUiSettings.stop(); } -}); + + stubUiSettings = new UiSettingsClient({ + api: { + async batchSet() { + return { settings: stubUiSettings.getAll() }; + } + }, + onUpdateError: () => {}, + defaults: metadata.uiSettings.defaults, + initialSettings: {}, + }); +} + +createStubUiSettings(); sinon.stub(chrome, 'getUiSettingsClient').callsFake(() => stubUiSettings); beforeEach(function () { @@ -68,16 +77,7 @@ beforeEach(function () { }); afterEach(function () { - stubUiSettings = new UiSettingsClient({ - defaults: metadata.uiSettings.defaults, - initialSettings: {}, - notify: new Notifier({ location: 'Config' }), - api: { - batchSet() { - return { settings: stubUiSettings.getAll() }; - } - } - }); + createStubUiSettings(); }); // Kick off mocha, called at the end of test entry files diff --git a/src/ui/public/theme/theme.js b/src/ui/public/theme/theme.js index 1acaafc070402c..fe7d26dce67cff 100644 --- a/src/ui/public/theme/theme.js +++ b/src/ui/public/theme/theme.js @@ -31,12 +31,7 @@ export function applyTheme(newTheme) { if (styleNode) { const css = themes[currentTheme]; - - if (styleNode.styleSheet) { - styleNode.styleSheet.cssText = css; - } else { - styleNode.appendChild(document.createTextNode(css)); - } + styleNode.textContent = css; } } diff --git a/src/ui/public/url/absolute_to_parsed_url.js b/src/ui/public/url/absolute_to_parsed_url.ts similarity index 87% rename from src/ui/public/url/absolute_to_parsed_url.js rename to src/ui/public/url/absolute_to_parsed_url.ts index ccbe35803ea036..cd2781c56eefe7 100644 --- a/src/ui/public/url/absolute_to_parsed_url.js +++ b/src/ui/public/url/absolute_to_parsed_url.ts @@ -17,9 +17,10 @@ * under the License. */ +import { parse } from 'url'; + import { extractAppPathAndId } from './extract_app_path_and_id'; import { KibanaParsedUrl } from './kibana_parsed_url'; -import { parse } from 'url'; /** * @@ -29,8 +30,15 @@ import { parse } from 'url'; * "/gra". * @return {KibanaParsedUrl} */ -export function absoluteToParsedUrl(absoluteUrl, basePath = '') { +export function absoluteToParsedUrl(absoluteUrl: string, basePath = '') { const { appPath, appId } = extractAppPathAndId(absoluteUrl, basePath); const { hostname, port, protocol } = parse(absoluteUrl); - return new KibanaParsedUrl({ basePath, appId, appPath, hostname, port, protocol }); + return new KibanaParsedUrl({ + basePath, + appId: appId!, + appPath, + hostname, + port, + protocol, + }); } diff --git a/src/ui/public/url/extract_app_path_and_id.js b/src/ui/public/url/extract_app_path_and_id.ts similarity index 94% rename from src/ui/public/url/extract_app_path_and_id.js rename to src/ui/public/url/extract_app_path_and_id.ts index 84ecd2296f00fe..44bba272e0873a 100644 --- a/src/ui/public/url/extract_app_path_and_id.js +++ b/src/ui/public/url/extract_app_path_and_id.ts @@ -25,15 +25,15 @@ import { parse } from 'url'; * @param {string} url - a relative or absolute url which contains an appPath, an appId, and optionally, a basePath. * @param {string} basePath - optional base path, if given should start with "/". */ -export function extractAppPathAndId(url, basePath = '') { +export function extractAppPathAndId(url: string, basePath = '') { const parsedUrl = parse(url); if (!parsedUrl.path) { - return { }; + return {}; } const pathWithoutBase = parsedUrl.path.slice(basePath.length); if (!pathWithoutBase.startsWith('/app/')) { - return { }; + return {}; } const appPath = parsedUrl.hash && parsedUrl.hash.length > 0 ? parsedUrl.hash.slice(1) : ''; diff --git a/src/ui/public/url/index.js b/src/ui/public/url/index.js index 1e0f1f62b9284a..b95c477d8916cd 100644 --- a/src/ui/public/url/index.js +++ b/src/ui/public/url/index.js @@ -19,4 +19,4 @@ export { KbnUrlProvider } from './url'; export { RedirectWhenMissingProvider } from './redirect_when_missing'; -export { modifyUrl } from './modify_url'; +export { modifyUrl } from '../../../core/public/utils'; diff --git a/src/ui/public/url/kibana_parsed_url.js b/src/ui/public/url/kibana_parsed_url.ts similarity index 58% rename from src/ui/public/url/kibana_parsed_url.js rename to src/ui/public/url/kibana_parsed_url.ts index 683af3b4bb2cc9..d05768ceb2c376 100644 --- a/src/ui/public/url/kibana_parsed_url.js +++ b/src/ui/public/url/kibana_parsed_url.ts @@ -19,48 +19,72 @@ import { parse } from 'url'; +import { modifyUrl } from '../../../core/public/utils'; import { prependPath } from './prepend_path'; -import { modifyUrl } from '../../../utils'; -/** - * Represents the pieces that make up a url in Kibana, offering some helpful functionality for - * translating those pieces into absolute or relative urls. A Kibana url with a basePath looks like this: - * http://localhost:5601/basePath/app/appId#/an/appPath?with=query¶ms - * basePath is "/basePath" - * appId is "appId" - * appPath is "/an/appPath?with=query¶ms" - * - * Almost all urls in Kibana should have this structure, including the "/app" portion in front of the appId - * (one exception is the login link). - */ -export class KibanaParsedUrl { +interface Options { + /** + * An optional base path for kibana. If supplied, should start with a "/". + * e.g. in https://localhost:5601/gra/app/kibana#/visualize/edit/viz_id the + * basePath is "/gra" + */ + basePath?: string; + /** - * @param {Object} options - * @property {string} options.basePath - An optional base path for kibana. If supplied, should start with a "/". - * e.g. in https://localhost:5601/gra/app/kibana#/visualize/edit/viz_id the basePath is - * "/gra". - * @property {string} options.appId - the app id. + * The app id. * e.g. in https://localhost:5601/gra/app/kibana#/visualize/edit/viz_id the app id is "kibana". - * @property {string} options.appPath - the path for a page in the the app. Should start with a "/". Don't include the hash sign. Can + */ + appId: string; + + /** + * The path for a page in the the app. Should start with a "/". Don't include the hash sign. Can * include all query parameters. * e.g. in https://localhost:5601/gra/app/kibana#/visualize/edit/viz_id?g=state the appPath is * "/visualize/edit/viz_id?g=state" - * @property {string} options.hostname - Optional hostname. Uses current window location's hostname if no host or - * protocol information is supplied. - * @property {string} options.port - Optional port. Uses current window location's port if no host or protocol - * information is supplied. - * @property {string} options.protocol - Optional protocol. Uses current window location's protocol if no host or - * protocol information is supplied. */ - constructor(options) { - const { - appId, - basePath = '', - appPath = '', - hostname, - protocol, - port - } = options; + appPath?: string; + + /** + * Optional hostname. Uses current window location's hostname if hostname, port, + * and protocol are undefined. + */ + hostname?: string; + + /** + * Optional port. Uses current window location's port if hostname, port, + * and protocol are undefined. + */ + port?: string; + + /** + * Optional protocol. Uses current window location's protocol if hostname, port, + * and protocol are undefined. + */ + protocol?: string; +} + +/** + * Represents the pieces that make up a url in Kibana, offering some helpful functionality + * for translating those pieces into absolute or relative urls. A Kibana url with a basePath + * looks like this: http://localhost:5601/basePath/app/appId#/an/appPath?with=query¶ms + * + * - basePath is "/basePath" + * - appId is "appId" + * - appPath is "/an/appPath?with=query¶ms" + * + * Almost all urls in Kibana should have this structure, including the "/app" portion in front of the appId + * (one exception is the login link). + */ +export class KibanaParsedUrl { + public appId: string; + public appPath: string; + public basePath: string; + public hostname?: string; + public protocol?: string; + public port?: string; + + constructor(options: Options) { + const { appId, basePath = '', appPath = '', hostname, protocol, port } = options; // We'll use window defaults const hostOrProtocolSpecified = hostname || protocol || port; @@ -73,7 +97,7 @@ export class KibanaParsedUrl { this.protocol = hostOrProtocolSpecified ? protocol : window.location.protocol; } - getGlobalState() { + public getGlobalState() { if (!this.appPath) { return ''; } @@ -82,35 +106,39 @@ export class KibanaParsedUrl { return query._g || ''; } - setGlobalState(newGlobalState) { - if (!this.appPath) { return; } + public setGlobalState(newGlobalState: string) { + if (!this.appPath) { + return; + } this.appPath = modifyUrl(this.appPath, parsed => { parsed.query._g = newGlobalState; }); } - addQueryParameter(name, val) { - this.appPath = modifyUrl(this.appPath, parsed => { parsed.query[name] = val; }); + public addQueryParameter(name: string, val: string) { + this.appPath = modifyUrl(this.appPath, parsed => { + parsed.query[name] = val; + }); } - getHashedAppPath() { + public getHashedAppPath() { return `#${this.appPath}`; } - getAppBasePath() { + public getAppBasePath() { return `/${this.appId}`; } - getAppRootPath() { + public getAppRootPath() { return `/app${this.getAppBasePath()}${this.getHashedAppPath()}`; } - getRootRelativePath() { + public getRootRelativePath() { return prependPath(this.getAppRootPath(), this.basePath); } - getAbsoluteUrl() { + public getAbsoluteUrl() { return modifyUrl(this.getRootRelativePath(), parsed => { parsed.protocol = this.protocol; parsed.port = this.port; diff --git a/src/ui/public/url/modify_url.js b/src/ui/public/url/modify_url.js deleted file mode 100644 index 33a17f7ac531ca..00000000000000 --- a/src/ui/public/url/modify_url.js +++ /dev/null @@ -1,21 +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. - */ - -// we select the modify_url directly so the other utils, which are not browser compatible, are not included -export { modifyUrl } from '../../../utils/modify_url'; diff --git a/src/ui/public/url/prepend_path.js b/src/ui/public/url/prepend_path.ts similarity index 94% rename from src/ui/public/url/prepend_path.js rename to src/ui/public/url/prepend_path.ts index 343e2e05b0fcc0..b8a77d5c23beeb 100644 --- a/src/ui/public/url/prepend_path.js +++ b/src/ui/public/url/prepend_path.ts @@ -17,8 +17,8 @@ * under the License. */ -import { parse, format } from 'url'; import { isString } from 'lodash'; +import { format, parse } from 'url'; /** * @@ -28,7 +28,7 @@ import { isString } from 'lodash'; * the relative path isn't in the right format (e.g. doesn't start with a "/") the relativePath is returned * unchanged. */ -export function prependPath(relativePath, newPath = '') { +export function prependPath(relativePath: string, newPath = '') { if (!relativePath || !isString(relativePath)) { return relativePath; } diff --git a/src/ui/public/url/relative_to_absolute.js b/src/ui/public/url/relative_to_absolute.ts similarity index 96% rename from src/ui/public/url/relative_to_absolute.js rename to src/ui/public/url/relative_to_absolute.ts index 14aed9d12eebec..7d0737d145a1b2 100644 --- a/src/ui/public/url/relative_to_absolute.js +++ b/src/ui/public/url/relative_to_absolute.ts @@ -26,7 +26,7 @@ * starts with a "/", for example "/account/cart", you would get back "http://www.mysite.com/account/cart". * @return {string} the relative url transformed into an absolute url */ -export function relativeToAbsolute(url) { +export function relativeToAbsolute(url: string) { // convert all link urls to absolute urls const a = document.createElement('a'); a.setAttribute('href', url); diff --git a/src/ui/public/utils/parse_es_interval.test.ts b/src/ui/public/utils/parse_es_interval.test.ts new file mode 100644 index 00000000000000..05b29a5fb69e71 --- /dev/null +++ b/src/ui/public/utils/parse_es_interval.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parseEsInterval } from './parse_es_interval'; + +describe('parseEsInterval', () => { + it('should correctly parse an interval containing unit and single value', () => { + expect(parseEsInterval('1ms')).toEqual({ value: 1, unit: 'ms', type: 'fixed' }); + expect(parseEsInterval('1s')).toEqual({ value: 1, unit: 's', type: 'fixed' }); + expect(parseEsInterval('1m')).toEqual({ value: 1, unit: 'm', type: 'calendar' }); + expect(parseEsInterval('1h')).toEqual({ value: 1, unit: 'h', type: 'calendar' }); + expect(parseEsInterval('1d')).toEqual({ value: 1, unit: 'd', type: 'calendar' }); + expect(parseEsInterval('1w')).toEqual({ value: 1, unit: 'w', type: 'calendar' }); + expect(parseEsInterval('1M')).toEqual({ value: 1, unit: 'M', type: 'calendar' }); + expect(parseEsInterval('1y')).toEqual({ value: 1, unit: 'y', type: 'calendar' }); + }); + + it('should correctly parse an interval containing unit and multiple value', () => { + expect(parseEsInterval('250ms')).toEqual({ value: 250, unit: 'ms', type: 'fixed' }); + expect(parseEsInterval('90s')).toEqual({ value: 90, unit: 's', type: 'fixed' }); + expect(parseEsInterval('60m')).toEqual({ value: 60, unit: 'm', type: 'fixed' }); + expect(parseEsInterval('12h')).toEqual({ value: 12, unit: 'h', type: 'fixed' }); + expect(parseEsInterval('7d')).toEqual({ value: 7, unit: 'd', type: 'fixed' }); + }); + + it('should throw an error for intervals containing calendar unit and multiple value', () => { + expect(() => parseEsInterval('4w')).toThrowError(); + expect(() => parseEsInterval('12M')).toThrowError(); + expect(() => parseEsInterval('10y')).toThrowError(); + }); + + it('should throw an error for invalid interval formats', () => { + expect(() => parseEsInterval('1')).toThrowError(); + expect(() => parseEsInterval('h')).toThrowError(); + expect(() => parseEsInterval('0m')).toThrowError(); + expect(() => parseEsInterval('0.5h')).toThrowError(); + }); +}); diff --git a/src/ui/public/utils/parse_es_interval.ts b/src/ui/public/utils/parse_es_interval.ts new file mode 100644 index 00000000000000..984f7e278d4fb1 --- /dev/null +++ b/src/ui/public/utils/parse_es_interval.ts @@ -0,0 +1,66 @@ +/* + * 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 dateMath from '@kbn/datemath'; + +const ES_INTERVAL_STRING_REGEX = new RegExp( + '^([1-9][0-9]*)\\s*(' + dateMath.units.join('|') + ')$' +); + +/** + * Extracts interval properties from an ES interval string. Disallows unrecognized interval formats + * and fractional values. Converts some intervals from "calendar" to "fixed" when the number of + * units is larger than 1, and throws an error for others. + * + * Conversion rules: + * + * | Interval | Single unit type | Multiple units type | + * | -------- | ---------------- | ------------------- | + * | ms | fixed | fixed | + * | s | fixed | fixed | + * | m | fixed | fixed | + * | h | calendar | fixed | + * | d | calendar | fixed | + * | w | calendar | N/A - disallowed | + * | M | calendar | N/A - disallowed | + * | y | calendar | N/A - disallowed | + * + */ +export function parseEsInterval(interval: string): { value: number; unit: string; type: string } { + const matches = String(interval) + .trim() + .match(ES_INTERVAL_STRING_REGEX); + + if (!matches) { + throw Error(`Invalid interval format: ${interval}`); + } + + const value = matches && parseFloat(matches[1]); + const unit = matches && matches[2]; + const type = unit && dateMath.unitsMap[unit].type; + + if (type === 'calendar' && value !== 1) { + throw Error(`Invalid calendar interval: ${interval}, value must be 1`); + } + + return { + value, + unit, + type: (type === 'mixed' && value === 1) || type === 'calendar' ? 'calendar' : 'fixed', + }; +} diff --git a/src/ui/public/utils/query_string.d.ts b/src/ui/public/utils/query_string.d.ts new file mode 100644 index 00000000000000..3f3c1752d38b29 --- /dev/null +++ b/src/ui/public/utils/query_string.d.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +declare class QueryStringClass { + public param(key: string, value: string): string; +} + +declare const QueryString: QueryStringClass; + +export { QueryString }; diff --git a/src/ui/public/validate_date_interval.js b/src/ui/public/validate_date_interval.js index ab6906c678f0e5..a315d4eaaa0b70 100644 --- a/src/ui/public/validate_date_interval.js +++ b/src/ui/public/validate_date_interval.js @@ -19,6 +19,7 @@ import { parseInterval } from './utils/parse_interval'; import { uiModules } from './modules'; +import { leastCommonInterval } from './vis/lib/least_common_interval'; uiModules .get('kibana') @@ -27,14 +28,31 @@ uiModules restrict: 'A', require: 'ngModel', link: function ($scope, $el, attrs, ngModelCntrl) { + const baseInterval = attrs.validateDateInterval || null; ngModelCntrl.$parsers.push(check); ngModelCntrl.$formatters.push(check); function check(value) { - ngModelCntrl.$setValidity('dateInterval', parseInterval(value) != null); + if(baseInterval) { + ngModelCntrl.$setValidity('dateInterval', parseWithBase(value) === true); + } else { + ngModelCntrl.$setValidity('dateInterval', parseInterval(value) != null); + } return value; } + + // When base interval is set, check for least common interval and allow + // input the value is the same. This means that the input interval is a + // multiple of the base interval. + function parseWithBase(value) { + try { + const interval = leastCommonInterval(baseInterval, value); + return interval === value.replace(/\s/g, ''); + } catch(e) { + return false; + } + } } }; }); diff --git a/src/ui/public/vis/__tests__/_agg_config.js b/src/ui/public/vis/__tests__/_agg_config.js index 2e87f1a9bffb81..707ccf2d6f17a2 100644 --- a/src/ui/public/vis/__tests__/_agg_config.js +++ b/src/ui/public/vis/__tests__/_agg_config.js @@ -414,11 +414,10 @@ describe('AggConfig', function () { const label = aggConfig.makeLabel(); expect(label).to.be('Count'); }); - it('default label should be "Percentage of Count" when Vis is in percentage mode', function () { + it('default label should be "Percentage of Count" when percentageMode is set to true', function () { const vis = new Vis(indexPattern, {}); const aggConfig = vis.aggs[0]; - aggConfig.vis.params.mode = 'percentage'; - const label = aggConfig.makeLabel(); + const label = aggConfig.makeLabel(true); expect(label).to.be('Percentage of Count'); }); it('empty label if the Vis type is not defined', function () { diff --git a/src/ui/public/vis/__tests__/_agg_configs.js b/src/ui/public/vis/__tests__/_agg_configs.js index 28f8a1bea68c81..14bf84485898a8 100644 --- a/src/ui/public/vis/__tests__/_agg_configs.js +++ b/src/ui/public/vis/__tests__/_agg_configs.js @@ -52,7 +52,7 @@ describe('AggConfigs', function () { aggs: [] }); - const ac = new AggConfigs(vis); + const ac = new AggConfigs(vis.indexPattern, [], vis.type.schemas.all); expect(ac).to.have.length(1); }); @@ -62,16 +62,16 @@ describe('AggConfigs', function () { aggs: [] }); - const ac = new AggConfigs(vis, [ + const ac = new AggConfigs(vis.indexPattern, [ { type: 'date_histogram', schema: 'segment' }, - new AggConfig(vis, { + new AggConfig(vis.aggs, { type: 'terms', schema: 'split' }) - ]); + ], vis.type.schemas.all); expect(ac).to.have.length(3); }); @@ -94,7 +94,7 @@ describe('AggConfigs', function () { ]; const spy = sinon.spy(AggConfig, 'ensureIds'); - new AggConfigs(vis, states); + new AggConfigs(vis.indexPattern, states, vis.type.schemas.all); expect(spy.callCount).to.be(1); expect(spy.firstCall.args[0]).to.be(states); AggConfig.ensureIds.restore(); @@ -136,17 +136,17 @@ describe('AggConfigs', function () { }); it('should only set the number of defaults defined by the max', function () { - const ac = new AggConfigs(vis); + const ac = new AggConfigs(vis.indexPattern, [], vis.type.schemas.all); expect(ac.bySchemaName.metric).to.have.length(2); }); it('should set the defaults defined in the schema when none exist', function () { - const ac = new AggConfigs(vis); + const ac = new AggConfigs(vis.indexPattern, [], vis.type.schemas.all); expect(ac).to.have.length(3); }); it('should NOT set the defaults defined in the schema when some exist', function () { - const ac = new AggConfigs(vis, [{ schema: 'segment', type: 'date_histogram' }]); + const ac = new AggConfigs(vis.indexPattern, [{ schema: 'segment', type: 'date_histogram' }], vis.type.schemas.all); expect(ac).to.have.length(3); expect(ac.bySchemaName.segment[0].type.name).to.equal('date_histogram'); }); @@ -332,7 +332,7 @@ describe('AggConfigs', function () { }); vis.isHierarchical = _.constant(true); - const topLevelDsl = vis.aggs.toDsl(); + const topLevelDsl = vis.aggs.toDsl(vis.isHierarchical()); const buckets = vis.aggs.bySchemaGroup.buckets; const metrics = vis.aggs.bySchemaGroup.metrics; diff --git a/src/ui/public/vis/__tests__/_vis.js b/src/ui/public/vis/__tests__/_vis.js index faccce9643a93f..a5765b8b8fe947 100644 --- a/src/ui/public/vis/__tests__/_vis.js +++ b/src/ui/public/vis/__tests__/_vis.js @@ -48,6 +48,7 @@ describe('Vis Class', function () { const state = (type) => ({ type: { visConfig: { defaults: {} }, + schemas: {}, ...type, } }); @@ -269,10 +270,11 @@ describe('Vis Class', function () { data = { columns: [{ + id: 'col-0', title: 'test', aggConfig }], - rows: [['US']] + rows: [{ 'col-0': 'US' }] }; }); diff --git a/src/ui/public/vis/__tests__/response_handlers/_build_chart_data.js b/src/ui/public/vis/__tests__/response_handlers/_build_chart_data.js index ee3c8647b16352..789b2f94f43fc6 100644 --- a/src/ui/public/vis/__tests__/response_handlers/_build_chart_data.js +++ b/src/ui/public/vis/__tests__/response_handlers/_build_chart_data.js @@ -21,9 +21,8 @@ import _ from 'lodash'; import ngMock from 'ng_mock'; import expect from 'expect.js'; import sinon from 'sinon'; -import { TabifyTable } from '../../../agg_response/tabify/_table'; import { AggResponseIndexProvider } from '../../../agg_response'; -import { BasicResponseHandlerProvider } from '../../response_handlers/basic'; +import { VislibResponseHandlerProvider } from '../../response_handlers/vislib'; describe('renderbot#buildChartData', function () { let buildChartData; @@ -32,7 +31,7 @@ describe('renderbot#buildChartData', function () { beforeEach(ngMock.module('kibana')); beforeEach(ngMock.inject(function (Private) { aggResponse = Private(AggResponseIndexProvider); - buildChartData = Private(BasicResponseHandlerProvider).handler; + buildChartData = Private(VislibResponseHandlerProvider).handler; })); describe('for hierarchical vis', function () { @@ -79,7 +78,7 @@ describe('renderbot#buildChartData', function () { } }; const esResp = { hits: { total: 1 } }; - const tabbed = { tables: [ new TabifyTable() ] }; + const tabbed = { tables: [ {}] }; sinon.stub(aggResponse, 'tabify').returns(tabbed); expect(buildChartData.call(renderbot, esResp)).to.eql(chart); @@ -88,7 +87,7 @@ describe('renderbot#buildChartData', function () { it('converts table groups into rows/columns wrappers for charts', function () { const converter = sinon.stub().returns('chart'); const esResp = { hits: { total: 1 } }; - const tables = [new TabifyTable(), new TabifyTable(), new TabifyTable(), new TabifyTable()]; + const tables = [{}, {}, {}, {}]; const renderbot = { vis: { diff --git a/src/ui/public/vis/__tests__/response_handlers/basic.js b/src/ui/public/vis/__tests__/response_handlers/basic.js index 11aabc0d8d91cd..84febf6270ef2c 100644 --- a/src/ui/public/vis/__tests__/response_handlers/basic.js +++ b/src/ui/public/vis/__tests__/response_handlers/basic.js @@ -19,7 +19,7 @@ import ngMock from 'ng_mock'; import expect from 'expect.js'; -import { BasicResponseHandlerProvider } from '../../response_handlers/basic'; +import { VislibResponseHandlerProvider } from '../../response_handlers/vislib'; import { VisProvider } from '../..'; import fixtures from 'fixtures/fake_hierarchical_data'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; @@ -39,7 +39,7 @@ describe('Basic Response Handler', function () { beforeEach(ngMock.module('kibana')); beforeEach(ngMock.inject(function (Private) { - basicResponseHandler = Private(BasicResponseHandlerProvider).handler; + basicResponseHandler = Private(VislibResponseHandlerProvider).handler; Vis = Private(VisProvider); indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); })); diff --git a/src/ui/public/vis/__tests__/vis_types/vislib_vis_type.js b/src/ui/public/vis/__tests__/vis_types/vislib_vis_type.js index 6779638f1a4ca6..5ad5f58b70b95c 100644 --- a/src/ui/public/vis/__tests__/vis_types/vislib_vis_type.js +++ b/src/ui/public/vis/__tests__/vis_types/vislib_vis_type.js @@ -39,9 +39,9 @@ describe('Vislib Vis Type', function () { })); describe('initialization', () => { - it('should set the basic response handler if not set', () => { + it('should set the vislib response handler if not set', () => { const visType = new VislibVisType(visConfig); - expect(visType.responseHandler).to.equal('basic'); + expect(visType.responseHandler).to.equal('vislib'); }); it('should not change response handler if its already set', () => { diff --git a/src/ui/public/vis/agg_config.js b/src/ui/public/vis/agg_config.js index 1df28693e1b520..881a3457905a82 100644 --- a/src/ui/public/vis/agg_config.js +++ b/src/ui/public/vis/agg_config.js @@ -62,11 +62,9 @@ class AggConfig { }, 0); } - constructor(vis, opts = {}, aggs) { - this.id = String(opts.id || AggConfig.nextId(vis.aggs)); - this.vis = vis; - this._indexPattern = vis.indexPattern; - this._aggs = aggs || vis.aggs; + constructor(aggConfigs, opts = {}) { + this.aggConfigs = aggConfigs; + this.id = String(opts.id || AggConfig.nextId(aggConfigs)); this._opts = opts; this.enabled = typeof opts.enabled === 'boolean' ? opts.enabled : true; @@ -261,28 +259,22 @@ class AggConfig { return this.params.field; } - makeLabel() { + makeLabel(percentageMode = false) { if (this.params.customLabel) { return this.params.customLabel; } if (!this.type) return ''; - let pre = (_.get(this.vis, 'params.mode') === 'percentage') ? 'Percentage of ' : ''; + let pre = percentageMode ? 'Percentage of ' : ''; return pre += this.type.makeLabel(this); } getIndexPattern() { - return this.vis.indexPattern; + return _.get(this.aggConfigs, 'indexPattern', null); } - getFieldOptions() { - const fieldParamType = this.type && this.type.params.byName.field; - - if (!fieldParamType || !fieldParamType.getFieldOptions) { - return null; - } - - return fieldParamType.getFieldOptions(this); + getTimeRange() { + return _.get(this.aggConfigs, 'timeRange', null); } fieldFormatter(contentType, defaultFormat) { @@ -305,7 +297,7 @@ class AggConfig { } fieldIsTimeField() { - const timeFieldName = this.vis.indexPattern.timeFieldName; + const timeFieldName = this.getIndexPattern().timeFieldName; return timeFieldName && this.fieldName() === timeFieldName; } @@ -333,13 +325,15 @@ class AggConfig { this.__type = type; + const fieldParam = _.get(this, 'type.params.byName.field'); + const availableFields = fieldParam ? fieldParam.getAvailableFields(this.getIndexPattern().fields) : []; // clear out the previous params except for a few special ones this.setParams({ // split row/columns is "outside" of the agg, so don't reset it row: this.params.row, // almost every agg has fields, so we try to persist that when type changes - field: _.get(this.getFieldOptions(), ['byName', this.getField()]) + field: _.get(availableFields, ['byName', this.getField()]) }); } @@ -348,8 +342,8 @@ class AggConfig { } set schema(schema) { - if (_.isString(schema)) { - schema = this.vis.type.schemas.all.byName[schema]; + if (_.isString(schema) && this.aggConfigs.schemas) { + schema = this.aggConfigs.schemas.byName[schema]; } this.__schema = schema; diff --git a/src/ui/public/vis/agg_configs.js b/src/ui/public/vis/agg_configs.js index de7e805158641a..6aebeabd081599 100644 --- a/src/ui/public/vis/agg_configs.js +++ b/src/ui/public/vis/agg_configs.js @@ -47,7 +47,7 @@ function parseParentAggs(dslLvlCursor, dsl) { } class AggConfigs extends IndexedArray { - constructor(vis, configStates = []) { + constructor(indexPattern, configStates = [], schemas) { configStates = AggConfig.ensureIds(configStates); super({ @@ -55,36 +55,77 @@ class AggConfigs extends IndexedArray { group: ['schema.group', 'type.name', 'schema.name'], }); - this.push(...configStates.map(aggConfigState => { - if (aggConfigState instanceof AggConfig) { - return aggConfigState; - } - return new AggConfig(vis, aggConfigState, this); - })); + this.indexPattern = indexPattern; + this.schemas = schemas; - this.vis = vis; + configStates.forEach(params => this.createAggConfig(params)); + if (this.schemas) { + this.initializeDefaultsFromSchemas(schemas); + } + } + + initializeDefaultsFromSchemas(schemas) { // Set the defaults for any schema which has them. If the defaults // for some reason has more then the max only set the max number // of defaults (not sure why a someone define more... // but whatever). Also if a schema.name is already set then don't // set anything. - if (vis && vis.type && vis.type.schemas && vis.type.schemas.all) { - _(vis.type.schemas.all) - .filter(schema => { - return Array.isArray(schema.defaults) && schema.defaults.length > 0; - }) - .each(schema => { - if (!this.bySchemaName[schema.name]) { - const defaults = schema.defaults.slice(0, schema.max); - _.each(defaults, defaultState => { - const state = _.defaults({ id: AggConfig.nextId(this) }, defaultState); - this.push(new AggConfig(vis, state, this)); - }); - } - }) - .commit(); + _(schemas) + .filter(schema => { + return Array.isArray(schema.defaults) && schema.defaults.length > 0; + }) + .each(schema => { + if (!this.bySchemaName[schema.name]) { + const defaults = schema.defaults.slice(0, schema.max); + _.each(defaults, defaultState => { + const state = _.defaults({ id: AggConfig.nextId(this) }, defaultState); + this.push(new AggConfig(this, state)); + }); + } + }) + .commit(); + } + + setTimeRange(timeRange) { + this.timeRange = timeRange; + + const updateAggTimeRange = (agg) => { + _.each(agg.params, param => { + if (param instanceof AggConfig) { + updateAggTimeRange(param); + } + }); + if (_.get(agg, 'type.name') === 'date_histogram') { + agg.params.timeRange = timeRange; + } + }; + + this.forEach(updateAggTimeRange); + } + + // clone method will reuse existing AggConfig in the list (will not create new instances) + clone({ enabledOnly = true } = {}) { + const filterAggs = (agg) => { + if (!enabledOnly) return true; + return agg.enabled; + }; + const aggConfigs = new AggConfigs(this.indexPattern, this.raw.filter(filterAggs), this.schemas); + return aggConfigs; + } + + createAggConfig(params, { addToAggConfigs = true } = {}) { + let aggConfig; + if (params instanceof AggConfig) { + aggConfig = params; + params.parent = this; + } else { + aggConfig = new AggConfig(this, params); + } + if (addToAggConfigs) { + this.push(aggConfig); } + return aggConfig; } /** @@ -104,14 +145,14 @@ class AggConfigs extends IndexedArray { return true; } - toDsl() { + toDsl(hierarchical = false) { const dslTopLvl = {}; let dslLvlCursor; let nestedMetrics; - if (this.vis.isHierarchical()) { + if (hierarchical) { // collect all metrics, and filter out the ones that we won't be copying - nestedMetrics = _(this.vis.aggs.bySchemaGroup.metrics) + nestedMetrics = _(this.bySchemaGroup.metrics) .filter(function (agg) { return agg.type.name !== 'count'; }) diff --git a/src/ui/public/vis/editors/config/editor_config_providers.test.ts b/src/ui/public/vis/editors/config/editor_config_providers.test.ts index ab0b8b10718b14..3ba4c78b0abb5c 100644 --- a/src/ui/public/vis/editors/config/editor_config_providers.test.ts +++ b/src/ui/public/vis/editors/config/editor_config_providers.test.ts @@ -18,7 +18,7 @@ */ import { EditorConfigProviderRegistry } from './editor_config_providers'; -import { EditorParamConfig, FixedParam, NumericIntervalParam } from './types'; +import { EditorParamConfig, FixedParam, NumericIntervalParam, TimeIntervalParam } from './types'; describe('EditorConfigProvider', () => { let registry: EditorConfigProviderRegistry; @@ -111,6 +111,49 @@ describe('EditorConfigProvider', () => { }).toThrowError(); }); + it('should allow same timeBase values', () => { + registry.register(singleConfig({ timeBase: '2h', default: '2h' })); + registry.register(singleConfig({ timeBase: '2h', default: '2h' })); + const config = getOutputConfig(registry) as TimeIntervalParam; + expect(config).toHaveProperty('timeBase'); + expect(config).toHaveProperty('default'); + expect(config.timeBase).toBe('2h'); + expect(config.default).toBe('2h'); + }); + + it('should merge multiple compatible timeBase values, using least common interval', () => { + registry.register(singleConfig({ timeBase: '2h', default: '2h' })); + registry.register(singleConfig({ timeBase: '3h', default: '3h' })); + registry.register(singleConfig({ timeBase: '4h', default: '4h' })); + const config = getOutputConfig(registry) as TimeIntervalParam; + expect(config).toHaveProperty('timeBase'); + expect(config).toHaveProperty('default'); + expect(config.timeBase).toBe('12h'); + expect(config.default).toBe('12h'); + }); + + it('should throw on combining incompatible timeBase values', () => { + registry.register(singleConfig({ timeBase: '2h', default: '2h' })); + registry.register(singleConfig({ timeBase: '1d', default: '1d' })); + expect(() => { + getOutputConfig(registry); + }).toThrowError(); + }); + + it('should throw on invalid timeBase values', () => { + registry.register(singleConfig({ timeBase: '2w', default: '2w' })); + expect(() => { + getOutputConfig(registry); + }).toThrowError(); + }); + + it('should throw if timeBase and default are different', () => { + registry.register(singleConfig({ timeBase: '1h', default: '2h' })); + expect(() => { + getOutputConfig(registry); + }).toThrowError(); + }); + it('should merge hidden together with fixedValue', () => { registry.register(singleConfig({ fixedValue: 'foo', hidden: true })); registry.register(singleConfig({ fixedValue: 'foo', hidden: false })); @@ -131,12 +174,24 @@ describe('EditorConfigProvider', () => { expect(config.hidden).toBe(false); }); - it('should merge warnings together into one string', () => { - registry.register(singleConfig({ warning: 'Warning' })); - registry.register(singleConfig({ warning: 'Another warning' })); + it('should merge hidden together with timeBase', () => { + registry.register(singleConfig({ timeBase: '2h', default: '2h', hidden: false })); + registry.register(singleConfig({ timeBase: '4h', default: '4h', hidden: false })); + const config = getOutputConfig(registry) as TimeIntervalParam; + expect(config).toHaveProperty('timeBase'); + expect(config).toHaveProperty('default'); + expect(config).toHaveProperty('hidden'); + expect(config.timeBase).toBe('4h'); + expect(config.default).toBe('4h'); + expect(config.hidden).toBe(false); + }); + + it('should merge helps together into one string', () => { + registry.register(singleConfig({ help: 'Warning' })); + registry.register(singleConfig({ help: 'Another help' })); const config = getOutputConfig(registry); - expect(config).toHaveProperty('warning'); - expect(config.warning).toBe('Warning\n\nAnother warning'); + expect(config).toHaveProperty('help'); + expect(config.help).toBe('Warning\n\nAnother help'); }); }); }); diff --git a/src/ui/public/vis/editors/config/editor_config_providers.ts b/src/ui/public/vis/editors/config/editor_config_providers.ts index d11144ab0ae613..6175b897f62fea 100644 --- a/src/ui/public/vis/editors/config/editor_config_providers.ts +++ b/src/ui/public/vis/editors/config/editor_config_providers.ts @@ -17,10 +17,13 @@ * under the License. */ +import { TimeIntervalParam } from 'ui/vis/editors/config/types'; import { AggConfig } from '../..'; import { AggType } from '../../../agg_types'; import { IndexPattern } from '../../../index_patterns'; import { leastCommonMultiple } from '../../../utils/math'; +import { parseEsInterval } from '../../../utils/parse_es_interval'; +import { leastCommonInterval } from '../../lib/least_common_interval'; import { EditorConfig, EditorParamConfig, FixedParam, NumericIntervalParam } from './types'; type EditorConfigProvider = ( @@ -47,6 +50,10 @@ class EditorConfigProviderRegistry { return this.mergeConfigs(configs); } + private isTimeBaseParam(config: EditorParamConfig): config is TimeIntervalParam { + return config.hasOwnProperty('default') && config.hasOwnProperty('timeBase'); + } + private isBaseParam(config: EditorParamConfig): config is NumericIntervalParam { return config.hasOwnProperty('base'); } @@ -59,12 +66,12 @@ class EditorConfigProviderRegistry { return Boolean(current.hidden || merged.hidden); } - private mergeWarning(current: EditorParamConfig, merged: EditorParamConfig): string | undefined { - if (!current.warning) { - return merged.warning; + private mergeHelp(current: EditorParamConfig, merged: EditorParamConfig): string | undefined { + if (!current.help) { + return merged.help; } - return merged.warning ? `${merged.warning}\n\n${current.warning}` : current.warning; + return merged.help ? `${merged.help}\n\n${current.help}` : current.help; } private mergeFixedAndBase( @@ -95,7 +102,7 @@ class EditorConfigProviderRegistry { } if (this.isBaseParam(current) && this.isBaseParam(merged)) { - // In case both had where interval values, just use the least common multiple between both interval + // In case where both had interval values, just use the least common multiple between both interval return { base: leastCommonMultiple(current.base, merged.base), }; @@ -108,7 +115,6 @@ class EditorConfigProviderRegistry { fixedValue: current.fixedValue, }; } - if (this.isBaseParam(current)) { return { base: current.base, @@ -118,18 +124,57 @@ class EditorConfigProviderRegistry { return {}; } + private mergeTimeBase( + current: TimeIntervalParam, + merged: EditorParamConfig, + paramName: string + ): { timeBase?: string; default?: string } { + if (current.default !== current.timeBase) { + throw new Error(`Tried to provide differing default and timeBase values for ${paramName}.`); + } + + if (this.isTimeBaseParam(current) && this.isTimeBaseParam(merged)) { + // In case both had where interval values, just use the least common multiple between both intervals + try { + const timeBase = leastCommonInterval(current.timeBase, merged.timeBase); + return { + default: timeBase, + timeBase, + }; + } catch (e) { + throw e; + } + } + + if (this.isTimeBaseParam(current)) { + try { + parseEsInterval(current.timeBase); + return { + default: current.timeBase, + timeBase: current.timeBase, + }; + } catch (e) { + throw e; + } + } + + return {}; + } + private mergeConfigs(configs: EditorConfig[]): EditorConfig { return configs.reduce((output, conf) => { Object.entries(conf).forEach(([paramName, paramConfig]) => { if (!output[paramName]) { - output[paramName] = { ...paramConfig }; - } else { - output[paramName] = { - hidden: this.mergeHidden(paramConfig, output[paramName]), - warning: this.mergeWarning(paramConfig, output[paramName]), - ...this.mergeFixedAndBase(paramConfig, output[paramName], paramName), - }; + output[paramName] = {}; } + + output[paramName] = { + hidden: this.mergeHidden(paramConfig, output[paramName]), + help: this.mergeHelp(paramConfig, output[paramName]), + ...(this.isTimeBaseParam(paramConfig) + ? this.mergeTimeBase(paramConfig, output[paramName], paramName) + : this.mergeFixedAndBase(paramConfig, output[paramName], paramName)), + }; }); return output; }, {}); diff --git a/src/ui/public/vis/editors/config/types.ts b/src/ui/public/vis/editors/config/types.ts index e8c49232c878f8..61c0ced3cd5198 100644 --- a/src/ui/public/vis/editors/config/types.ts +++ b/src/ui/public/vis/editors/config/types.ts @@ -22,7 +22,7 @@ */ interface Param { hidden?: boolean; - warning?: string; + help?: string; } /** @@ -41,7 +41,16 @@ export type NumericIntervalParam = Partial & { base: number; }; -export type EditorParamConfig = NumericIntervalParam | FixedParam | Param; +/** + * Time interval parameters must always be set in the editor to a multiple of + * the specified base. It can optionally also be hidden. + */ +export type TimeIntervalParam = Partial & { + default: string; + timeBase: string; +}; + +export type EditorParamConfig = NumericIntervalParam | TimeIntervalParam | FixedParam | Param; export interface EditorConfig { [paramName: string]: EditorParamConfig; diff --git a/src/ui/public/vis/editors/default/__tests__/agg.js b/src/ui/public/vis/editors/default/__tests__/agg.js index 6fc586417b075d..0f5c76f2f428f1 100644 --- a/src/ui/public/vis/editors/default/__tests__/agg.js +++ b/src/ui/public/vis/editors/default/__tests__/agg.js @@ -64,8 +64,7 @@ describe('Vis-Editor-Agg plugin directive', function () { $parentScope.agg = { id: 1, params: {}, - schema: makeConfig(), - getFieldOptions: () => null + schema: makeConfig() }; $parentScope.groupName = 'metrics'; $parentScope.group = [{ diff --git a/src/ui/public/vis/editors/default/__tests__/agg_params.js b/src/ui/public/vis/editors/default/__tests__/agg_params.js index 70abf6d676f736..9e84cdf4b90ada 100644 --- a/src/ui/public/vis/editors/default/__tests__/agg_params.js +++ b/src/ui/public/vis/editors/default/__tests__/agg_params.js @@ -76,7 +76,7 @@ describe('Vis-Editor-Agg-Params plugin directive', function () { ] }); - $parentScope.agg = new AggConfig(vis, state); + $parentScope.agg = new AggConfig(vis.aggs, state); $parentScope.vis = vis; // make the element diff --git a/src/ui/public/vis/editors/default/agg_add.js b/src/ui/public/vis/editors/default/agg_add.js index 965323ccac4fd6..9c7e2c4fc1e61c 100644 --- a/src/ui/public/vis/editors/default/agg_add.js +++ b/src/ui/public/vis/editors/default/agg_add.js @@ -36,7 +36,7 @@ uiModules self.submit = function (schema) { self.form = false; - const aggConfig = new AggConfig($scope.vis, { + const aggConfig = new AggConfig($scope.state.aggs, { schema: schema, id: AggConfig.nextId($scope.state.aggs), }); diff --git a/src/ui/public/vis/editors/default/agg_group.html b/src/ui/public/vis/editors/default/agg_group.html index dfa04e4de9dc55..965390fd86091f 100644 --- a/src/ui/public/vis/editors/default/agg_group.html +++ b/src/ui/public/vis/editors/default/agg_group.html @@ -10,6 +10,6 @@
- +
diff --git a/src/ui/public/vis/editors/default/agg_params.js b/src/ui/public/vis/editors/default/agg_params.js index d89e7e3944818d..de252c2f2dd821 100644 --- a/src/ui/public/vis/editors/default/agg_params.js +++ b/src/ui/public/vis/editors/default/agg_params.js @@ -29,6 +29,7 @@ import { documentationLinks } from '../../../documentation_links/documentation_l import aggParamsTemplate from './agg_params.html'; import { aggTypeFilters } from '../../../agg_types/filter'; import { editorConfigProviders } from '../config/editor_config_providers'; +import { aggTypeFieldFilters } from '../../../agg_types/param_types/filter'; uiModules .get('app/visualize') @@ -50,9 +51,12 @@ uiModules // We set up this watch prior to adding the controls below, because when the controls are added, // there is a possibility that the agg type can be automatically selected (if there is only one) - $scope.$watch('agg.type', updateAggParamEditor); + $scope.$watch('agg.type', () => { + updateAggParamEditor(); + updateEditorConfig('default'); + }); - function updateEditorConfig() { + function updateEditorConfig(property = 'fixedValue') { $scope.editorConfig = editorConfigProviders.getConfigForAgg( aggTypes.byType[$scope.groupName], $scope.indexPattern, @@ -61,17 +65,21 @@ uiModules Object.keys($scope.editorConfig).forEach(param => { const config = $scope.editorConfig[param]; + const paramOptions = $scope.agg.type.params.find((paramOption) => paramOption.name === param); // If the parameter has a fixed value in the config, set this value. // Also for all supported configs we should freeze the editor for this param. - if (config.hasOwnProperty('fixedValue')) { - $scope.agg.params[param] = config.fixedValue; + if (config.hasOwnProperty(property)) { + if(paramOptions && paramOptions.deserialize) { + $scope.agg.params[param] = paramOptions.deserialize(config[property]); + } else { + $scope.agg.params[param] = config[property]; + } } }); } - $scope.$watchCollection('agg.params', updateEditorConfig); - updateEditorConfig(); + $scope.$watchCollection('agg.params', updateEditorConfig); // this will contain the controls for the schema (rows or columns?), which are unrelated to // controls for the agg, which is why they are first @@ -122,7 +130,6 @@ uiModules // create child scope, used in the editors $aggParamEditorsScope = $scope.$new(); - $aggParamEditorsScope.indexedFields = $scope.agg.getFieldOptions(); const aggParamHTML = { basic: [], advanced: [] @@ -139,10 +146,10 @@ uiModules return; } // if field param exists, compute allowed fields - if (param.name === 'field') { - fields = $aggParamEditorsScope.indexedFields; - } else if (param.type === 'field') { - fields = $aggParamEditorsScope[`${param.name}Options`] = param.getFieldOptions($scope.agg); + if (param.type === 'field') { + const availableFields = param.getAvailableFields($scope.agg.getIndexPattern().fields); + fields = $scope.indexedFields = $aggParamEditorsScope[`${param.name}Options`] = + aggTypeFieldFilters.filter(availableFields, param.type, $scope.agg, $scope.vis); } if (fields) { diff --git a/src/ui/public/vis/editors/default/default.js b/src/ui/public/vis/editors/default/default.js index 39171a3f97fd19..8015f23ac3a995 100644 --- a/src/ui/public/vis/editors/default/default.js +++ b/src/ui/public/vis/editors/default/default.js @@ -21,7 +21,6 @@ import './sidebar'; import './vis_options'; import './vis_editor_resizer'; import './vis_type_agg_filter'; -import './vis_type_field_filter'; import $ from 'jquery'; import _ from 'lodash'; diff --git a/src/ui/public/vis/editors/default/sidebar.html b/src/ui/public/vis/editors/default/sidebar.html index 6b04bb29819762..511a21ca0aa91d 100644 --- a/src/ui/public/vis/editors/default/sidebar.html +++ b/src/ui/public/vis/editors/default/sidebar.html @@ -139,10 +139,10 @@
- + - +
diff --git a/src/ui/public/vis/editors/default/vis_options.js b/src/ui/public/vis/editors/default/vis_options.js index 69d58bbd9274cb..cb1e3131cce0c1 100644 --- a/src/ui/public/vis/editors/default/vis_options.js +++ b/src/ui/public/vis/editors/default/vis_options.js @@ -22,6 +22,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { uiModules } from '../../../modules'; import visOptionsTemplate from './vis_options.html'; +import { I18nProvider } from '@kbn/i18n/react'; /** * This directive sort of "transcludes" in whatever template you pass in via the `editor` attribute. @@ -53,7 +54,10 @@ uiModules }; const renderReactComponent = () => { const Component = $scope.editor; - render(, $el[0]); + render( + + + , $el[0]); }; // Bind the `editor` template with the scope. if (reactOptionsComponent) { diff --git a/src/ui/public/vis/editors/default/vis_type_field_filter.ts b/src/ui/public/vis/editors/default/vis_type_field_filter.ts deleted file mode 100644 index 926b73da356757..00000000000000 --- a/src/ui/public/vis/editors/default/vis_type_field_filter.ts +++ /dev/null @@ -1,56 +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 { isFunction } from 'lodash'; -import { FieldParamType } from '../../../agg_types/param_types'; -import { aggTypeFieldFilters } from '../../../agg_types/param_types/filter'; -import { IndexPattern } from '../../../index_patterns'; -import { AggConfig } from '../../../vis'; - -import { propFilter } from '../../../filters/_prop_filter'; - -const filterByType = propFilter('type'); - -/** - * This filter uses the {@link FieldParamType|fieldParamType} information - * and limits available fields based on that. - */ -aggTypeFieldFilters.addFilter( - ( - field: any, - fieldParamType: FieldParamType, - indexPattern: IndexPattern, - aggConfig: AggConfig - ) => { - const { onlyAggregatable, scriptable, filterFieldTypes } = fieldParamType; - - const filters = isFunction(filterFieldTypes) - ? filterFieldTypes.bind(fieldParamType, aggConfig.vis) - : filterFieldTypes; - - if ((onlyAggregatable && !field.aggregatable) || (!scriptable && field.scripted)) { - return false; - } - - if (!filters) { - return true; - } - - return filterByType([field], filters).length !== 0; - } -); diff --git a/src/ui/public/vis/lib/least_common_interval.test.ts b/src/ui/public/vis/lib/least_common_interval.test.ts new file mode 100644 index 00000000000000..f19b5c51f68b33 --- /dev/null +++ b/src/ui/public/vis/lib/least_common_interval.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { leastCommonInterval } from './least_common_interval'; + +describe('leastCommonInterval', () => { + it('should correctly return lowest common interval for fixed units', () => { + expect(leastCommonInterval('1ms', '1s')).toBe('1s'); + expect(leastCommonInterval('500ms', '1s')).toBe('1s'); + expect(leastCommonInterval('1000ms', '1s')).toBe('1s'); + expect(leastCommonInterval('1500ms', '1s')).toBe('3s'); + expect(leastCommonInterval('1234ms', '1s')).toBe('617s'); + expect(leastCommonInterval('1s', '2m')).toBe('2m'); + expect(leastCommonInterval('300s', '2m')).toBe('10m'); + expect(leastCommonInterval('1234ms', '7m')).toBe('4319m'); + expect(leastCommonInterval('45m', '2h')).toBe('6h'); + expect(leastCommonInterval('12h', '4d')).toBe('4d'); + expect(leastCommonInterval(' 20 h', '7d')).toBe('35d'); + }); + + it('should correctly return lowest common interval for calendar units', () => { + expect(leastCommonInterval('1m', '1h')).toBe('1h'); + expect(leastCommonInterval('1h', '1d')).toBe('1d'); + expect(leastCommonInterval('1d', '1w')).toBe('1w'); + expect(leastCommonInterval('1w', '1M')).toBe('1M'); + expect(leastCommonInterval('1M', '1y')).toBe('1y'); + expect(leastCommonInterval('1M', '1m')).toBe('1M'); + expect(leastCommonInterval('1y', '1w')).toBe('1y'); + }); + + it('should throw an error for intervals of different types', () => { + expect(() => { + leastCommonInterval('60 s', '1m'); + }).toThrowError(); + expect(() => { + leastCommonInterval('1d', '7d'); + }).toThrowError(); + expect(() => { + leastCommonInterval('1h', '3d'); + }).toThrowError(); + expect(() => { + leastCommonInterval('7d', '1w'); + }).toThrowError(); + expect(() => { + leastCommonInterval('1M', '1000ms'); + }).toThrowError(); + }); + + it('should throw an error for invalid intervals', () => { + expect(() => { + leastCommonInterval('foo', 'bar'); + }).toThrowError(); + expect(() => { + leastCommonInterval('0h', '1h'); + }).toThrowError(); + expect(() => { + leastCommonInterval('0.5h', '1h'); + }).toThrowError(); + expect(() => { + leastCommonInterval('5w', '1h'); + }).toThrowError(); + expect(() => { + leastCommonInterval('2M', '4w'); + }).toThrowError(); + }); +}); diff --git a/src/ui/public/vis/lib/least_common_interval.ts b/src/ui/public/vis/lib/least_common_interval.ts new file mode 100644 index 00000000000000..bbe3e6d4ee7c5a --- /dev/null +++ b/src/ui/public/vis/lib/least_common_interval.ts @@ -0,0 +1,82 @@ +/* + * 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 dateMath from '@kbn/datemath'; +import { leastCommonMultiple } from '../../utils/math'; +import { parseEsInterval } from '../../utils/parse_es_interval'; + +/** + * Finds the lowest common interval between two given ES date histogram intervals + * in the format of (value)(unit) + * + * - `ms, s` units are fixed-length intervals + * - `m, h, d` units are fixed-length intervals when value > 1 (i.e. 2m, 24h, 7d), + * but calendar interval when value === 1 + * - `w, M, q, y` units are calendar intervals and do not support multiple, aka + * value must === 1 + * + * @returns {string} + */ +export function leastCommonInterval(a: string, b: string): string { + const { unitsMap, unitsDesc } = dateMath; + const aInt = parseEsInterval(a); + const bInt = parseEsInterval(b); + + if (a === b) { + return a; + } + + const aUnit = unitsMap[aInt.unit]; + const bUnit = unitsMap[bInt.unit]; + + // If intervals aren't the same type, throw error + if (aInt.type !== bInt.type) { + throw Error(`Incompatible intervals: ${a} (${aInt.type}), ${b} (${bInt.type})`); + } + + // If intervals are calendar units, pick the larger one (calendar value is always 1) + if (aInt.type === 'calendar') { + return aUnit.weight > bUnit.weight ? `${aInt.value}${aInt.unit}` : `${bInt.value}${bInt.unit}`; + } + + // Otherwise if intervals are fixed units, find least common multiple in milliseconds + const aMs = aInt.value * aUnit.base; + const bMs = bInt.value * bUnit.base; + const lcmMs = leastCommonMultiple(aMs, bMs); + + // Return original interval string if it matches one of the original milliseconds + if (lcmMs === bMs) { + return b.replace(/\s/g, ''); + } + if (lcmMs === aMs) { + return a.replace(/\s/g, ''); + } + + // Otherwise find the biggest unit that divides evenly + const lcmUnit = unitsDesc.find(unit => unitsMap[unit].base && lcmMs % unitsMap[unit].base === 0); + + // Throw error in case we couldn't divide evenly, theoretically we never get here as everything is + // divisible by 1 millisecond + if (!lcmUnit) { + throw Error(`Unable to find common interval for: ${a}, ${b}`); + } + + // Return the interval string + return `${lcmMs / unitsMap[lcmUnit].base}${lcmUnit}`; +} diff --git a/src/ui/public/vis/map/convert_to_geojson.js b/src/ui/public/vis/map/convert_to_geojson.js index 317c2b4a5865c5..8ce11ede15c724 100644 --- a/src/ui/public/vis/map/convert_to_geojson.js +++ b/src/ui/public/vis/map/convert_to_geojson.js @@ -28,27 +28,29 @@ export function convertToGeoJson(tabifiedResponse) { let max = -Infinity; let geoAgg; - if (tabifiedResponse && tabifiedResponse.tables && tabifiedResponse.tables[0] && tabifiedResponse.tables[0].rows) { + if (tabifiedResponse && tabifiedResponse.rows) { - const table = tabifiedResponse.tables[0]; - const geohashIndex = table.columns.findIndex(column => column.aggConfig.type.dslName === 'geohash_grid'); - geoAgg = table.columns.find(column => column.aggConfig.type.dslName === 'geohash_grid'); + const table = tabifiedResponse; + const geohashColumn = table.columns.find(column => column.aggConfig.type.dslName === 'geohash_grid'); - if (geohashIndex === -1) { + if (!geohashColumn) { features = []; } else { - const metricIndex = table.columns.findIndex(column => column.aggConfig.type.type === 'metrics'); - const geocentroidIndex = table.columns.findIndex(column => column.aggConfig.type.dslName === 'geo_centroid'); + geoAgg = geohashColumn.aggConfig; + + const metricColumn = table.columns.find(column => column.aggConfig.type.type === 'metrics'); + const geocentroidColumn = table.columns.find(column => column.aggConfig.type.dslName === 'geo_centroid'); features = table.rows.map(row => { - const geohash = row[geohashIndex]; + const geohash = row[geohashColumn.id]; + if (!geohash) return false; const geohashLocation = decodeGeoHash(geohash); let pointCoordinates; - if (geocentroidIndex > -1) { - const location = row[geocentroidIndex]; + if (geocentroidColumn) { + const location = row[geocentroidColumn.id]; pointCoordinates = [location.lon, location.lat]; } else { pointCoordinates = [geohashLocation.longitude[2], geohashLocation.latitude[2]]; @@ -66,13 +68,13 @@ export function convertToGeoJson(tabifiedResponse) { geohashLocation.longitude[2] ]; - if (geoAgg.aggConfig.params.useGeocentroid) { + if (geoAgg.params.useGeocentroid) { // see https://github.com/elastic/elasticsearch/issues/24694 for why clampGrid is used pointCoordinates[0] = clampGrid(pointCoordinates[0], geohashLocation.longitude[0], geohashLocation.longitude[1]); pointCoordinates[1] = clampGrid(pointCoordinates[1], geohashLocation.latitude[0], geohashLocation.latitude[1]); } - const value = row[metricIndex]; + const value = row[metricColumn.id]; min = Math.min(min, value); max = Math.max(max, value); @@ -93,7 +95,7 @@ export function convertToGeoJson(tabifiedResponse) { }; - }); + }).filter(row => row); } @@ -111,8 +113,8 @@ export function convertToGeoJson(tabifiedResponse) { meta: { min: min, max: max, - geohashPrecision: geoAgg && geoAgg.aggConfig.params.precision, - geohashGridDimensionsAtEquator: geoAgg && gridDimensions(geoAgg.aggConfig.params.precision) + geohashPrecision: geoAgg && geoAgg.params.precision, + geohashGridDimensionsAtEquator: geoAgg && gridDimensions(geoAgg.params.precision) } }; } diff --git a/src/ui/public/vis/request_handlers/courier.js b/src/ui/public/vis/request_handlers/courier.js index eb9ff172d62265..fd64c225863a00 100644 --- a/src/ui/public/vis/request_handlers/courier.js +++ b/src/ui/public/vis/request_handlers/courier.js @@ -35,10 +35,8 @@ const CourierRequestHandlerProvider = function () { */ async function buildTabularInspectorData(vis, searchSource, aggConfigs) { const table = tabifyAggResponse(aggConfigs, searchSource.finalResponse, { - canSplit: false, - asAggConfigResults: false, partialRows: true, - isHierarchical: vis.isHierarchical(), + metricsAtAllLevels: vis.isHierarchical(), }); const columns = table.columns.map((col, index) => { const field = col.aggConfig.getField(); @@ -46,7 +44,7 @@ const CourierRequestHandlerProvider = function () { col.aggConfig.isFilterable() && (!field || field.filterable); return ({ - name: col.title, + name: col.name, field: `col${index}`, filter: isCellContentFilterable && ((value) => { const filter = col.aggConfig.createFilter(value.raw); @@ -61,9 +59,10 @@ const CourierRequestHandlerProvider = function () { }); }); const rows = table.rows.map(row => { - return row.reduce((prev, cur, index) => { - const fieldFormatter = table.columns[index].aggConfig.fieldFormatter('text'); - prev[`col${index}`] = new FormattedData(cur, fieldFormatter(cur)); + return table.columns.reduce((prev, cur, index) => { + const value = row[cur.id]; + const fieldFormatter = cur.aggConfig.fieldFormatter('text'); + prev[`col${index}`] = new FormattedData(value, fieldFormatter(value)); return prev; }, {}); }); @@ -85,6 +84,8 @@ const CourierRequestHandlerProvider = function () { const timeFilterSearchSource = searchSource.createChild({ callParentStartHandlers: true }); const requestSearchSource = timeFilterSearchSource.createChild({ callParentStartHandlers: true }); + aggs.setTimeRange(timeRange); + // For now we need to mirror the history of the passed search source, since // the spy panel wouldn't work otherwise. Object.defineProperty(requestSearchSource, 'history', { @@ -123,7 +124,7 @@ const CourierRequestHandlerProvider = function () { return requestSearchSource.getSearchRequestBody().then(q => { const queryHash = calculateObjectHash(q); if (shouldQuery(queryHash)) { - const lastAggConfig = vis.getAggConfig(); + const lastAggConfig = aggs; vis.API.inspectorAdapters.requests.reset(); const request = vis.API.inspectorAdapters.requests.start('Data', { description: `This request queries Elasticsearch to fetch the data for the visualization.`, diff --git a/src/ui/public/vis/response_handlers/legacy.js b/src/ui/public/vis/response_handlers/legacy.js new file mode 100644 index 00000000000000..c8072efa4b4d21 --- /dev/null +++ b/src/ui/public/vis/response_handlers/legacy.js @@ -0,0 +1,110 @@ +/* + * 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 { tabifyAggResponse } from '../../agg_response/tabify'; +import AggConfigResult from '../../vis/agg_config_result'; +import { VisResponseHandlersRegistryProvider } from '../../registry/vis_response_handlers'; + +const LegacyResponseHandlerProvider = function () { + + return { + name: 'legacy', + handler: function (vis, response) { + return new Promise((resolve) => { + const converted = { tables: [] }; + const metricsAtAllLevels = vis.params.hasOwnProperty('showMetricsAtAllLevels') ? + vis.params.showMetricsAtAllLevels : vis.isHierarchical(); + + const table = tabifyAggResponse(vis.getAggConfig(), response, { + metricsAtAllLevels: metricsAtAllLevels, + partialRows: vis.params.showPartialRows, + }); + + const asAggConfigResults = _.get(vis, 'type.responseHandlerConfig.asAggConfigResults', false); + + const splitColumn = table.columns.find(column => column.aggConfig.schema.name === 'split'); + if (splitColumn) { + const splitAgg = splitColumn.aggConfig; + const splitMap = {}; + let splitIndex = 0; + + table.rows.forEach(row => { + const splitValue = row[splitColumn.id]; + const splitColumnIndex = table.columns.findIndex(column => column === splitColumn); + + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; + const tableGroup = { + $parent: converted, + aggConfig: splitAgg, + title: `${splitValue}: ${splitAgg.makeLabel()}`, + key: splitValue, + tables: [] + }; + tableGroup.tables.push({ + $parent: tableGroup, + columns: table.columns.filter((column, i) => i !== splitColumnIndex).map(column => ({ title: column.name, ...column })), + rows: [] + }); + + converted.tables.push(tableGroup); + } + + let previousSplitAgg = new AggConfigResult(splitAgg, null, splitValue, splitValue); + const tableIndex = splitMap[splitValue]; + const newRow = _.map(converted.tables[tableIndex].tables[0].columns, column => { + const value = row[column.id]; + const aggConfigResult = new AggConfigResult(column.aggConfig, previousSplitAgg, value, value); + if (column.aggConfig.type.type === 'buckets') { + previousSplitAgg = aggConfigResult; + } + return asAggConfigResults ? aggConfigResult : value; + }); + + converted.tables[tableIndex].tables[0].rows.push(newRow); + }); + } else { + + converted.tables.push({ + columns: table.columns.map(column => ({ title: column.name, ...column })), + rows: table.rows.map(row => { + let previousSplitAgg; + return table.columns.map(column => { + const value = row[column.id]; + const aggConfigResult = new AggConfigResult(column.aggConfig, previousSplitAgg, value, value); + if (column.aggConfig.type.type === 'buckets') { + previousSplitAgg = aggConfigResult; + } + return asAggConfigResults ? aggConfigResult : value; + }); + }), + aggConfig: (column) => column.aggConfig + }); + } + + resolve(converted); + }); + } + }; +}; + +VisResponseHandlersRegistryProvider.register(LegacyResponseHandlerProvider); + +export { LegacyResponseHandlerProvider }; diff --git a/src/ui/public/vis/response_handlers/tabify.js b/src/ui/public/vis/response_handlers/tabify.js index 1b3cc3a1c33465..ac45995b73c24c 100644 --- a/src/ui/public/vis/response_handlers/tabify.js +++ b/src/ui/public/vis/response_handlers/tabify.js @@ -17,9 +17,9 @@ * under the License. */ -import _ from 'lodash'; import { AggResponseIndexProvider } from '../../agg_response'; import { VisResponseHandlersRegistryProvider } from '../../registry/vis_response_handlers'; +import { getTime } from 'ui/timefilter/get_time'; const TabifyResponseHandlerProvider = function (Private) { const aggResponse = Private(AggResponseIndexProvider); @@ -28,11 +28,12 @@ const TabifyResponseHandlerProvider = function (Private) { name: 'tabify', handler: function (vis, response) { return new Promise((resolve) => { + const time = getTime(vis.indexPattern, vis.filters.timeRange); const tableGroup = aggResponse.tabify(vis.getAggConfig(), response, { - canSplit: true, - asAggConfigResults: _.get(vis, 'type.responseHandlerConfig.asAggConfigResults', false), - isHierarchical: vis.isHierarchical() + metricsAtAllLevels: vis.isHierarchical(), + partialRows: vis.params.showPartialRows, + timeRange: time ? time.range : undefined, }); resolve(tableGroup); diff --git a/src/ui/public/vis/response_handlers/basic.js b/src/ui/public/vis/response_handlers/vislib.js similarity index 68% rename from src/ui/public/vis/response_handlers/basic.js rename to src/ui/public/vis/response_handlers/vislib.js index d8ce9942a88983..d8359b7bdef6ae 100644 --- a/src/ui/public/vis/response_handlers/basic.js +++ b/src/ui/public/vis/response_handlers/vislib.js @@ -18,17 +18,18 @@ */ import { AggResponseIndexProvider } from '../../agg_response'; -import { TabifyTable } from '../../agg_response/tabify/_table'; - +import { LegacyResponseHandlerProvider } from './legacy'; import { VisResponseHandlersRegistryProvider } from '../../registry/vis_response_handlers'; -const BasicResponseHandlerProvider = function (Private) { +const VislibResponseHandlerProvider = function (Private) { const aggResponse = Private(AggResponseIndexProvider); + const tableResponseProvider = Private(LegacyResponseHandlerProvider).handler; function convertTableGroup(vis, tableGroup) { const tables = tableGroup.tables; const firstChild = tables[0]; - if (firstChild instanceof TabifyTable) { + + if (firstChild.columns) { const chart = convertTable(vis, firstChild); // if chart is within a split, assign group title to its label @@ -39,6 +40,7 @@ const BasicResponseHandlerProvider = function (Private) { } if (!tables.length) return; + const out = {}; let outList; @@ -59,39 +61,38 @@ const BasicResponseHandlerProvider = function (Private) { } function convertTable(vis, table) { - return vis.type.responseConverter ? vis.type.responseConverter(vis, table) : table; + return vis.type.responseConverter ? vis.type.responseConverter(table) : table; } return { - name: 'basic', + name: 'vislib', handler: function (vis, response) { return new Promise((resolve) => { if (vis.isHierarchical()) { // the hierarchical converter is very self-contained (woot!) + // todo: it should be updated to be based on tabified data just as other responseConverters resolve(aggResponse.hierarchical(vis, response)); } - const tableGroup = aggResponse.tabify(vis.getAggConfig(), response, { - canSplit: true, - asAggConfigResults: true, - isHierarchical: vis.isHierarchical() - }); + return tableResponseProvider(vis, response).then(tableGroup => { + let converted = convertTableGroup(vis, tableGroup); + if (!converted) { + // mimic a row of tables that doesn't have any tables + // https://github.com/elastic/kibana/blob/7bfb68cd24ed42b1b257682f93c50cd8d73e2520/src/kibana/components/vislib/components/zero_injection/inject_zeros.js#L32 + converted = { rows: [] }; + } - let converted = convertTableGroup(vis, tableGroup); - if (!converted) { - // mimic a row of tables that doesn't have any tables - // https://github.com/elastic/kibana/blob/7bfb68cd24ed42b1b257682f93c50cd8d73e2520/src/kibana/components/vislib/components/zero_injection/inject_zeros.js#L32 - converted = { rows: [] }; - } + converted.hits = response.hits.total; + + resolve(converted); + }); - converted.hits = response.hits.total; - resolve(converted); }); } }; }; -VisResponseHandlersRegistryProvider.register(BasicResponseHandlerProvider); +VisResponseHandlersRegistryProvider.register(VislibResponseHandlerProvider); -export { BasicResponseHandlerProvider }; +export { VislibResponseHandlerProvider }; diff --git a/src/ui/public/vis/vis.js b/src/ui/public/vis/vis.js index e00943d815a661..85d59bf4ccd57a 100644 --- a/src/ui/public/vis/vis.js +++ b/src/ui/public/vis/vis.js @@ -49,7 +49,11 @@ const getTerms = (table, columnIndex, rowIndex) => { } // get only rows where cell value matches current row for all the fields before columnIndex - const rows = table.rows.filter(row => row.every((cell, i) => cell === table.rows[rowIndex][i] || i >= columnIndex)); + const rows = table.rows.filter(row => { + return table.columns.every((column, i) => { + return row[column.id] === table.rows[rowIndex][column.id] || i >= columnIndex; + }); + }); const terms = rows.map(row => row[columnIndex]); return [...new Set(terms.filter(term => { @@ -99,17 +103,17 @@ export function VisProvider(Private, indexPatterns, getAppState) { filterBarClickHandler(appState)(event); }, addFilter: (data, columnIndex, rowIndex, cellValue) => { - const agg = data.columns[columnIndex].aggConfig; + const { aggConfig, id: columnId } = data.columns[columnIndex]; let filter = []; - const value = rowIndex > -1 ? data.rows[rowIndex][columnIndex] : cellValue; + const value = rowIndex > -1 ? data.rows[rowIndex][columnId] : cellValue; if (!value) { return; } - if (agg.type.name === 'terms' && agg.params.otherBucket) { + if (aggConfig.type.name === 'terms' && aggConfig.params.otherBucket) { const terms = getTerms(data, columnIndex, rowIndex); - filter = agg.createFilter(value, { terms }); + filter = aggConfig.createFilter(value, { terms }); } else { - filter = agg.createFilter(value); + filter = aggConfig.createFilter(value); } queryFilter.addFilters(filter); }, brush: (event) => { @@ -188,7 +192,7 @@ export function VisProvider(Private, indexPatterns, getAppState) { updateVisualizationConfig(state.params, this.params); - this.aggs = new AggConfigs(this, state.aggs); + this.aggs = new AggConfigs(this.indexPattern, state.aggs, this.type.schemas.all); } setState(state, updateCurrentState = true) { @@ -233,7 +237,7 @@ export function VisProvider(Private, indexPatterns, getAppState) { copyCurrentState(includeDisabled = false) { const state = this.getCurrentState(includeDisabled); - state.aggs = new AggConfigs(this, state.aggs); + state.aggs = new AggConfigs(this.indexPattern, state.aggs, this.type.schemas.all); return state; } @@ -252,7 +256,7 @@ export function VisProvider(Private, indexPatterns, getAppState) { } getAggConfig() { - return new AggConfigs(this, this.aggs.raw.filter(agg => agg.enabled)); + return this.aggs.clone({ enabledOnly: true }); } getState() { diff --git a/src/ui/public/vis/vis_types/vislib_vis_type.js b/src/ui/public/vis/vis_types/vislib_vis_type.js index 7d503a66d2d3c4..f691f511a2d7ee 100644 --- a/src/ui/public/vis/vis_types/vislib_vis_type.js +++ b/src/ui/public/vis/vis_types/vislib_vis_type.js @@ -108,7 +108,8 @@ export function VislibVisTypeProvider(Private, $rootScope, $timeout, $compile) { class VislibVisType extends BaseVisType { constructor(opts) { if (!opts.responseHandler) { - opts.responseHandler = 'basic'; + opts.responseHandler = 'vislib'; + opts.responseHandlerConfig = { asAggConfigResults: true }; } if (!opts.responseConverter) { opts.responseConverter = pointSeries; diff --git a/src/ui/public/vislib/lib/handler.js b/src/ui/public/vislib/lib/handler.js index c370d5189f5547..c07a8d7426e353 100644 --- a/src/ui/public/vislib/lib/handler.js +++ b/src/ui/public/vislib/lib/handler.js @@ -219,18 +219,7 @@ export function VisHandlerProvider(Private) { // to continuously call render on resize .attr('class', 'visualize-error chart error'); - if (message === 'No results found') { - div.append('div') - .attr('class', 'text-center visualize-error visualize-chart') - .append('div').attr('class', 'item top') - .append('div').attr('class', 'item') - .append('h2').html('') - .append('h4').text(message); - - div.append('div').attr('class', 'item bottom'); - } else { - div.append('h4').text(markdownIt.renderInline(message)); - } + div.append('h4').text(markdownIt.renderInline(message)); dispatchRenderComplete(this.el); return div; diff --git a/src/ui/public/vislib/visualizations/point_series/heatmap_chart.js b/src/ui/public/vislib/visualizations/point_series/heatmap_chart.js index 3f2391eb1e3516..b765ccb31838e7 100644 --- a/src/ui/public/vislib/visualizations/point_series/heatmap_chart.js +++ b/src/ui/public/vislib/visualizations/point_series/heatmap_chart.js @@ -69,6 +69,7 @@ export function VislibVisualizationsHeatmapChartProvider(Private) { const zScale = this.getValueAxis().getScale(); const [min, max] = zScale.domain(); const labels = []; + const maxColorCnt = 10; if (cfg.get('setColorRange')) { colorsRange.forEach(range => { const from = isFinite(range.from) ? zAxisFormatter(range.from) : range.from; @@ -90,8 +91,14 @@ export function VislibVisualizationsHeatmapChartProvider(Private) { } else { val = val * (max - min) + min; nextVal = nextVal * (max - min) + min; - if (max > 1) { - val = Math.ceil(val); + if (max - min > maxColorCnt) { + const valInt = Math.ceil(val); + if (i === 0) { + val = (valInt === val ? val : valInt - 1); + } + else{ + val = valInt; + } nextVal = Math.ceil(nextVal); } if (isFinite(val)) val = zAxisFormatter(val); @@ -184,12 +191,16 @@ export function VislibVisualizationsHeatmapChartProvider(Private) { val = Math.min(colorsNumber - 1, Math.floor(val * colorsNumber)); } } + if (d.y == null) { + return -1; + } return !isNaN(val) ? val : -1; } function label(d) { const colorBucket = getColorBucket(d); - if (colorBucket === -1) d.hide = true; + // colorBucket id should always GTE 0 + if (colorBucket < 0) d.hide = true; return labels[colorBucket]; } diff --git a/src/ui/public/visualize/components/visualization.test.js b/src/ui/public/visualize/components/visualization.test.js index 8c88e004c6fc69..1d768787ce1934 100644 --- a/src/ui/public/visualize/components/visualization.test.js +++ b/src/ui/public/visualize/components/visualization.test.js @@ -43,7 +43,7 @@ class VisualizationStub { describe('', () => { const visData = { - hits: { total: 1 } + hits: 1 }; const uiState = { @@ -63,19 +63,18 @@ describe('', () => { return this.uiState; }, params: { - }, type: { title: 'new vis', requiresSearch: true, - handleNoResults: true, + useCustomNoDataScreen: false, visualization: VisualizationStub } }; }); it('should display no result message when length of data is 0', () => { - const data = { hits: { total: 0 } }; + const data = { rows: [] }; const wrapper = render(); expect(wrapper.text()).toBe('No results found'); }); @@ -87,7 +86,7 @@ describe('', () => { it('should call onInit when rendering no data', () => { const spy = jest.fn(); - const noData = { hits: { total: 0 } }; + const noData = { hits: 0 }; mount( void; } export class VisualizationNoResults extends React.Component { + private containerDiv = React.createRef(); + public render() { return ( -
+