diff --git a/.eslintrc.js b/.eslintrc.js index 019f93d91b555..c821bfd5a2404 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -186,8 +186,10 @@ module.exports = { 'x-pack/legacy/plugins/apm/**/*.js', 'test/*/config.ts', 'test/visual_regression/tests/**/*', - 'x-pack/test/visual_regression/tests/**/*', - 'x-pack/test/*/config.ts', + 'x-pack/test/*/{tests,test_suites,apis,apps}/**/*', + 'x-pack/test/*/*config.*ts', + 'x-pack/test/saved_object_api_integration/*/apis/**/*', + 'x-pack/test/ui_capabilities/*/tests/**/*', ], rules: { 'import/no-default-export': 'off', @@ -396,6 +398,14 @@ module.exports = { 'no-console': ['warn', { allow: ['error'] }], }, }, + { + plugins: ['react-hooks'], + files: ['x-pack/legacy/plugins/apm/**/*.{ts,tsx}'], + rules: { + 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks + 'react-hooks/exhaustive-deps': ['error', { additionalHooks: '^useFetcher$' }], + }, + }, /** * GIS overrides diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 274d9a25ef534..3abc37a5f60b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -36,6 +36,7 @@ /src/core/ @elastic/kibana-platform /src/legacy/server/saved_objects/ @elastic/kibana-platform /src/legacy/ui/public/saved_objects @elastic/kibana-platform +/config/kibana.yml @elastic/kibana-platform # Security /x-pack/legacy/plugins/security/ @elastic/kibana-security @@ -57,6 +58,7 @@ # Elasticsearch UI /src/legacy/core_plugins/console/ @elastic/es-ui +/src/plugins/es_ui_shared/ @elastic/es-ui /x-pack/legacy/plugins/console_extensions/ @elastic/es-ui /x-pack/legacy/plugins/cross_cluster_replication/ @elastic/es-ui /x-pack/legacy/plugins/index_lifecycle_management/ @elastic/es-ui diff --git a/.i18nrc.json b/.i18nrc.json index bcc1fe70ab1e7..0424b9528a204 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -17,14 +17,15 @@ "visTypeMarkdown": "src/legacy/core_plugins/vis_type_markdown", "metricVis": "src/legacy/core_plugins/metric_vis", "visTypeVega": "src/legacy/core_plugins/vis_type_vega", - "tableVis": "src/legacy/core_plugins/table_vis", + "visTypeTable": "src/legacy/core_plugins/vis_type_table", "regionMap": "src/legacy/core_plugins/region_map", "statusPage": "src/legacy/core_plugins/status_page", "tileMap": "src/legacy/core_plugins/tile_map", "timelion": "src/legacy/core_plugins/timelion", "tagCloud": "src/legacy/core_plugins/tagcloud", "tsvb": "src/legacy/core_plugins/metrics", - "kbnESQuery": "packages/kbn-es-query" + "kbnESQuery": "packages/kbn-es-query", + "inspector": "src/plugins/inspector" }, "exclude": ["src/legacy/ui/ui_render/ui_render_mixin.js"], "translations": [] diff --git a/docs/canvas/canvas-getting-started.asciidoc b/docs/canvas/canvas-getting-started.asciidoc index c02c6b1f3e214..3874b91b85e92 100644 --- a/docs/canvas/canvas-getting-started.asciidoc +++ b/docs/canvas/canvas-getting-started.asciidoc @@ -7,7 +7,7 @@ To get up and running with Canvas, use the following tutorial where you'll creat [float] === Before you begin -For this tutorial, you'll need to add the {kibana-ref}/add-sample-data.html[Sample eCommerce orders data]. +For this tutorial, you'll need to add the <>. [float] === Create and personalize your workpad diff --git a/docs/canvas/canvas-workpad.asciidoc b/docs/canvas/canvas-workpad.asciidoc index 1b47beb01dc31..87df4ddb7537f 100644 --- a/docs/canvas/canvas-workpad.asciidoc +++ b/docs/canvas/canvas-workpad.asciidoc @@ -14,7 +14,7 @@ When you create a workpad, you'll start with a blank page, or you can choose a w * To import an existing workpad, click and drag a workpad JSON file to the *Import workpad JSON file* field. -For advanced workpad examples, add a {kibana-ref}/add-sample-data.html[sample Kibana data set], then select *Canvas* from the *View Data* dropdown list. +For advanced workpad examples, add a <>, then select *Canvas* from the *View Data* dropdown list. For more workpad inspiration, go to the link:https://www.elastic.co/blog/[Elastic Blog]. diff --git a/docs/code/code-basic-nav.asciidoc b/docs/code/code-basic-nav.asciidoc index 6b74790514255..77a56b24801cc 100644 --- a/docs/code/code-basic-nav.asciidoc +++ b/docs/code/code-basic-nav.asciidoc @@ -17,4 +17,9 @@ Clicking *Blame* shows the most recent commit per line. [role="screenshot"] image::images/code-blame.png[] +[float] +==== Branch selector +You can use the Branch selector to view different branches of a repo. Note that code intelligence and search index are not available for any branch other than master branch. + + include::code-semantic-nav.asciidoc[] diff --git a/docs/code/code-install-lang-server.asciidoc b/docs/code/code-install-lang-server.asciidoc index acc6c5a9347eb..1bb5a24f0c7f3 100644 --- a/docs/code/code-install-lang-server.asciidoc +++ b/docs/code/code-install-lang-server.asciidoc @@ -16,6 +16,10 @@ You can check the status of the language servers and get installation instructio [role="screenshot"] image::images/code-lang-server-status.png[] +[float] +=== Ctag language server +*Code* also uses a Ctag language server to generate symbol information and code intelligence when a dedicated language server is not available. The code intelligence information generated by the Ctag language server is less accurate but covers more languages. + include::code-basic-nav.asciidoc[] diff --git a/docs/code/code-repo-management.asciidoc b/docs/code/code-repo-management.asciidoc index 0424242f760ad..dde3c4dbc6fd1 100644 --- a/docs/code/code-repo-management.asciidoc +++ b/docs/code/code-repo-management.asciidoc @@ -15,12 +15,7 @@ Deleting a repo removes it from local storage and the Elasticsearch index. [float] ==== Reindex a repo -You can set *Code* to automatically reindex imported repos at set intervals by set the following config in `kibana.yaml`. - -[source,yaml] ----- -xpack.code.disableIndexScheduler: false ----- +*Code* automatically reindexes an imported repo at set intervals, but in some cases you might need to manually refresh the index. For example, you might refresh an index after a new language server is installed. Or, you might want to immediately update the index to the HEAD revision. Click *Reindex* to initiate a reindex. In some cases you might need to manually refresh the index besides automatic indexing. For example, you might refresh an index after a new language server is installed. Or, you might want to immediately update the index to the HEAD revision. Click *Reindex* to initiate a reindex. diff --git a/docs/dashboard.asciidoc b/docs/dashboard.asciidoc index 48189465bc3e7..df5348a0673dc 100644 --- a/docs/dashboard.asciidoc +++ b/docs/dashboard.asciidoc @@ -3,66 +3,84 @@ [partintro] -- -A Kibana _dashboard_ displays a collection of visualizations, searches, and {kibana-ref}/maps.html[maps]. -You can arrange, resize, and edit the dashboard content and then save the dashboard -so you can share it. -[role="screenshot"] -image:images/Dashboard_example.png[Example dashboard] +A {kib} _dashboard_ is a collection of visualizations, searches, and +maps, typically in real-time. Dashboards provide +at-a-glance insights into your data and enable you to drill down into details. --- +To start working with dashboards, click *Dashboard* in the side navigation. +With *Dashboard*, you can: -[[dashboard-getting-started]] -== Building a Dashboard +* <> +* <> +* <> +* <> +* <> + + +[role="screenshot"] +image:images/Dashboard_example.png[Example dashboard] -If you haven't yet indexed data into {es} or created an index pattern, -you'll be prompted to do so as you follow the steps for creating a dashboard. -Or, you can use one of the prebuilt sample data sets, available from the -Kibana home page. [float] [[dashboard-read-only-access]] === [xpack]#Read only access# -When you have insufficient privileges to create or save dashboards, the following -indicator in Kibana will be displayed. The buttons to create new dashboards or edit -existing dashboard won't be visible. For more information on granting access to -Kibana see <>. +If you see +the read-only icon in the application header, +then you don't have sufficient privileges to create and save dashboards. The buttons to create and edit +dashboards are not visible. For more information, see <>. [role="screenshot"] image::images/dashboard-read-only-badge.png[Example of Dashboard's read only access indicator in Kibana's header] [float] +[[dashboard-getting-started]] +=== Interact with dashboards + +When you open *Dashhboard*, you're presented an overview of your dashboards. +If you don't have any dashboards, you can add +<>, +which include pre-built dashboards. + +Once you open a dashboard, you can filter the data +by entering a search query, changing the time filter, or clicking +in the visualizations, searches, and maps. If a dashboard element has a stored query, +both queries are applied. + +-- + [[dashboard-create-new-dashboard]] -=== Creating a new Dashboard +== Create a dashboard + +To create a dashboard, you must have data indexed into {es}, an index pattern +to retrieve the data from {es}, and +visualizations, saved searches, or maps. If these don't exist, you're prompted to +add them as you create the dashboard. -. In the side navigation, click *Dashboard*. +For an end-to-end example, see <>. + +. Open *Dashboard.* . Click *Create new dashboard.* . Click *Add*. -. [[adding-visualizations-to-a-dashboard]]Use *Add Panels* to add visualizations -and saved searches to the dashboard. If you have a large number of -visualizations, you can filter the lists. +. Use *Add panels* to add elements to the dashboard. ++ +The visualizations, saved searches, and maps +are stored in panels that you can move and resize. A +menu in the upper right of the panel has options for customizing +the panel. You can add elements from +multiple indices, and the same element can appear in multiple dashboards. + [role="screenshot"] image:images/Dashboard_add_visualization.png[Example add visualization to dashboard] -. [[saving-dashboards]]When you're finished adding and arranging the panels, -go to the menu bar and click *Save*. - -. In *Save Dashboard*, enter a dashboard title and optionally a description. - -. To store the time period specified in the time filter, enable *Store time -with dashboard*. - -. Click *Save*. - -[[loading-a-saved-dashboard]] -To import, export, and delete dashboards, see <>. +. When you're finished adding and arranging the panels, +*Save* the dashboard. +[float] [[customizing-your-dashboard]] -== Arranging Dashboard Elements +=== Arrange dashboard elements -The visualizations and searches in a dashboard are stored in panels that you can move, -resize, edit, and delete. To start editing, click *Edit* in the menu bar. +In *Edit* mode, you can move, resize, customize, and delete panels to suit your needs. [[moving-containers]] * To move a panel, click and hold the panel header and drag to the new location. @@ -71,53 +89,58 @@ resize, edit, and delete. To start editing, click *Edit* in the menu bar. * To resize a panel, click the resize control on the lower right and drag to the new dimensions. -[[removing-containers]] -Additional commands for managing the panel and its contents -are in the gear menu in the upper right. - -[role="screenshot"] -image:images/Dashboard_Resize_Menu.png[Example dashboard] +* To toggle the use of margins and panel titles, use the *Options* menu in the upper left. -NOTE: Deleting a panel from a +* To delete a panel, open the panel menu and select *Delete from dashboard.* Deleting a panel from a dashboard does *not* delete the saved visualization or search. -[[viewing-detailed-information]] -== Inspecting a Visualization from the Dashboard -Many visualizations allow you to inspect the data and requests behind the -visualization. +[float] +[[sharing-dashboards]] +=== Share a dashboard + +[[embedding-dashboards]] +When you've finished your dashboard, you can share it with your teammates. +From the *Share* menu, you can: + +* Embed the code in a web page. Users must have Kibana access +to view an embedded dashboard. +* Share a direct link to a {kib} dashboard +* Generate a PDF report +* Generate a PNG report + +TIP: You can create a link to a dashboard by title by doing this: + +`${domain}/${basepath?}/app/kibana#/dashboards?title=${yourdashboardtitle}` + +TIP: When sharing a link to a dashboard snapshot, use the *Short URL*. Snapshot +URLs are long and can be problematic for Internet Explorer and other +tools. To create a short URL, you must have write access to {kib}. + +[float] +[[import-dashboards]] +=== Import and export dashboards -In the dashboard, expand the visualization's panel menu (or gear menu if in -*Edit* mode) and select *Inspect*. +To import and export dashboards, go to *Management > Saved Objects*. For details, +see <>. -The initial view shows the underlying data for the visualization. To view the -requests that were made for the visualization, choose *Requests* from the *View* -menu. +[float] +[[viewing-detailed-information]] +=== Inspect and edit elements -The views you'll see depend on the element that you inspect. +Many dashboard elements allow you to drill down into the data and requests +behind the element. Open the menu in the upper right of the panel and select *Inspect*. +The views you see depend on the element that you inspect. [role="screenshot"] -image:images/Dashboard_visualization_data.png[Example of visualization data] +image:images/Dashboard_inspect.png[Inspect in dashboard] +To open an element for editing, put the dashboard in *Edit* mode, +and then select *Edit* from the panel menu. The changes you make appear in +every dashboard that uses the element. -[[sharing-dashboards]] -== Sharing a Dashboard -You can either share a direct link to a Kibana dashboard, -or embed the dashboard in a web page. Users must have Kibana access -to view an embedded dashboard. -[[embedding-dashboards]] -. Open the dashboard you want to share. -. In the menu bar, click *Share*. -. Copy the link you want to share or the iframe you want to embed. You can -share the live dashboard or a static snapshot of the current point in time. -TIP: You can create a link to a dashboard by title by doing this: + -`${domain}/${basepath?}/app/kibana#/dashboards?title=${yourdashboardtitle}` -TIP: When sharing a link to a dashboard snapshot, use the *Short URL*. Snapshot -URLs are long and can be problematic for Internet Explorer and other -tools. To create a short URL, you must have write access to {kib}. diff --git a/docs/development/core/development-basepath.asciidoc b/docs/development/core/development-basepath.asciidoc index e84171c79cb22..d49dfe2938fad 100644 --- a/docs/development/core/development-basepath.asciidoc +++ b/docs/development/core/development-basepath.asciidoc @@ -66,6 +66,15 @@ To accomplish this the `serve` task does a few things: - redirects from `/{any}/app/{appName}` to `/{randomBasePath}/app/{appName}` so that refreshes should work - proxies all requests starting with `/{randomBasePath}/` to the Kibana server +If you're writing scripts that interact with the Kibana API, the base path proxy will likely +make this difficult. To bypass the base path proxy for a single request, prefix urls with +`__UNSAFE_bypassBasePath` and the request will be routed to the development Kibana server. + +["source","shell"] +----------- +curl "http://elastic:changeme@localhost:5601/__UNSAFE_bypassBasePath/api/status" +----------- + This proxy can sometimes have unintended side effects in development, so when needed you can opt out by passing the `--no-base-path` flag to the `serve` task or `yarn start`. diff --git a/docs/development/core/development-functional-tests.asciidoc b/docs/development/core/development-functional-tests.asciidoc index ec65e2519e530..6d2c5a72f0532 100644 --- a/docs/development/core/development-functional-tests.asciidoc +++ b/docs/development/core/development-functional-tests.asciidoc @@ -61,14 +61,29 @@ export TEST_ES_PASS= node scripts/functional_test_runner ---------- -** Selenium tests can be run in headless mode by setting the environment variable below. Unset this variable to show the browser. +** If you are running x-pack functional tests, start server and runner from {blob}xpack[x-pack] folder: ++ +["source", "shell"] +---------- +node scripts/functional_tests_server.js +node ../scripts/functional_test_runner.js +---------- + +** Selenium tests are run in headless mode on CI. Locally the same tests will be executed in a real browser. You can activate headless mode by setting the environment variable below: + ["source", "shell"] ---------- export TEST_BROWSER_HEADLESS=1 ---------- -** When running against cloud deployment, some tests are not applicable use --exclude-tag to skip those tests. An example shell file can be found at: {blob}test/scripts/jenkins_kibana.sh[test/scripts/jenkins_kibana.sh] +** If you are using Google Chrome, you can slow down the local network connection to verify test stability: ++ +["source", "shell"] +---------- +export TEST_THROTTLE_NETWORK=1 +---------- + +** When running against a Cloud deployment, some tests are not applicable. To skip tests that do not apply, use --exclude-tag. An example shell file can be found at: {blob}test/scripts/jenkins_cloud.sh[test/scripts/jenkins_cloud.sh] + ["source", "shell"] ---------- @@ -80,7 +95,8 @@ node scripts/functional_test_runner --exclude-tag skipCloud When run without any arguments the `FunctionalTestRunner` automatically loads the configuration in the standard location, but you can override that behavior with the `--config` flag. List configs with multiple --config arguments. -* `--config test/functional/config.js` starts Elasticsearch and Kibana servers with the selenium tests configuration. +* `--config test/functional/config.js` starts Elasticsearch and Kibana servers with the WebDriver tests configured to run in Chrome. +* `--config test/functional/config.firefox.js` starts Elasticsearch and Kibana servers with the WebDriver tests configured to run in Firefox. * `--config test/api_integration/config.js` starts Elasticsearch and Kibana servers with the api integration tests configuration. There are also command line flags for `--bail` and `--grep`, which behave just like their mocha counterparts. For instance, use `--grep=foo` to run only tests that match a regular expression. @@ -98,7 +114,7 @@ Use the `--help` flag for more options. The tests are written in https://mochajs.org[mocha] using https://github.com/elastic/kibana/tree/master/packages/kbn-expect[@kbn/expect] for assertions. -We use https://sites.google.com/a/chromium.org/chromedriver/[chromedriver], https://theintern.github.io/leadfoot[leadfoot], and https://github.com/theintern/digdug[digdug] for automating Chrome. When the `FunctionalTestRunner` launches, digdug opens a `Tunnel` which starts chromedriver and a stripped-down instance of Chrome. It also creates an instance of https://theintern.github.io/leadfoot/module-leadfoot_Command.html[Leadfoot's `Command`] class, which is available via the `remote` service. The `remote` communicates to Chrome through the digdug `Tunnel`. See the https://theintern.github.io/leadfoot/module-leadfoot_Command.html[leadfoot/Command API] docs for all the commands you can use with `remote`. +We use https://www.w3.org/TR/webdriver1/[WebDriver Protocol] to run tests in both Chrome and Firefox with the help of https://sites.google.com/a/chromium.org/chromedriver/[chromedriver] and https://firefox-source-docs.mozilla.org/testing/geckodriver/[geckodriver]. When the `FunctionalTestRunner` launches, remote service creates a new webdriver session, which starts the driver and a stripped-down browser instance. We use `browser` service and `webElementWrapper` class to wrap up https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/[Webdriver API]. The `FunctionalTestRunner` automatically transpiles functional tests using babel, so that tests can use the same ECMAScript features that Kibana source code uses. See {blob}style_guides/js_style_guide.md[style_guides/js_style_guide.md]. @@ -133,6 +149,34 @@ The `FunctionalTestRunner`'s primary purpose is to execute test files. These fil **Test Suite**::: A test suite is a collection of tests defined by calling `describe()`, and then populated with tests and setup/teardown hooks by calling `it()`, `before()`, `beforeEach()`, etc. Every test file must define only one top level test suite, and test suites can have as many nested test suites as they like. +**Tags**::: +Use tags in `describe()` function to group functional tests. Tags include: +* `ciGroup{id}` - Assigns test suite to a specific CI worker +* `skipCloud` and `skipFirefox` - Excludes test suite from running on Cloud or Firefox +* `smoke` - Groups tests that run on Chrome and Firefox + +**Cross-browser testing**::: +On CI, all the functional tests are executed in Chrome by default. To also run a suite against Firefox, assign the `smoke` tag: + +["source","js"] +----------- +// on CI test suite will be run twice: in Chrome and Firefox +describe('My Cross-browser Test Suite', function () { + this.tags('smoke'); + + it('My First Test'); +} +----------- + +If the tests do not apply to Firefox, assign the `skipFirefox` tag. + +To run tests on Firefox locally, use `config.firefox.js`: + +["source","shell"] +----------- +node scripts/functional_test_runner --config test/functional/config.firefox.js +----------- + [float] ===== Anatomy of a test file @@ -201,7 +245,7 @@ The first and only argument to all providers is a Provider API Object. This obje Within config files the API has the following properties [horizontal] -`log`::: An instance of the {blob}src/utils/tooling_log/tooling_log.js[`ToolingLog`] that is ready for use +`log`::: An instance of the {blob}packages/kbn-dev-utils/src/tooling_log/tooling_log.js[`ToolingLog`] that is ready for use `readConfigFile(path)`::: Returns a promise that will resolve to a Config instance that provides the values from the config file at `path` Within service and PageObject Providers the API is: @@ -224,17 +268,17 @@ Within a test Provider the API is exactly the same as the service providers API The `FunctionalTestRunner` comes with three built-in services: **config:**::: -* Source: {blob}src/functional_test_runner/lib/config/config.js[src/functional_test_runner/lib/config/config.js] -* Schema: {blob}src/functional_test_runner/lib/config/schema.js[src/functional_test_runner/lib/config/schema.js] +* Source: {blob}src/functional_test_runner/lib/config/config.ts[src/functional_test_runner/lib/config/config.ts] +* Schema: {blob}src/functional_test_runner/lib/config/schema.ts[src/functional_test_runner/lib/config/schema.ts] * Use `config.get(path)` to read any value from the config file **log:**::: -* Source: {blob}src/utils/tooling_log/tooling_log.js[src/utils/tooling_log/tooling_log.js] +* Source: {blob}packages/kbn-dev-utils/src/tooling_log/tooling_log.js[packages/kbn-dev-utils/src/tooling_log/tooling_log.js] * `ToolingLog` instances are readable streams. The instance provided by this service is automatically piped to stdout by the `FunctionalTestRunner` CLI * `log.verbose()`, `log.debug()`, `log.info()`, `log.warning()` all work just like console.log but produce more organized output **lifecycle:**::: -* Source: {blob}src/functional_test_runner/lib/lifecycle.js[src/functional_test_runner/lib/lifecycle.js] +* Source: {blob}src/functional_test_runner/lib/lifecycle.ts[src/functional_test_runner/lib/lifecycle.ts] * Designed primary for use in services * Exposes lifecycle events for basic coordination. Handlers can return a promise and resolve/fail asynchronously * Phases include: `beforeLoadTests`, `beforeTests`, `beforeEachTest`, `cleanup`, `phaseStart`, `phaseEnd` @@ -244,15 +288,15 @@ The `FunctionalTestRunner` comes with three built-in services: The Kibana functional tests define the vast majority of the actual functionality used by tests. -**retry:**::: -* Source: {blob}test/functional/services/retry.js[test/functional/services/retry.js] -* Helpers for retrying operations +**browser**::: +* Source: {blob}test/functional/services/browser.ts[test/functional/services/browser.ts] +* Higher level wrapper for `remote` service, which exposes available browser actions * Popular methods: -** `retry.try(fn)` - execute `fn` in a loop until it succeeds or the default try timeout elapses -** `retry.tryForTime(ms, fn)` execute fn in a loop until it succeeds or `ms` milliseconds elapses +** `browser.getWindowSize()` +** `browser.refresh()` **testSubjects:**::: -* Source: {blob}test/functional/services/test_subjects.js[test/functional/services/test_subjects.js] +* Source: {blob}test/functional/services/test_subjects.ts[test/functional/services/test_subjects.ts] * Test subjects are elements that are tagged specifically for selecting from tests * Use `testSubjects` over CSS selectors when possible * Usage: @@ -277,14 +321,21 @@ await testSubjects.click(‘containerButton’); ** `testSubjects.click(testSubjectSelector)` - Click a test subject in the page; throw if it can't be found after some time **find:**::: -* Source: {blob}test/functional/services/find.js[test/functional/services/find.js] +* Source: {blob}test/functional/services/find.ts[test/functional/services/find.ts] * Helpers for `remote.findBy*` methods that log and manage timeouts * Popular methods: ** `find.byCssSelector()` ** `find.allByCssSelector()` +**retry:**::: +* Source: {blob}test/common/services/retry/retry.ts[test/common/services/retry/retry.ts] +* Helpers for retrying operations +* Popular methods: +** `retry.try(fn, onFailureBlock)` - Execute `fn` in a loop until it succeeds or the default timeout elapses. The optional `onFailureBlock` is executed before each retry attempt. +** `retry.tryForTime(ms, fn, onFailureBlock)` - Execute `fn` in a loop until it succeeds or `ms` milliseconds elapses. The optional `onFailureBlock` is executed before each retry attempt. + **kibanaServer:**::: -* Source: {blob}test/functional/services/kibana_server/kibana_server.js[test/functional/services/kibana_server/kibana_server.js] +* Source: {blob}test/common/services/kibana_server/kibana_server.js[test/common/services/kibana_server/kibana_server.js] * Helpers for interacting with Kibana's server * Commonly used methods: ** `kibanaServer.uiSettings.update()` @@ -292,32 +343,28 @@ await testSubjects.click(‘containerButton’); ** `kibanaServer.status.getOverallState()` **esArchiver:**::: -* Source: {blob}test/functional/services/es_archiver.js[test/functional/services/es_archiver.js] +* Source: {blob}test/common/services/es_archiver.ts[test/common/services/es_archiver.ts] * Load/unload archives created with the `esArchiver` * Popular methods: ** `esArchiver.load(name)` ** `esArchiver.loadIfNeeded(name)` ** `esArchiver.unload(name)` -**docTable:**::: -* Source: {blob}test/functional/services/doc_table.js[test/functional/services/doc_table.js] -* Helpers for interacting with doc tables +Full list of services that are used in functional tests can be found here: {blob}test/functional/services[test/functional/services] -**pointSeriesVis:**::: -* Source: {blob}test/functional/services/point_series_vis.js[test/functional/services/point_series_vis.js] -* Helpers for interacting with point series visualizations **Low-level utilities:**::: * es -** Source: {blob}test/functional/services/es.js[test/functional/services/es.js] +** Source: {blob}test/common/services/es.ts[test/common/services/es.ts] ** Elasticsearch client ** Higher level options: `kibanaServer.uiSettings` or `esArchiver` * remote -** Source: {blob}test/functional/services/remote/remote.js[test/functional/services/remote/remote.js] -** Instance of https://theintern.github.io/leadfoot/module-leadfoot_Command.html[Leadfoot's `Command]` class +** Source: {blob}test/functional/services/remote/remote.ts[test/functional/services/remote/remote.ts] +** Instance of https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html[WebDriver] class ** Responsible for all communication with the browser -** Higher level options: `testSubjects`, `find`, and `PageObjects.common` -** See the https://theintern.github.io/leadfoot/module-leadfoot_Command.html[leadfoot/Command API] for full API +** To perform browser actions, use `remote` service +** For searching and manipulating with DOM elements, use `testSubjects` and `find` services +** See the https://seleniumhq.github.io/selenium/docs/api/javascript/[selenium-webdriver docs] for the full API. [float] ===== Custom Services diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md b/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md index bca2a7046d7c9..8bbd1dfcd31fa 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.availableapps.md @@ -4,8 +4,10 @@ ## ApplicationStart.availableApps property +Apps available based on the current capabilities. Should be used to show navigation links and make routing decisions. + Signature: ```typescript -availableApps: CapabilitiesStart['availableApps']; +availableApps: readonly App[]; ``` diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.capabilities.md b/docs/development/core/public/kibana-plugin-public.applicationstart.capabilities.md index 9ef82592f8754..14326197ea549 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.capabilities.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.capabilities.md @@ -4,8 +4,10 @@ ## ApplicationStart.capabilities property +Gets the read-only capabilities. + Signature: ```typescript -capabilities: CapabilitiesStart['capabilities']; +capabilities: RecursiveReadonly; ``` diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.md b/docs/development/core/public/kibana-plugin-public.applicationstart.md index 820d75cbd0e18..5854a7c65714e 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-public.applicationstart.md @@ -4,6 +4,7 @@ ## ApplicationStart interface + Signature: ```typescript @@ -14,7 +15,6 @@ export interface ApplicationStart | Property | Type | Description | | --- | --- | --- | -| [availableApps](./kibana-plugin-public.applicationstart.availableapps.md) | CapabilitiesStart['availableApps'] | | -| [capabilities](./kibana-plugin-public.applicationstart.capabilities.md) | CapabilitiesStart['capabilities'] | | -| [mount](./kibana-plugin-public.applicationstart.mount.md) | (mountHandler: Function) => void | | +| [availableApps](./kibana-plugin-public.applicationstart.availableapps.md) | readonly App[] | Apps available based on the current capabilities. Should be used to show navigation links and make routing decisions. | +| [capabilities](./kibana-plugin-public.applicationstart.capabilities.md) | RecursiveReadonly<Capabilities> | Gets the read-only capabilities. | diff --git a/docs/development/core/public/kibana-plugin-public.applicationstart.mount.md b/docs/development/core/public/kibana-plugin-public.applicationstart.mount.md deleted file mode 100644 index c6fd7872348bc..0000000000000 --- a/docs/development/core/public/kibana-plugin-public.applicationstart.mount.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationStart](./kibana-plugin-public.applicationstart.md) > [mount](./kibana-plugin-public.applicationstart.mount.md) - -## ApplicationStart.mount property - -Signature: - -```typescript -mount: (mountHandler: Function) => void; -``` diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.active.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.active.md index e1546488ba425..9cb278916dc4a 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.active.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.active.md @@ -4,9 +4,11 @@ ## ChromeNavLink.active property -Indicates whether or not this app is currently on the screen. +> Warning: This API is now obsolete. +> +> -NOTE: remove this when ApplicationService is implemented and managing apps. +Indicates whether or not this app is currently on the screen. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.disabled.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.disabled.md index 8dacb95e3aa18..d2b30530dd551 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.disabled.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.disabled.md @@ -4,9 +4,11 @@ ## ChromeNavLink.disabled property -Disables a link from being clickable. +> Warning: This API is now obsolete. +> +> -NOTE: this is only used by the ML and Graph plugins currently. They use this field to disable the nav link when the license is expired. +Disables a link from being clickable. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.hidden.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.hidden.md index 431ef0e6b8774..6d04ab6d78851 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.hidden.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.hidden.md @@ -6,8 +6,6 @@ Hides a link from the navigation. -NOTE: remove this when ApplicationService is implemented. Instead, plugins should only register an Application if needed. - Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.linktolastsuburl.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.linktolastsuburl.md index fa7020ae52bb9..7d76f4dc62be4 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.linktolastsuburl.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.linktolastsuburl.md @@ -4,9 +4,11 @@ ## ChromeNavLink.linkToLastSubUrl property -Whether or not the subUrl feature should be enabled. +> Warning: This API is now obsolete. +> +> -NOTE: only read by legacy platform. +Whether or not the subUrl feature should be enabled. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.md index b7696aec74be3..93ebbe3653ac4 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.md @@ -15,17 +15,17 @@ export interface ChromeNavLink | Property | Type | Description | | --- | --- | --- | -| [active](./kibana-plugin-public.chromenavlink.active.md) | boolean | Indicates whether or not this app is currently on the screen.NOTE: remove this when ApplicationService is implemented and managing apps. | +| [active](./kibana-plugin-public.chromenavlink.active.md) | boolean | Indicates whether or not this app is currently on the screen. | | [baseUrl](./kibana-plugin-public.chromenavlink.baseurl.md) | string | The base route used to open the root of an application. | -| [disabled](./kibana-plugin-public.chromenavlink.disabled.md) | boolean | Disables a link from being clickable.NOTE: this is only used by the ML and Graph plugins currently. They use this field to disable the nav link when the license is expired. | +| [disabled](./kibana-plugin-public.chromenavlink.disabled.md) | boolean | Disables a link from being clickable. | | [euiIconType](./kibana-plugin-public.chromenavlink.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | -| [hidden](./kibana-plugin-public.chromenavlink.hidden.md) | boolean | Hides a link from the navigation.NOTE: remove this when ApplicationService is implemented. Instead, plugins should only register an Application if needed. | +| [hidden](./kibana-plugin-public.chromenavlink.hidden.md) | boolean | Hides a link from the navigation. | | [icon](./kibana-plugin-public.chromenavlink.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | | [id](./kibana-plugin-public.chromenavlink.id.md) | string | A unique identifier for looking up links. | -| [linkToLastSubUrl](./kibana-plugin-public.chromenavlink.linktolastsuburl.md) | boolean | Whether or not the subUrl feature should be enabled.NOTE: only read by legacy platform. | +| [linkToLastSubUrl](./kibana-plugin-public.chromenavlink.linktolastsuburl.md) | boolean | Whether or not the subUrl feature should be enabled. | | [order](./kibana-plugin-public.chromenavlink.order.md) | number | An ordinal used to sort nav links relative to one another for display. | -| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | string | A url base that legacy apps can set to match deep URLs to an applcation.NOTE: this should be removed once legacy apps are gone. | +| [subUrlBase](./kibana-plugin-public.chromenavlink.suburlbase.md) | string | A url base that legacy apps can set to match deep URLs to an applcation. | | [title](./kibana-plugin-public.chromenavlink.title.md) | string | The title of the application. | | [tooltip](./kibana-plugin-public.chromenavlink.tooltip.md) | string | A tooltip shown when hovering over an app link. | -| [url](./kibana-plugin-public.chromenavlink.url.md) | string | A url that legacy apps can set to deep link into their applications.NOTE: Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should be removed once the ApplicationService is implemented and mounting apps. At that time, each app can handle opening to the previous location when they are mounted. | +| [url](./kibana-plugin-public.chromenavlink.url.md) | string | A url that legacy apps can set to deep link into their applications. | diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md index b3957f22611f4..b9d12432a01df 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.suburlbase.md @@ -4,9 +4,11 @@ ## ChromeNavLink.subUrlBase property -A url base that legacy apps can set to match deep URLs to an applcation. +> Warning: This API is now obsolete. +> +> -NOTE: this should be removed once legacy apps are gone. +A url base that legacy apps can set to match deep URLs to an applcation. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.chromenavlink.url.md b/docs/development/core/public/kibana-plugin-public.chromenavlink.url.md index ce9f502fd5d39..33bd8fa3411d4 100644 --- a/docs/development/core/public/kibana-plugin-public.chromenavlink.url.md +++ b/docs/development/core/public/kibana-plugin-public.chromenavlink.url.md @@ -4,9 +4,11 @@ ## ChromeNavLink.url property -A url that legacy apps can set to deep link into their applications. +> Warning: This API is now obsolete. +> +> -NOTE: Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should be removed once the ApplicationService is implemented and mounting apps. At that time, each app can handle opening to the previous location when they are mounted. +A url that legacy apps can set to deep link into their applications. Signature: diff --git a/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md b/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md new file mode 100644 index 0000000000000..2644596354e38 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextsetup.createcontextcontainer.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextSetup](./kibana-plugin-public.contextsetup.md) > [createContextContainer](./kibana-plugin-public.contextsetup.createcontextcontainer.md) + +## ContextSetup.createContextContainer() method + +Creates a new [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for a service owner. + +Signature: + +```typescript +createContextContainer(): IContextContainer; +``` +Returns: + +`IContextContainer` + diff --git a/docs/development/core/public/kibana-plugin-public.contextsetup.md b/docs/development/core/public/kibana-plugin-public.contextsetup.md new file mode 100644 index 0000000000000..43b4042d04ed6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.contextsetup.md @@ -0,0 +1,137 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ContextSetup](./kibana-plugin-public.contextsetup.md) + +## ContextSetup interface + +An object that handles registration of context providers and configuring handlers with context. + +Signature: + +```typescript +export interface ContextSetup +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createContextContainer()](./kibana-plugin-public.contextsetup.createcontextcontainer.md) | Creates a new [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for a service owner. | + +## Remarks + +A [IContextContainer](./kibana-plugin-public.icontextcontainer.md) can be used by any Core service or plugin (known as the "service owner") which wishes to expose APIs in a handler function. The container object will manage registering context providers and configuring a handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on the dependencies that the handler's plugin declares. + +Contexts providers are executed in the order they were registered. Each provider gets access to context values provided by any plugins that it depends on. + +In order to configure a handler with context, you must call the [IContextContainer.createHandler()](./kibana-plugin-public.icontextcontainer.createhandler.md) function and use the returned handler which will automatically build a context object when called. + +When registering context or creating handlers, the \_calling plugin's opaque id\_ must be provided. This id is passed in via the plugin's initializer and can be accessed from the [PluginInitializerContext.opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) Note this should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + +```ts +// Correct +class MyPlugin { + private readonly handlers = new Map(); + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(pluginOpaqueId, contextName, provider) { + this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + }, + registerRoute(pluginOpaqueId, path, handler) { + this.handlers.set( + path, + this.contextContainer.createHandler(pluginOpaqueId, handler) + ); + } + } + } +} + +// Incorrect +class MyPlugin { + private readonly handlers = new Map(); + + constructor(private readonly initContext: PluginInitializerContext) {} + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(contextName, provider) { + // BUG! + // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + }, + registerRoute(path, handler) { + this.handlers.set( + path, + // BUG! + // This handler will not receive any contexts provided by other dependencies of the calling plugin. + this.contextContainer.createHandler(this.initContext.opaqueId, handler) + ); + } + } + } +} + +``` + +## Example + +Say we're creating a plugin for rendering visualizations that allows new rendering methods to be registered. If we want to offer context to these rendering methods, we can leverage the ContextService to manage these contexts. + +```ts +export interface VizRenderContext { + core: { + i18n: I18nStart; + uiSettings: UISettingsClientContract; + } + [contextName: string]: unknown; +} + +export type VizRenderer = (context: VizRenderContext, domElement: HTMLElement) => () => void; + +class VizRenderingPlugin { + private readonly vizRenderers = new Map () => void)>(); + + setup(core) { + this.contextContainer = core.createContextContainer< + VizRenderContext, + ReturnType, + [HTMLElement] + >(); + + return { + registerContext: this.contextContainer.registerContext, + registerVizRenderer: (plugin: PluginOpaqueId, renderMethod: string, renderer: VizTypeRenderer) => + this.vizRenderers.set(renderMethod, this.contextContainer.createHandler(plugin, renderer)), + }; + } + + start(core) { + // Register the core context available to all renderers. Use the VizRendererContext's pluginId as the first arg. + this.contextContainer.registerContext('viz_rendering', 'core', () => ({ + i18n: core.i18n, + uiSettings: core.uiSettings + })); + + return { + registerContext: this.contextContainer.registerContext, + + renderVizualization: (renderMethod: string, domElement: HTMLElement) => { + if (!this.vizRenderer.has(renderMethod)) { + throw new Error(`Render method '${renderMethod}' has not been registered`); + } + + // The handler can now be called directly with only an `HTMLElement` and will automatically + // have a new `context` object created and populated by the context container. + const handler = this.vizRenderers.get(renderMethod) + return handler(domElement); + } + }; + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.context.md b/docs/development/core/public/kibana-plugin-public.coresetup.context.md new file mode 100644 index 0000000000000..e56ecb92074c4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.coresetup.context.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [CoreSetup](./kibana-plugin-public.coresetup.md) > [context](./kibana-plugin-public.coresetup.context.md) + +## CoreSetup.context property + +[ContextSetup](./kibana-plugin-public.contextsetup.md) + +Signature: + +```typescript +context: ContextSetup; +``` diff --git a/docs/development/core/public/kibana-plugin-public.coresetup.md b/docs/development/core/public/kibana-plugin-public.coresetup.md index 5bbd54a2561a3..a4b5b88df36dc 100644 --- a/docs/development/core/public/kibana-plugin-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-public.coresetup.md @@ -16,6 +16,7 @@ export interface CoreSetup | Property | Type | Description | | --- | --- | --- | +| [context](./kibana-plugin-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | | [http](./kibana-plugin-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-public.httpsetup.md) | | [notifications](./kibana-plugin-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | diff --git a/docs/development/core/public/kibana-plugin-public.httpbody.md b/docs/development/core/public/kibana-plugin-public.httpbody.md new file mode 100644 index 0000000000000..ab31f28b8dc38 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpbody.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpBody](./kibana-plugin-public.httpbody.md) + +## HttpBody type + + +Signature: + +```typescript +export declare type HttpBody = BodyInit | null | any; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httperrorrequest.error.md b/docs/development/core/public/kibana-plugin-public.httperrorrequest.error.md new file mode 100644 index 0000000000000..a8b511f889cdf --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httperrorrequest.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) > [error](./kibana-plugin-public.httperrorrequest.error.md) + +## HttpErrorRequest.error property + +Signature: + +```typescript +error: Error; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httperrorrequest.md b/docs/development/core/public/kibana-plugin-public.httperrorrequest.md new file mode 100644 index 0000000000000..e28d092eda71d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httperrorrequest.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) + +## HttpErrorRequest interface + + +Signature: + +```typescript +export interface HttpErrorRequest +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-public.httperrorrequest.error.md) | Error | | +| [request](./kibana-plugin-public.httperrorrequest.request.md) | Request | | + diff --git a/docs/development/core/public/kibana-plugin-public.httperrorrequest.request.md b/docs/development/core/public/kibana-plugin-public.httperrorrequest.request.md new file mode 100644 index 0000000000000..7a8a33307612f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httperrorrequest.request.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) > [request](./kibana-plugin-public.httperrorrequest.request.md) + +## HttpErrorRequest.request property + +Signature: + +```typescript +request?: Request; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httperrorresponse.error.md b/docs/development/core/public/kibana-plugin-public.httperrorresponse.error.md new file mode 100644 index 0000000000000..cb82a1f37f84e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httperrorresponse.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) > [error](./kibana-plugin-public.httperrorresponse.error.md) + +## HttpErrorResponse.error property + +Signature: + +```typescript +error: Error | HttpFetchError; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httperrorresponse.md b/docs/development/core/public/kibana-plugin-public.httperrorresponse.md new file mode 100644 index 0000000000000..ff001e4401c6c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httperrorresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) + +## HttpErrorResponse interface + + +Signature: + +```typescript +export interface HttpErrorResponse extends HttpResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-public.httperrorresponse.error.md) | Error | HttpFetchError | | + diff --git a/docs/development/core/public/kibana-plugin-public.httpfetchoptions.headers.md b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.headers.md new file mode 100644 index 0000000000000..2fb4c448fe237 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.headers.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) > [headers](./kibana-plugin-public.httpfetchoptions.headers.md) + +## HttpFetchOptions.headers property + +Signature: + +```typescript +headers?: HttpHeadersInit; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpfetchoptions.md b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.md new file mode 100644 index 0000000000000..93fabb053871a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) + +## HttpFetchOptions interface + + +Signature: + +```typescript +export interface HttpFetchOptions extends HttpRequestInit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-public.httpfetchoptions.headers.md) | HttpHeadersInit | | +| [prependBasePath](./kibana-plugin-public.httpfetchoptions.prependbasepath.md) | boolean | | +| [query](./kibana-plugin-public.httpfetchoptions.query.md) | HttpFetchQuery | | + diff --git a/docs/development/core/public/kibana-plugin-public.httpfetchoptions.prependbasepath.md b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.prependbasepath.md new file mode 100644 index 0000000000000..5fff6c3518b56 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.prependbasepath.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) > [prependBasePath](./kibana-plugin-public.httpfetchoptions.prependbasepath.md) + +## HttpFetchOptions.prependBasePath property + +Signature: + +```typescript +prependBasePath?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpfetchoptions.query.md b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.query.md new file mode 100644 index 0000000000000..2c24a3a3a548d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpfetchoptions.query.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) > [query](./kibana-plugin-public.httpfetchoptions.query.md) + +## HttpFetchOptions.query property + +Signature: + +```typescript +query?: HttpFetchQuery; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpfetchquery.md b/docs/development/core/public/kibana-plugin-public.httpfetchquery.md new file mode 100644 index 0000000000000..e09b22b074453 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpfetchquery.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) + +## HttpFetchQuery interface + + +Signature: + +```typescript +export interface HttpFetchQuery +``` diff --git a/docs/development/core/public/kibana-plugin-public.httphandler.md b/docs/development/core/public/kibana-plugin-public.httphandler.md new file mode 100644 index 0000000000000..8bc9c3302252f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httphandler.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpHandler](./kibana-plugin-public.httphandler.md) + +## HttpHandler type + + +Signature: + +```typescript +export declare type HttpHandler = (path: string, options?: HttpFetchOptions) => Promise; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpheadersinit.md b/docs/development/core/public/kibana-plugin-public.httpheadersinit.md new file mode 100644 index 0000000000000..15877a55fcddc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpheadersinit.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) + +## HttpHeadersInit interface + + +Signature: + +```typescript +export interface HttpHeadersInit +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.body.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.body.md new file mode 100644 index 0000000000000..ecf8343ab529c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.body.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [body](./kibana-plugin-public.httprequestinit.body.md) + +## HttpRequestInit.body property + +Signature: + +```typescript +body?: BodyInit | null; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.cache.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.cache.md new file mode 100644 index 0000000000000..813639b51f814 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.cache.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [cache](./kibana-plugin-public.httprequestinit.cache.md) + +## HttpRequestInit.cache property + +Signature: + +```typescript +cache?: RequestCache; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.credentials.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.credentials.md new file mode 100644 index 0000000000000..26e86722a8219 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.credentials.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [credentials](./kibana-plugin-public.httprequestinit.credentials.md) + +## HttpRequestInit.credentials property + +Signature: + +```typescript +credentials?: RequestCredentials; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.headers.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.headers.md new file mode 100644 index 0000000000000..2e5f86ebe38ef --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.headers.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [headers](./kibana-plugin-public.httprequestinit.headers.md) + +## HttpRequestInit.headers property + +Signature: + +```typescript +headers?: HttpHeadersInit; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.integrity.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.integrity.md new file mode 100644 index 0000000000000..9d8b3644aa9d7 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.integrity.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [integrity](./kibana-plugin-public.httprequestinit.integrity.md) + +## HttpRequestInit.integrity property + +Signature: + +```typescript +integrity?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.keepalive.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.keepalive.md new file mode 100644 index 0000000000000..bb1a50c280dce --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.keepalive.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [keepalive](./kibana-plugin-public.httprequestinit.keepalive.md) + +## HttpRequestInit.keepalive property + +Signature: + +```typescript +keepalive?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.md new file mode 100644 index 0000000000000..89fa6d5379581 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) + +## HttpRequestInit interface + + +Signature: + +```typescript +export interface HttpRequestInit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [body](./kibana-plugin-public.httprequestinit.body.md) | BodyInit | null | | +| [cache](./kibana-plugin-public.httprequestinit.cache.md) | RequestCache | | +| [credentials](./kibana-plugin-public.httprequestinit.credentials.md) | RequestCredentials | | +| [headers](./kibana-plugin-public.httprequestinit.headers.md) | HttpHeadersInit | | +| [integrity](./kibana-plugin-public.httprequestinit.integrity.md) | string | | +| [keepalive](./kibana-plugin-public.httprequestinit.keepalive.md) | boolean | | +| [method](./kibana-plugin-public.httprequestinit.method.md) | string | | +| [mode](./kibana-plugin-public.httprequestinit.mode.md) | RequestMode | | +| [redirect](./kibana-plugin-public.httprequestinit.redirect.md) | RequestRedirect | | +| [referrer](./kibana-plugin-public.httprequestinit.referrer.md) | string | | +| [referrerPolicy](./kibana-plugin-public.httprequestinit.referrerpolicy.md) | ReferrerPolicy | | +| [signal](./kibana-plugin-public.httprequestinit.signal.md) | AbortSignal | null | | +| [window](./kibana-plugin-public.httprequestinit.window.md) | any | | + diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.method.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.method.md new file mode 100644 index 0000000000000..2aab899405576 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.method.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [method](./kibana-plugin-public.httprequestinit.method.md) + +## HttpRequestInit.method property + +Signature: + +```typescript +method?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.mode.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.mode.md new file mode 100644 index 0000000000000..611671331ee58 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.mode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [mode](./kibana-plugin-public.httprequestinit.mode.md) + +## HttpRequestInit.mode property + +Signature: + +```typescript +mode?: RequestMode; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.redirect.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.redirect.md new file mode 100644 index 0000000000000..6795e99d370f3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.redirect.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [redirect](./kibana-plugin-public.httprequestinit.redirect.md) + +## HttpRequestInit.redirect property + +Signature: + +```typescript +redirect?: RequestRedirect; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.referrer.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.referrer.md new file mode 100644 index 0000000000000..60e249cc9cf1d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.referrer.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [referrer](./kibana-plugin-public.httprequestinit.referrer.md) + +## HttpRequestInit.referrer property + +Signature: + +```typescript +referrer?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.referrerpolicy.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.referrerpolicy.md new file mode 100644 index 0000000000000..3f92ee021f9cc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.referrerpolicy.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [referrerPolicy](./kibana-plugin-public.httprequestinit.referrerpolicy.md) + +## HttpRequestInit.referrerPolicy property + +Signature: + +```typescript +referrerPolicy?: ReferrerPolicy; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.signal.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.signal.md new file mode 100644 index 0000000000000..8657c6b7a1242 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.signal.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [signal](./kibana-plugin-public.httprequestinit.signal.md) + +## HttpRequestInit.signal property + +Signature: + +```typescript +signal?: AbortSignal | null; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httprequestinit.window.md b/docs/development/core/public/kibana-plugin-public.httprequestinit.window.md new file mode 100644 index 0000000000000..aec7fad7e3927 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httprequestinit.window.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) > [window](./kibana-plugin-public.httprequestinit.window.md) + +## HttpRequestInit.window property + +Signature: + +```typescript +window?: any; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpresponse.body.md b/docs/development/core/public/kibana-plugin-public.httpresponse.body.md new file mode 100644 index 0000000000000..c590c9ec49d1b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpresponse.body.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpResponse](./kibana-plugin-public.httpresponse.md) > [body](./kibana-plugin-public.httpresponse.body.md) + +## HttpResponse.body property + +Signature: + +```typescript +body?: HttpBody; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpresponse.md b/docs/development/core/public/kibana-plugin-public.httpresponse.md new file mode 100644 index 0000000000000..b2ec48fd4d6b5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpresponse.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpResponse](./kibana-plugin-public.httpresponse.md) + +## HttpResponse interface + + +Signature: + +```typescript +export interface HttpResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [body](./kibana-plugin-public.httpresponse.body.md) | HttpBody | | +| [request](./kibana-plugin-public.httpresponse.request.md) | Request | | +| [response](./kibana-plugin-public.httpresponse.response.md) | Response | | + diff --git a/docs/development/core/public/kibana-plugin-public.httpresponse.request.md b/docs/development/core/public/kibana-plugin-public.httpresponse.request.md new file mode 100644 index 0000000000000..3aaae6f8af091 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpresponse.request.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpResponse](./kibana-plugin-public.httpresponse.md) > [request](./kibana-plugin-public.httpresponse.request.md) + +## HttpResponse.request property + +Signature: + +```typescript +request: Request; +``` diff --git a/docs/development/core/public/kibana-plugin-public.httpresponse.response.md b/docs/development/core/public/kibana-plugin-public.httpresponse.response.md new file mode 100644 index 0000000000000..44c8eb4295f1c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.httpresponse.response.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [HttpResponse](./kibana-plugin-public.httpresponse.md) > [response](./kibana-plugin-public.httpresponse.response.md) + +## HttpResponse.response property + +Signature: + +```typescript +response?: Response; +``` diff --git a/docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md new file mode 100644 index 0000000000000..a02cc0f2e0a39 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.createhandler.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) > [createHandler](./kibana-plugin-public.icontextcontainer.createhandler.md) + +## IContextContainer.createHandler() method + +Create a new handler function pre-wired to context for the plugin. + +Signature: + +```typescript +createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler): (...rest: THandlerParameters) => Promisify; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| pluginOpaqueId | PluginOpaqueId | The plugin opaque ID for the plugin that registers this handler. | +| handler | IContextHandler<TContext, THandlerReturn, THandlerParameters> | Handler function to pass context object to. | + +Returns: + +`(...rest: THandlerParameters) => Promisify` + +A function that takes `THandlerParameters`, calls `handler` with a new context, and returns a Promise of the `handler` return value. + diff --git a/docs/development/core/public/kibana-plugin-public.icontextcontainer.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.md new file mode 100644 index 0000000000000..0bc7c8f3808d1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.md @@ -0,0 +1,80 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) + +## IContextContainer interface + +An object that handles registration of context providers and configuring handlers with context. + +Signature: + +```typescript +export interface IContextContainer +``` + +## Methods + +| Method | Description | +| --- | --- | +| [createHandler(pluginOpaqueId, handler)](./kibana-plugin-public.icontextcontainer.createhandler.md) | Create a new handler function pre-wired to context for the plugin. | +| [registerContext(pluginOpaqueId, contextName, provider)](./kibana-plugin-public.icontextcontainer.registercontext.md) | Register a new context provider. | + +## Remarks + +A [IContextContainer](./kibana-plugin-public.icontextcontainer.md) can be used by any Core service or plugin (known as the "service owner") which wishes to expose APIs in a handler function. The container object will manage registering context providers and configuring a handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on the dependencies that the handler's plugin declares. + +Contexts providers are executed in the order they were registered. Each provider gets access to context values provided by any plugins that it depends on. + +In order to configure a handler with context, you must call the [IContextContainer.createHandler()](./kibana-plugin-public.icontextcontainer.createhandler.md) function and use the returned handler which will automatically build a context object when called. + +When registering context or creating handlers, the \_calling plugin's opaque id\_ must be provided. This id is passed in via the plugin's initializer and can be accessed from the [PluginInitializerContext.opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) Note this should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + +```ts +// Correct +class MyPlugin { + private readonly handlers = new Map(); + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(pluginOpaqueId, contextName, provider) { + this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + }, + registerRoute(pluginOpaqueId, path, handler) { + this.handlers.set( + path, + this.contextContainer.createHandler(pluginOpaqueId, handler) + ); + } + } + } +} + +// Incorrect +class MyPlugin { + private readonly handlers = new Map(); + + constructor(private readonly initContext: PluginInitializerContext) {} + + setup(core) { + this.contextContainer = core.context.createContextContainer(); + return { + registerContext(contextName, provider) { + // BUG! + // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + }, + registerRoute(path, handler) { + this.handlers.set( + path, + // BUG! + // This handler will not receive any contexts provided by other dependencies of the calling plugin. + this.contextContainer.createHandler(this.initContext.opaqueId, handler) + ); + } + } + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md b/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md new file mode 100644 index 0000000000000..2cf10a6ec841d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextcontainer.registercontext.md @@ -0,0 +1,34 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextContainer](./kibana-plugin-public.icontextcontainer.md) > [registerContext](./kibana-plugin-public.icontextcontainer.registercontext.md) + +## IContextContainer.registerContext() method + +Register a new context provider. + +Signature: + +```typescript +registerContext(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| pluginOpaqueId | PluginOpaqueId | The plugin opaque ID for the plugin that registers this context. | +| contextName | TContextName | The key of the TContext object this provider supplies the value for. | +| provider | IContextProvider<TContext, TContextName, THandlerParameters> | A [IContextProvider](./kibana-plugin-public.icontextprovider.md) to be called each time a new context is created. | + +Returns: + +`this` + +The [IContextContainer](./kibana-plugin-public.icontextcontainer.md) for method chaining. + +## Remarks + +The value (or resolved Promise value) returned by the `provider` function will be attached to the context object on the key specified by `contextName`. + +Throws an exception if more than one provider is registered for the same `contextName`. + diff --git a/docs/development/core/public/kibana-plugin-public.icontexthandler.md b/docs/development/core/public/kibana-plugin-public.icontexthandler.md new file mode 100644 index 0000000000000..2251b1131c313 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontexthandler.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextHandler](./kibana-plugin-public.icontexthandler.md) + +## IContextHandler type + +A function registered by a plugin to perform some action. + +Signature: + +```typescript +export declare type IContextHandler = (context: TContext, ...rest: THandlerParameters) => TReturn; +``` + +## Remarks + +A new `TContext` will be built for each handler before invoking. + diff --git a/docs/development/core/public/kibana-plugin-public.icontextprovider.md b/docs/development/core/public/kibana-plugin-public.icontextprovider.md new file mode 100644 index 0000000000000..a84917d6e1442 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.icontextprovider.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [IContextProvider](./kibana-plugin-public.icontextprovider.md) + +## IContextProvider type + +A function that returns a context value for a specific key of given context type. + +Signature: + +```typescript +export declare type IContextProvider, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; +``` + +## Remarks + +This function will be called each time a new context is built for a handler invocation. + diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index 98b6a8703f543..8da53487d5e7a 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -34,15 +34,24 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | | [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | | [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | | [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | | [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | | [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | | [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | | | [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | | [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | +| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | +| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | | +| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | +| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | | [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | | +| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | | +| [HttpResponse](./kibana-plugin-public.httpresponse.md) | | | [HttpServiceBase](./kibana-plugin-public.httpservicebase.md) | | | [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | | [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | | [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | | [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | @@ -58,8 +67,12 @@ The plugin integrates with the core system via lifecycle events: `setup` | --- | --- | | [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | | [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [HttpBody](./kibana-plugin-public.httpbody.md) | | +| [HttpHandler](./kibana-plugin-public.httphandler.md) | | | [HttpSetup](./kibana-plugin-public.httpsetup.md) | | | [HttpStart](./kibana-plugin-public.httpstart.md) | | +| [IContextHandler](./kibana-plugin-public.icontexthandler.md) | A function registered by a plugin to perform some action. | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | | [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | | [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | | [ToastInput](./kibana-plugin-public.toastinput.md) | | diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.md b/docs/development/core/public/kibana-plugin-public.overlaystart.md index c14ba89a7ffd0..1345beffbfb6a 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.md @@ -16,5 +16,5 @@ export interface OverlayStart | Property | Type | Description | | --- | --- | --- | | [openFlyout](./kibana-plugin-public.overlaystart.openflyout.md) | (flyoutChildren: React.ReactNode, flyoutProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | -| [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | (modalChildren: React.ReactNode, modalProps?: {
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | +| [openModal](./kibana-plugin-public.overlaystart.openmodal.md) | (modalChildren: React.ReactNode, modalProps?: {
className?: string;
closeButtonAriaLabel?: string;
'data-test-subj'?: string;
}) => OverlayRef | | diff --git a/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md b/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md index 0fc8ba164eaee..a4569e178f17d 100644 --- a/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md +++ b/docs/development/core/public/kibana-plugin-public.overlaystart.openmodal.md @@ -8,6 +8,7 @@ ```typescript openModal: (modalChildren: React.ReactNode, modalProps?: { + className?: string; closeButtonAriaLabel?: string; 'data-test-subj'?: string; }) => OverlayRef; diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md index 5dbe464d15618..3ad220349c45c 100644 --- a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.md @@ -11,3 +11,10 @@ The available core services passed to a `PluginInitializer` ```typescript export interface PluginInitializerContext ``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) | PluginOpaqueId | A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. | + diff --git a/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md new file mode 100644 index 0000000000000..10e6b79be4959 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.plugininitializercontext.opaqueid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) > [opaqueId](./kibana-plugin-public.plugininitializercontext.opaqueid.md) + +## PluginInitializerContext.opaqueId property + +A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. + +Signature: + +```typescript +readonly opaqueId: PluginOpaqueId; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authheaders.md b/docs/development/core/server/kibana-plugin-server.authheaders.md index 96939cb8bbcbf..bdb7cda2fa304 100644 --- a/docs/development/core/server/kibana-plugin-server.authheaders.md +++ b/docs/development/core/server/kibana-plugin-server.authheaders.md @@ -9,5 +9,5 @@ Auth Headers map Signature: ```typescript -export declare type AuthHeaders = Record; +export declare type AuthHeaders = Record; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md b/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md deleted file mode 100644 index 4287978c3ac34..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.authresultdata.headers.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) > [headers](./kibana-plugin-server.authresultdata.headers.md) - -## AuthResultData.headers property - -Auth specific headers to authenticate a user against Elasticsearch. - -Signature: - -```typescript -headers: AuthHeaders; -``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.md b/docs/development/core/server/kibana-plugin-server.authresultdata.md deleted file mode 100644 index 57908bd704591..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.authresultdata.md +++ /dev/null @@ -1,21 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) - -## AuthResultData interface - -Result of an incoming request authentication. - -Signature: - -```typescript -export interface AuthResultData -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [headers](./kibana-plugin-server.authresultdata.headers.md) | AuthHeaders | Auth specific headers to authenticate a user against Elasticsearch. | -| [state](./kibana-plugin-server.authresultdata.state.md) | Record<string, any> | Data to associate with an incoming request. Any downstream plugin may get access to the data. | - diff --git a/docs/development/core/server/kibana-plugin-server.authresultparams.md b/docs/development/core/server/kibana-plugin-server.authresultparams.md new file mode 100644 index 0000000000000..b098fe278d850 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultparams.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultParams](./kibana-plugin-server.authresultparams.md) + +## AuthResultParams interface + +Result of an incoming request authentication. + +Signature: + +```typescript +export interface AuthResultParams +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [requestHeaders](./kibana-plugin-server.authresultparams.requestheaders.md) | AuthHeaders | Auth specific headers to attach to a request object. Used to perform a request to Elasticsearch on behalf of an authenticated user. | +| [responseHeaders](./kibana-plugin-server.authresultparams.responseheaders.md) | AuthHeaders | Auth specific headers to attach to a response object. Used to send back authentication mechanism related headers to a client when needed. | +| [state](./kibana-plugin-server.authresultparams.state.md) | Record<string, any> | Data to associate with an incoming request. Any downstream plugin may get access to the data. | + diff --git a/docs/development/core/server/kibana-plugin-server.authresultparams.requestheaders.md b/docs/development/core/server/kibana-plugin-server.authresultparams.requestheaders.md new file mode 100644 index 0000000000000..0fda032b64f98 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultparams.requestheaders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultParams](./kibana-plugin-server.authresultparams.md) > [requestHeaders](./kibana-plugin-server.authresultparams.requestheaders.md) + +## AuthResultParams.requestHeaders property + +Auth specific headers to attach to a request object. Used to perform a request to Elasticsearch on behalf of an authenticated user. + +Signature: + +```typescript +requestHeaders?: AuthHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultparams.responseheaders.md b/docs/development/core/server/kibana-plugin-server.authresultparams.responseheaders.md new file mode 100644 index 0000000000000..c14feb25801d1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authresultparams.responseheaders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultParams](./kibana-plugin-server.authresultparams.md) > [responseHeaders](./kibana-plugin-server.authresultparams.responseheaders.md) + +## AuthResultParams.responseHeaders property + +Auth specific headers to attach to a response object. Used to send back authentication mechanism related headers to a client when needed. + +Signature: + +```typescript +responseHeaders?: AuthHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-server.authresultdata.state.md b/docs/development/core/server/kibana-plugin-server.authresultparams.state.md similarity index 56% rename from docs/development/core/server/kibana-plugin-server.authresultdata.state.md rename to docs/development/core/server/kibana-plugin-server.authresultparams.state.md index 70054395514b7..8ca3da20a9c22 100644 --- a/docs/development/core/server/kibana-plugin-server.authresultdata.state.md +++ b/docs/development/core/server/kibana-plugin-server.authresultparams.state.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultData](./kibana-plugin-server.authresultdata.md) > [state](./kibana-plugin-server.authresultdata.state.md) +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthResultParams](./kibana-plugin-server.authresultparams.md) > [state](./kibana-plugin-server.authresultparams.state.md) -## AuthResultData.state property +## AuthResultParams.state property Data to associate with an incoming request. Any downstream plugin may get access to the data. Signature: ```typescript -state: Record; +state?: Record; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authstatus.md b/docs/development/core/server/kibana-plugin-server.authstatus.md new file mode 100644 index 0000000000000..e59ade4f73e38 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.authstatus.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [AuthStatus](./kibana-plugin-server.authstatus.md) + +## AuthStatus enum + +Status indicating an outcome of the authentication. + +Signature: + +```typescript +export declare enum AuthStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| authenticated | "authenticated" | auth interceptor successfully authenticated a user | +| unauthenticated | "unauthenticated" | auth interceptor failed user authentication | +| unknown | "unknown" | auth interceptor has not been registered | + diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md index e8e245ac01597..54d78c840ed5a 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.authenticated.md @@ -9,5 +9,5 @@ Authentication is successful with given credentials, allow request to pass throu Signature: ```typescript -authenticated: (data?: Partial) => AuthResult; +authenticated: (data?: AuthResultParams) => AuthResult; ``` diff --git a/docs/development/core/server/kibana-plugin-server.authtoolkit.md b/docs/development/core/server/kibana-plugin-server.authtoolkit.md index 2fe4312153a6a..85bc0b4204241 100644 --- a/docs/development/core/server/kibana-plugin-server.authtoolkit.md +++ b/docs/development/core/server/kibana-plugin-server.authtoolkit.md @@ -16,7 +16,7 @@ export interface AuthToolkit | Property | Type | Description | | --- | --- | --- | -| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (data?: Partial<AuthResultData>) => AuthResult | Authentication is successful with given credentials, allow request to pass through | +| [authenticated](./kibana-plugin-server.authtoolkit.authenticated.md) | (data?: AuthResultParams) => AuthResult | Authentication is successful with given credentials, allow request to pass through | | [redirected](./kibana-plugin-server.authtoolkit.redirected.md) | (url: string) => AuthResult | Authentication requires to interrupt request handling and redirect to a configured url | | [rejected](./kibana-plugin-server.authtoolkit.rejected.md) | (error: Error, options?: {
statusCode?: number;
}) => AuthResult | Authentication is unsuccessful, fail the request with specified error. | diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.http.md b/docs/development/core/server/kibana-plugin-server.coresetup.http.md index c9206b7a7e711..e5347dd7c6625 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.http.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.http.md @@ -13,7 +13,6 @@ http: { registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; - createNewServer: HttpServiceSetup['createNewServer']; isTlsEnabled: HttpServiceSetup['isTlsEnabled']; }; ``` diff --git a/docs/development/core/server/kibana-plugin-server.coresetup.md b/docs/development/core/server/kibana-plugin-server.coresetup.md index f4653d7f43579..38990f2797677 100644 --- a/docs/development/core/server/kibana-plugin-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-server.coresetup.md @@ -17,5 +17,5 @@ export interface CoreSetup | Property | Type | Description | | --- | --- | --- | | [elasticsearch](./kibana-plugin-server.coresetup.elasticsearch.md) | {
adminClient$: Observable<ClusterClient>;
dataClient$: Observable<ClusterClient>;
createClient: (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ClusterClient;
} | | -| [http](./kibana-plugin-server.coresetup.http.md) | {
createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory'];
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
createNewServer: HttpServiceSetup['createNewServer'];
isTlsEnabled: HttpServiceSetup['isTlsEnabled'];
} | | +| [http](./kibana-plugin-server.coresetup.http.md) | {
createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory'];
registerOnPreAuth: HttpServiceSetup['registerOnPreAuth'];
registerAuth: HttpServiceSetup['registerAuth'];
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
basePath: HttpServiceSetup['basePath'];
isTlsEnabled: HttpServiceSetup['isTlsEnabled'];
} | | diff --git a/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.md b/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.md new file mode 100644 index 0000000000000..cabee8a47e5ca --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CustomHttpResponseOptions](./kibana-plugin-server.customhttpresponseoptions.md) + +## CustomHttpResponseOptions interface + +HTTP response parameters for a response with adjustable status code. + +Signature: + +```typescript +export interface CustomHttpResponseOptions extends HttpResponseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [statusCode](./kibana-plugin-server.customhttpresponseoptions.statuscode.md) | number | | + diff --git a/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.statuscode.md b/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.statuscode.md new file mode 100644 index 0000000000000..5444ccd2ebb55 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.customhttpresponseoptions.statuscode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [CustomHttpResponseOptions](./kibana-plugin-server.customhttpresponseoptions.md) > [statusCode](./kibana-plugin-server.customhttpresponseoptions.statuscode.md) + +## CustomHttpResponseOptions.statusCode property + +Signature: + +```typescript +statusCode: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.getauthheaders.md b/docs/development/core/server/kibana-plugin-server.getauthheaders.md index ee7572615fe1a..fba8b8ca8ee3a 100644 --- a/docs/development/core/server/kibana-plugin-server.getauthheaders.md +++ b/docs/development/core/server/kibana-plugin-server.getauthheaders.md @@ -9,5 +9,5 @@ Get headers to authenticate a user against Elasticsearch. Signature: ```typescript -export declare type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; +export declare type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; ``` diff --git a/docs/development/core/server/kibana-plugin-server.getauthstate.md b/docs/development/core/server/kibana-plugin-server.getauthstate.md new file mode 100644 index 0000000000000..47fc38c28f5e0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.getauthstate.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [GetAuthState](./kibana-plugin-server.getauthstate.md) + +## GetAuthState type + +Get authentication state for a request. Returned by `auth` interceptor. + +Signature: + +```typescript +export declare type GetAuthState = (request: KibanaRequest | LegacyRequest) => { + status: AuthStatus; + state: unknown; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-server.headers.md b/docs/development/core/server/kibana-plugin-server.headers.md index 83259efe8b79d..cd73d4de43b9d 100644 --- a/docs/development/core/server/kibana-plugin-server.headers.md +++ b/docs/development/core/server/kibana-plugin-server.headers.md @@ -4,9 +4,14 @@ ## Headers type +Http request headers to read. Signature: ```typescript -export declare type Headers = Record; +export declare type Headers = { + [header in KnownHeaders]?: string | string[] | undefined; +} & { + [header: string]: string | string[] | undefined; +}; ``` diff --git a/docs/development/core/server/kibana-plugin-server.httpresponseoptions.headers.md b/docs/development/core/server/kibana-plugin-server.httpresponseoptions.headers.md new file mode 100644 index 0000000000000..ee347f99a41a4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpresponseoptions.headers.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpResponseOptions](./kibana-plugin-server.httpresponseoptions.md) > [headers](./kibana-plugin-server.httpresponseoptions.headers.md) + +## HttpResponseOptions.headers property + +HTTP Headers with additional information about response + +Signature: + +```typescript +headers?: ResponseHeaders; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpresponseoptions.md b/docs/development/core/server/kibana-plugin-server.httpresponseoptions.md new file mode 100644 index 0000000000000..8f9ccf22c8c6d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpresponseoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpResponseOptions](./kibana-plugin-server.httpresponseoptions.md) + +## HttpResponseOptions interface + +HTTP response parameters + +Signature: + +```typescript +export interface HttpResponseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [headers](./kibana-plugin-server.httpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | + diff --git a/docs/development/core/server/kibana-plugin-server.httpresponsepayload.md b/docs/development/core/server/kibana-plugin-server.httpresponsepayload.md new file mode 100644 index 0000000000000..3dc4e2c7956f7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpresponsepayload.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpResponsePayload](./kibana-plugin-server.httpresponsepayload.md) + +## HttpResponsePayload type + +Data send to the client as a response payload. + +Signature: + +```typescript +export declare type HttpResponsePayload = undefined | string | Record | Buffer | Stream; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.auth.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.auth.md new file mode 100644 index 0000000000000..e39c3c6316768 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.auth.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [auth](./kibana-plugin-server.httpserversetup.auth.md) + +## HttpServerSetup.auth property + +Signature: + +```typescript +auth: { + get: GetAuthState; + isAuthenticated: IsAuthenticated; + getAuthHeaders: GetAuthHeaders; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.basepath.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.basepath.md new file mode 100644 index 0000000000000..5cfb2f5c4e8b4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.basepath.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [basePath](./kibana-plugin-server.httpserversetup.basepath.md) + +## HttpServerSetup.basePath property + +Signature: + +```typescript +basePath: { + get: (request: KibanaRequest | LegacyRequest) => string; + set: (request: KibanaRequest | LegacyRequest, basePath: string) => void; + prepend: (url: string) => string; + remove: (url: string) => string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.createcookiesessionstoragefactory.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.createcookiesessionstoragefactory.md new file mode 100644 index 0000000000000..3dc01a52a2f58 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.createcookiesessionstoragefactory.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [createCookieSessionStorageFactory](./kibana-plugin-server.httpserversetup.createcookiesessionstoragefactory.md) + +## HttpServerSetup.createCookieSessionStorageFactory property + +Creates cookie based session storage factory [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) + +Signature: + +```typescript +createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => Promise>; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.istlsenabled.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.istlsenabled.md new file mode 100644 index 0000000000000..6961d4feeb7c7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.istlsenabled.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [isTlsEnabled](./kibana-plugin-server.httpserversetup.istlsenabled.md) + +## HttpServerSetup.isTlsEnabled property + +Flag showing whether a server was configured to use TLS connection. + +Signature: + +```typescript +isTlsEnabled: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.md new file mode 100644 index 0000000000000..143ae66c0b32c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.md @@ -0,0 +1,93 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) + +## HttpServerSetup interface + +Kibana HTTP Service provides own abstraction for work with HTTP stack. Plugins don't have direct access to `hapi` server and its primitives anymore. Moreover, plugins shouldn't rely on the fact that HTTP Service uses one or another library under the hood. This gives the platform flexibility to upgrade or changing our internal HTTP stack without breaking plugins. If the HTTP Service lacks functionality you need, we are happy to discuss and support your needs. + +Signature: + +```typescript +export interface HttpServerSetup +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [auth](./kibana-plugin-server.httpserversetup.auth.md) | {
get: GetAuthState;
isAuthenticated: IsAuthenticated;
getAuthHeaders: GetAuthHeaders;
} | | +| [basePath](./kibana-plugin-server.httpserversetup.basepath.md) | {
get: (request: KibanaRequest | LegacyRequest) => string;
set: (request: KibanaRequest | LegacyRequest, basePath: string) => void;
prepend: (url: string) => string;
remove: (url: string) => string;
} | | +| [createCookieSessionStorageFactory](./kibana-plugin-server.httpserversetup.createcookiesessionstoragefactory.md) | <T>(cookieOptions: SessionStorageCookieOptions<T>) => Promise<SessionStorageFactory<T>> | Creates cookie based session storage factory [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) | +| [isTlsEnabled](./kibana-plugin-server.httpserversetup.istlsenabled.md) | boolean | Flag showing whether a server was configured to use TLS connection. | +| [registerAuth](./kibana-plugin-server.httpserversetup.registerauth.md) | (handler: AuthenticationHandler) => void | To define custom authentication and/or authorization mechanism for incoming requests. A handler should return a state to associate with the incoming request. The state can be retrieved later via http.auth.get(..) Only one AuthenticationHandler can be registered. | +| [registerOnPostAuth](./kibana-plugin-server.httpserversetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic to perform for incoming requests. Runs the handler after Auth interceptor did make sure a user has access to the requested resource. The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). | +| [registerOnPreAuth](./kibana-plugin-server.httpserversetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests. Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). | +| [registerRouter](./kibana-plugin-server.httpserversetup.registerrouter.md) | (router: Router) => void | Add all the routes registered with router to HTTP server request listeners. | +| [server](./kibana-plugin-server.httpserversetup.server.md) | Server | | + +## Example + +To handle an incoming request in your plugin you should: - Create a `Router` instance. Use `plugin-id` as a prefix path segment for your routes. + +```ts +import { Router } from 'src/core/server'; +const router = new Router('my-app'); + +``` +- Use `@kbn/config-schema` package to create a schema to validate the request `params`, `query`, and `body`. Every incoming request will be validated against the created schema. If validation failed, the request is rejected with `400` status and `Bad request` error without calling the route's handler. To opt out of validating the request, specify `false`. + +```ts +import { schema, TypeOf } from '@kbn/config-schema'; +const validate = { + params: schema.object({ + id: schema.string(), + }), +}; + +``` +- Declare a function to respond to incoming request. The function will receive `request` object containing request details: url, headers, matched route, as well as validated `params`, `query`, `body`. And `response` object instructing HTTP server to create HTTP response with information sent back to the client as the response body, headers, and HTTP status. Unlike, `hapi` route handler in the Legacy platform, any exception raised during the handler call will generate `500 Server error` response and log error details for further investigation. See below for returning custom error responses. + +```ts +const handler = async (request: KibanaRequest, response: ResponseFactory) => { + const data = await findObject(request.params.id); + // creates a command to respond with 'not found' error + if (!data) return response.notFound(); + // creates a command to send found data to the client and set response headers + return response.ok(data, { + headers: { + 'content-type': 'application/json' + } + }); +} + +``` +- Register route handler for GET request to 'my-app/path/{id}' path + +```ts +import { schema, TypeOf } from '@kbn/config-schema'; +import { Router } from 'src/core/server'; +const router = new Router('my-app'); + +const validate = { + params: schema.object({ + id: schema.string(), + }), +}; + +router.get({ + path: 'path/{id}', + validate +}, +async (request, response) => { + const data = await findObject(request.params.id); + if (!data) return response.notFound(); + return response.ok(data, { + headers: { + 'content-type': 'application/json' + } + }); +}); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.registerauth.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.registerauth.md new file mode 100644 index 0000000000000..6e63e0996a63a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.registerauth.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [registerAuth](./kibana-plugin-server.httpserversetup.registerauth.md) + +## HttpServerSetup.registerAuth property + +To define custom authentication and/or authorization mechanism for incoming requests. A handler should return a state to associate with the incoming request. The state can be retrieved later via http.auth.get(..) Only one AuthenticationHandler can be registered. + +Signature: + +```typescript +registerAuth: (handler: AuthenticationHandler) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpostauth.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpostauth.md new file mode 100644 index 0000000000000..c74a67da350ec --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpostauth.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [registerOnPostAuth](./kibana-plugin-server.httpserversetup.registeronpostauth.md) + +## HttpServerSetup.registerOnPostAuth property + +To define custom logic to perform for incoming requests. Runs the handler after Auth interceptor did make sure a user has access to the requested resource. The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). + +Signature: + +```typescript +registerOnPostAuth: (handler: OnPostAuthHandler) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpreauth.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpreauth.md new file mode 100644 index 0000000000000..f6efa1c1dd73c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.registeronpreauth.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [registerOnPreAuth](./kibana-plugin-server.httpserversetup.registeronpreauth.md) + +## HttpServerSetup.registerOnPreAuth property + +To define custom logic to perform for incoming requests. Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). + +Signature: + +```typescript +registerOnPreAuth: (handler: OnPreAuthHandler) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.registerrouter.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.registerrouter.md new file mode 100644 index 0000000000000..4c2a9ae327406 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.registerrouter.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [registerRouter](./kibana-plugin-server.httpserversetup.registerrouter.md) + +## HttpServerSetup.registerRouter property + +Add all the routes registered with `router` to HTTP server request listeners. + +Signature: + +```typescript +registerRouter: (router: Router) => void; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpserversetup.server.md b/docs/development/core/server/kibana-plugin-server.httpserversetup.server.md new file mode 100644 index 0000000000000..a137eba7c8a5a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.httpserversetup.server.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) > [server](./kibana-plugin-server.httpserversetup.server.md) + +## HttpServerSetup.server property + +Signature: + +```typescript +server: Server; +``` diff --git a/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md b/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md deleted file mode 100644 index e41684ea2b784..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.httpservicesetup.createnewserver.md +++ /dev/null @@ -1,11 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) > [createNewServer](./kibana-plugin-server.httpservicesetup.createnewserver.md) - -## HttpServiceSetup.createNewServer property - -Signature: - -```typescript -createNewServer: (cfg: Partial) => Promise; -``` diff --git a/docs/development/core/server/kibana-plugin-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md index ec4a2537b8404..7e8f17510c8ee 100644 --- a/docs/development/core/server/kibana-plugin-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-server.httpservicesetup.md @@ -2,18 +2,11 @@ [Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) -## HttpServiceSetup interface +## HttpServiceSetup type Signature: ```typescript -export interface HttpServiceSetup extends HttpServerSetup +export declare type HttpServiceSetup = HttpServerSetup; ``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [createNewServer](./kibana-plugin-server.httpservicesetup.createnewserver.md) | (cfg: Partial<HttpConfig>) => Promise<HttpServerSetup> | | - diff --git a/docs/development/core/server/kibana-plugin-server.isauthenticated.md b/docs/development/core/server/kibana-plugin-server.isauthenticated.md new file mode 100644 index 0000000000000..15f412710412a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.isauthenticated.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) + +## IsAuthenticated type + +Return authentication status for a request. + +Signature: + +```typescript +export declare type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.md index a9622e4319d57..19167f2f64041 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.md @@ -26,6 +26,6 @@ export declare class KibanaRequestHeaders | Readonly copy of incoming request headers. | | [params](./kibana-plugin-server.kibanarequest.params.md) | | Params | | | [query](./kibana-plugin-server.kibanarequest.query.md) | | Query | | -| [route](./kibana-plugin-server.kibanarequest.route.md) | | RecursiveReadonly<KibanaRequestRoute> | | -| [url](./kibana-plugin-server.kibanarequest.url.md) | | Url | | +| [route](./kibana-plugin-server.kibanarequest.route.md) | | RecursiveReadonly<KibanaRequestRoute> | matched route details | +| [url](./kibana-plugin-server.kibanarequest.url.md) | | Url | a WHATWG URL standard object. | diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.route.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.route.md index 301eeef1b6bb5..88954eedf4cfb 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.route.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.route.md @@ -4,6 +4,8 @@ ## KibanaRequest.route property +matched route details + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-server.kibanarequest.url.md b/docs/development/core/server/kibana-plugin-server.kibanarequest.url.md index b8bd46199763e..62d1f97159476 100644 --- a/docs/development/core/server/kibana-plugin-server.kibanarequest.url.md +++ b/docs/development/core/server/kibana-plugin-server.kibanarequest.url.md @@ -4,6 +4,8 @@ ## KibanaRequest.url property +a WHATWG URL standard object. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md new file mode 100644 index 0000000000000..82832ee9334a5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.kibanaresponsefactory.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) + +## KibanaResponseFactory type + +Creates an object containing request response payload, HTTP headers, error details, and other data transmitted to the client. + +Signature: + +```typescript +export declare type KibanaResponseFactory = typeof kibanaResponseFactory; +``` diff --git a/docs/development/core/server/kibana-plugin-server.knownheaders.md b/docs/development/core/server/kibana-plugin-server.knownheaders.md new file mode 100644 index 0000000000000..986794f3aaa61 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.knownheaders.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [KnownHeaders](./kibana-plugin-server.knownheaders.md) + +## KnownHeaders type + +Set of well-known HTTP headers. + +Signature: + +```typescript +export declare type KnownHeaders = KnownKeys; +``` diff --git a/docs/development/core/server/kibana-plugin-server.legacyrequest.md b/docs/development/core/server/kibana-plugin-server.legacyrequest.md index 6f67928faa52c..a794b3bbe87c7 100644 --- a/docs/development/core/server/kibana-plugin-server.legacyrequest.md +++ b/docs/development/core/server/kibana-plugin-server.legacyrequest.md @@ -2,12 +2,15 @@ [Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [LegacyRequest](./kibana-plugin-server.legacyrequest.md) -## LegacyRequest type +## LegacyRequest interface -Support Legacy platform request for the period of migration. +> Warning: This API is now obsolete. +> +> `hapi` request object, supported during migration process only for backward compatibility. +> Signature: ```typescript -export declare type LegacyRequest = Request; +export interface LegacyRequest extends Request ``` diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index ab79f2b382909..4cb19665a5dd6 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -17,27 +17,38 @@ The plugin integrates with the core system via lifecycle events: `setup` | [ClusterClient](./kibana-plugin-server.clusterclient.md) | Represents an Elasticsearch cluster API client and allows to call API on behalf of the internal Kibana user and the actual user that is derived from the request headers (via asScoped(...)). | | [ElasticsearchErrorHelpers](./kibana-plugin-server.elasticsearcherrorhelpers.md) | Helpers for working with errors returned from the Elasticsearch service.Since the internal data of errors are subject to change, consumers of the Elasticsearch service should always use these helpers to classify errors instead of checking error internals such as body.error.header[WWW-Authenticate] | | [KibanaRequest](./kibana-plugin-server.kibanarequest.md) | Kibana specific abstraction for an incoming request. | -| [Router](./kibana-plugin-server.router.md) | | +| [Router](./kibana-plugin-server.router.md) | Provides ability to declare a handler function for a particular path and HTTP request method. Each route can have only one handler functions, which is executed when the route is matched. | | [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) | | +| [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) | | +| [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) | | | [ScopedClusterClient](./kibana-plugin-server.scopedclusterclient.md) | Serves the same purpose as "normal" ClusterClient but exposes additional callAsCurrentUser method that doesn't use credentials of the Kibana internal user (as callAsInternalUser does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API | +## Enumerations + +| Enumeration | Description | +| --- | --- | +| [AuthStatus](./kibana-plugin-server.authstatus.md) | Status indicating an outcome of the authentication. | + ## Interfaces | Interface | Description | | --- | --- | -| [AuthResultData](./kibana-plugin-server.authresultdata.md) | Result of an incoming request authentication. | +| [AuthResultParams](./kibana-plugin-server.authresultparams.md) | Result of an incoming request authentication. | | [AuthToolkit](./kibana-plugin-server.authtoolkit.md) | A tool set defining an outcome of Auth interceptor for incoming request. | | [CallAPIOptions](./kibana-plugin-server.callapioptions.md) | The set of options that defines how API call should be made and result be processed. | | [CoreSetup](./kibana-plugin-server.coresetup.md) | Context passed to the plugins setup method. | | [CoreStart](./kibana-plugin-server.corestart.md) | Context passed to the plugins start method. | +| [CustomHttpResponseOptions](./kibana-plugin-server.customhttpresponseoptions.md) | HTTP response parameters for a response with adjustable status code. | | [DiscoveredPlugin](./kibana-plugin-server.discoveredplugin.md) | Small container object used to expose information about discovered plugins that may or may not have been started. | | [ElasticsearchError](./kibana-plugin-server.elasticsearcherror.md) | | | [ElasticsearchServiceSetup](./kibana-plugin-server.elasticsearchservicesetup.md) | | | [FakeRequest](./kibana-plugin-server.fakerequest.md) | Fake request object created manually by Kibana plugins. | -| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | +| [HttpResponseOptions](./kibana-plugin-server.httpresponseoptions.md) | HTTP response parameters | +| [HttpServerSetup](./kibana-plugin-server.httpserversetup.md) | Kibana HTTP Service provides own abstraction for work with HTTP stack. Plugins don't have direct access to hapi server and its primitives anymore. Moreover, plugins shouldn't rely on the fact that HTTP Service uses one or another library under the hood. This gives the platform flexibility to upgrade or changing our internal HTTP stack without breaking plugins. If the HTTP Service lacks functionality you need, we are happy to discuss and support your needs. | | [HttpServiceStart](./kibana-plugin-server.httpservicestart.md) | | | [InternalCoreStart](./kibana-plugin-server.internalcorestart.md) | | | [KibanaRequestRoute](./kibana-plugin-server.kibanarequestroute.md) | Request specific route information exposed to a handler. | +| [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | | | [Logger](./kibana-plugin-server.logger.md) | Logger exposes all the necessary methods to log any type of information and this is the interface used by the logging consumers including plugins. | | [LoggerFactory](./kibana-plugin-server.loggerfactory.md) | The single purpose of LoggerFactory interface is to define a way to retrieve a context-based logger instance. | | [LogMeta](./kibana-plugin-server.logmeta.md) | Contextual metadata | @@ -47,7 +58,9 @@ The plugin integrates with the core system via lifecycle events: `setup` | [PluginInitializerContext](./kibana-plugin-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. | | [PluginsServiceSetup](./kibana-plugin-server.pluginsservicesetup.md) | | | [PluginsServiceStart](./kibana-plugin-server.pluginsservicestart.md) | | -| [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Route specific configuration. | +| [ResponseErrorMeta](./kibana-plugin-server.responseerrormeta.md) | Additional metadata to enhance error output or provide error details. | +| [RouteConfig](./kibana-plugin-server.routeconfig.md) | Route specific configuration. | +| [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md) | Additional route options. | | [SavedObject](./kibana-plugin-server.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-server.savedobjectattributes.md) | | | [SavedObjectReference](./kibana-plugin-server.savedobjectreference.md) | A reference to another saved object. | @@ -55,17 +68,36 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkCreateObject](./kibana-plugin-server.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkGetObject](./kibana-plugin-server.savedobjectsbulkgetobject.md) | | | [SavedObjectsBulkResponse](./kibana-plugin-server.savedobjectsbulkresponse.md) | | -| [SavedObjectsClientWrapperOptions](./kibana-plugin-server.savedobjectsclientwrapperoptions.md) | | +| [SavedObjectsClientProviderOptions](./kibana-plugin-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | +| [SavedObjectsClientWrapperOptions](./kibana-plugin-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | | [SavedObjectsCreateOptions](./kibana-plugin-server.savedobjectscreateoptions.md) | | +| [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) | Options controlling the export operation. | | [SavedObjectsFindOptions](./kibana-plugin-server.savedobjectsfindoptions.md) | | | [SavedObjectsFindResponse](./kibana-plugin-server.savedobjectsfindresponse.md) | | +| [SavedObjectsImportConflictError](./kibana-plugin-server.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | +| [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) | Represents a failure to import. | +| [SavedObjectsImportMissingReferencesError](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | +| [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) | Options to control the import operation. | +| [SavedObjectsImportResponse](./kibana-plugin-server.savedobjectsimportresponse.md) | The response describing the result of an import. | +| [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportUnknownError](./kibana-plugin-server.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | +| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsMigrationVersion](./kibana-plugin-server.savedobjectsmigrationversion.md) | A dictionary of saved object type -> version used to determine what migrations need to be applied to a saved object. | +| [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) | A raw document as represented directly in the saved object index. | +| [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) | Options to control the "resolve import" operation. | | [SavedObjectsService](./kibana-plugin-server.savedobjectsservice.md) | | | [SavedObjectsUpdateOptions](./kibana-plugin-server.savedobjectsupdateoptions.md) | | | [SavedObjectsUpdateResponse](./kibana-plugin-server.savedobjectsupdateresponse.md) | | | [SessionStorage](./kibana-plugin-server.sessionstorage.md) | Provides an interface to store and retrieve data across requests. | +| [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) | Configuration used to create HTTP session storage based on top of cookie mechanism. | | [SessionStorageFactory](./kibana-plugin-server.sessionstoragefactory.md) | SessionStorage factory to bind one to an incoming request | +## Variables + +| Variable | Description | +| --- | --- | +| [kibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) | Set of helpers used to create KibanaResponse to form HTTP response on an incoming request. Should be returned as a result of [RequestHandler](./kibana-plugin-server.requesthandler.md) execution. | + ## Type Aliases | Type Alias | Description | @@ -75,14 +107,22 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AuthHeaders](./kibana-plugin-server.authheaders.md) | Auth Headers map | | [ElasticsearchClientConfig](./kibana-plugin-server.elasticsearchclientconfig.md) | | | [GetAuthHeaders](./kibana-plugin-server.getauthheaders.md) | Get headers to authenticate a user against Elasticsearch. | -| [Headers](./kibana-plugin-server.headers.md) | | -| [LegacyRequest](./kibana-plugin-server.legacyrequest.md) | Support Legacy platform request for the period of migration. | +| [GetAuthState](./kibana-plugin-server.getauthstate.md) | Get authentication state for a request. Returned by auth interceptor. | +| [Headers](./kibana-plugin-server.headers.md) | Http request headers to read. | +| [HttpResponsePayload](./kibana-plugin-server.httpresponsepayload.md) | Data send to the client as a response payload. | +| [HttpServiceSetup](./kibana-plugin-server.httpservicesetup.md) | | +| [IsAuthenticated](./kibana-plugin-server.isauthenticated.md) | Return authentication status for a request. | +| [KibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) | Creates an object containing request response payload, HTTP headers, error details, and other data transmitted to the client. | +| [KnownHeaders](./kibana-plugin-server.knownheaders.md) | Set of well-known HTTP headers. | | [OnPostAuthHandler](./kibana-plugin-server.onpostauthhandler.md) | | | [OnPreAuthHandler](./kibana-plugin-server.onpreauthhandler.md) | | | [PluginInitializer](./kibana-plugin-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | | [RecursiveReadonly](./kibana-plugin-server.recursivereadonly.md) | | +| [RedirectResponseOptions](./kibana-plugin-server.redirectresponseoptions.md) | HTTP response parameters for redirection response | +| [RequestHandler](./kibana-plugin-server.requesthandler.md) | A function executed when route path matched requested resource path. Request handler is expected to return a result of one of [KibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) functions. | +| [ResponseError](./kibana-plugin-server.responseerror.md) | Error message and optional data send to the client in case of error. | | [RouteMethod](./kibana-plugin-server.routemethod.md) | The set of common HTTP methods supported by Kibana routing. | | [SavedObjectsClientContract](./kibana-plugin-server.savedobjectsclientcontract.md) | \#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.\#\#\# 503s from missing indexUnlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's action.auto_create_index setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) | -| [SavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsclientwrapperfactory.md) | | +| [SavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | diff --git a/docs/development/core/server/kibana-plugin-server.redirectresponseoptions.md b/docs/development/core/server/kibana-plugin-server.redirectresponseoptions.md new file mode 100644 index 0000000000000..6fb0a5add2fb6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.redirectresponseoptions.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RedirectResponseOptions](./kibana-plugin-server.redirectresponseoptions.md) + +## RedirectResponseOptions type + +HTTP response parameters for redirection response + +Signature: + +```typescript +export declare type RedirectResponseOptions = HttpResponseOptions & { + headers: { + location: string; + }; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-server.requesthandler.md b/docs/development/core/server/kibana-plugin-server.requesthandler.md new file mode 100644 index 0000000000000..b7e593c30f2f3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.requesthandler.md @@ -0,0 +1,42 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RequestHandler](./kibana-plugin-server.requesthandler.md) + +## RequestHandler type + +A function executed when route path matched requested resource path. Request handler is expected to return a result of one of [KibanaResponseFactory](./kibana-plugin-server.kibanaresponsefactory.md) functions. + +Signature: + +```typescript +export declare type RequestHandler

= (request: KibanaRequest, TypeOf, TypeOf>, response: KibanaResponseFactory) => KibanaResponse | Promise>; +``` + +## Example + + +```ts +const router = new Router('my-app'); +// creates a route handler for GET request on 'my-app/path/{id}' path +router.get( + { + path: 'path/{id}', + // defines a validation schema for a named segment of the route path + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + // function to execute to create a responses + async (request, response) => { + const data = await findObject(request.params.id); + // creates a command to respond with 'not found' error + if (!data) return response.notFound(); + // creates a command to send found data to the client + return response.ok(data); + } +); + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.responseerror.md b/docs/development/core/server/kibana-plugin-server.responseerror.md new file mode 100644 index 0000000000000..6aa4a4e97ff72 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.responseerror.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ResponseError](./kibana-plugin-server.responseerror.md) + +## ResponseError type + +Error message and optional data send to the client in case of error. + +Signature: + +```typescript +export declare type ResponseError = string | Error | { + message: string | Error; + meta?: ResponseErrorMeta; +}; +``` diff --git a/docs/development/core/server/kibana-plugin-server.responseerrormeta.data.md b/docs/development/core/server/kibana-plugin-server.responseerrormeta.data.md new file mode 100644 index 0000000000000..afef0c88432a4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.responseerrormeta.data.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ResponseErrorMeta](./kibana-plugin-server.responseerrormeta.md) > [data](./kibana-plugin-server.responseerrormeta.data.md) + +## ResponseErrorMeta.data property + +Signature: + +```typescript +data?: Record; +``` diff --git a/docs/development/core/server/kibana-plugin-server.responseerrormeta.doclink.md b/docs/development/core/server/kibana-plugin-server.responseerrormeta.doclink.md new file mode 100644 index 0000000000000..472cb3ef48e36 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.responseerrormeta.doclink.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ResponseErrorMeta](./kibana-plugin-server.responseerrormeta.md) > [docLink](./kibana-plugin-server.responseerrormeta.doclink.md) + +## ResponseErrorMeta.docLink property + +Signature: + +```typescript +docLink?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.responseerrormeta.errorcode.md b/docs/development/core/server/kibana-plugin-server.responseerrormeta.errorcode.md new file mode 100644 index 0000000000000..1f26f072e0b9a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.responseerrormeta.errorcode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ResponseErrorMeta](./kibana-plugin-server.responseerrormeta.md) > [errorCode](./kibana-plugin-server.responseerrormeta.errorcode.md) + +## ResponseErrorMeta.errorCode property + +Signature: + +```typescript +errorCode?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.responseerrormeta.md b/docs/development/core/server/kibana-plugin-server.responseerrormeta.md new file mode 100644 index 0000000000000..9ab351d013dd7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.responseerrormeta.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [ResponseErrorMeta](./kibana-plugin-server.responseerrormeta.md) + +## ResponseErrorMeta interface + +Additional metadata to enhance error output or provide error details. + +Signature: + +```typescript +export interface ResponseErrorMeta +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [data](./kibana-plugin-server.responseerrormeta.data.md) | Record<string, any> | | +| [docLink](./kibana-plugin-server.responseerrormeta.doclink.md) | string | | +| [errorCode](./kibana-plugin-server.responseerrormeta.errorcode.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.md b/docs/development/core/server/kibana-plugin-server.routeconfig.md new file mode 100644 index 0000000000000..87ec365dc2510 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) + +## RouteConfig interface + +Route specific configuration. + +Signature: + +```typescript +export interface RouteConfig

+``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [options](./kibana-plugin-server.routeconfig.options.md) | RouteConfigOptions | Additional route options [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md). | +| [path](./kibana-plugin-server.routeconfig.path.md) | string | The endpoint \_within\_ the router path to register the route. E.g. if the router is registered at /elasticsearch and the route path is /search, the full path for the route is /elasticsearch/search. Supports: - named path segments path/{name}. - optional path segments path/{position?}. - multi-segments path/{coordinates*2}. Segments are accessible within a handler function as params property of [KibanaRequest](./kibana-plugin-server.kibanarequest.md) object. To have read access to params you \*must\* specify validation schema with [RouteConfig.validate](./kibana-plugin-server.routeconfig.validate.md). | +| [validate](./kibana-plugin-server.routeconfig.validate.md) | RouteSchemas<P, Q, B> | false | A schema created with @kbn/config-schema that every request will be validated against. You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify false. | + diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.options.md b/docs/development/core/server/kibana-plugin-server.routeconfig.options.md new file mode 100644 index 0000000000000..12ca36da6de7c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.options.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [options](./kibana-plugin-server.routeconfig.options.md) + +## RouteConfig.options property + +Additional route options [RouteConfigOptions](./kibana-plugin-server.routeconfigoptions.md). + +Signature: + +```typescript +options?: RouteConfigOptions; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.path.md b/docs/development/core/server/kibana-plugin-server.routeconfig.path.md new file mode 100644 index 0000000000000..3437f0e0fe064 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.path.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [path](./kibana-plugin-server.routeconfig.path.md) + +## RouteConfig.path property + +The endpoint \_within\_ the router path to register the route. E.g. if the router is registered at `/elasticsearch` and the route path is `/search`, the full path for the route is `/elasticsearch/search`. Supports: - named path segments `path/{name}`. - optional path segments `path/{position?}`. - multi-segments `path/{coordinates*2}`. Segments are accessible within a handler function as `params` property of [KibanaRequest](./kibana-plugin-server.kibanarequest.md) object. To have read access to `params` you \*must\* specify validation schema with [RouteConfig.validate](./kibana-plugin-server.routeconfig.validate.md). + +Signature: + +```typescript +path: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md new file mode 100644 index 0000000000000..f7177485f5fb6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.routeconfig.validate.md @@ -0,0 +1,32 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [RouteConfig](./kibana-plugin-server.routeconfig.md) > [validate](./kibana-plugin-server.routeconfig.validate.md) + +## RouteConfig.validate property + +A schema created with `@kbn/config-schema` that every request will be validated against. You \*must\* specify a validation schema to be able to read: - url path segments - request query - request body To opt out of validating the request, specify `false`. + +Signature: + +```typescript +validate: RouteSchemas | false; +``` + +## Example + + +```ts + import { schema } from '@kbn/config-schema'; + router.get({ + path: 'path/{id}' + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({...}), + body: schema.object({...}), + }, + }) + +``` + diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md index 3fb4426c407cd..2bb2491cae5df 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.authrequired.md @@ -4,7 +4,7 @@ ## RouteConfigOptions.authRequired property -A flag shows that authentication for a route: enabled when true disabled when false +A flag shows that authentication for a route: `enabled` when true `disabled` when false Enabled by default. diff --git a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md index 97e480c5490fc..b4d210ac0b711 100644 --- a/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-server.routeconfigoptions.md @@ -4,7 +4,7 @@ ## RouteConfigOptions interface -Route specific configuration. +Additional route options. Signature: @@ -16,6 +16,6 @@ export interface RouteConfigOptions | Property | Type | Description | | --- | --- | --- | -| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | boolean | A flag shows that authentication for a route: enabled when true disabled when falseEnabled by default. | +| [authRequired](./kibana-plugin-server.routeconfigoptions.authrequired.md) | boolean | A flag shows that authentication for a route: enabled when true disabled when falseEnabled by default. | | [tags](./kibana-plugin-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | diff --git a/docs/development/core/server/kibana-plugin-server.router.(constructor).md b/docs/development/core/server/kibana-plugin-server.router.(constructor).md index 5f8e1e5e293ab..26048a603c9f6 100644 --- a/docs/development/core/server/kibana-plugin-server.router.(constructor).md +++ b/docs/development/core/server/kibana-plugin-server.router.(constructor).md @@ -16,5 +16,5 @@ constructor(path: string); | Parameter | Type | Description | | --- | --- | --- | -| path | string | | +| path | string | a router path, set as the very first path segment for all registered routes. | diff --git a/docs/development/core/server/kibana-plugin-server.router.delete.md b/docs/development/core/server/kibana-plugin-server.router.delete.md index cd49f80baaf70..565dc10ce76e8 100644 --- a/docs/development/core/server/kibana-plugin-server.router.delete.md +++ b/docs/development/core/server/kibana-plugin-server.router.delete.md @@ -4,7 +4,7 @@ ## Router.delete() method -Register a `DELETE` request with the router +Register a route handler for `DELETE` request. Signature: diff --git a/docs/development/core/server/kibana-plugin-server.router.get.md b/docs/development/core/server/kibana-plugin-server.router.get.md index ab8e7c8c5a65d..a3899eaa678f7 100644 --- a/docs/development/core/server/kibana-plugin-server.router.get.md +++ b/docs/development/core/server/kibana-plugin-server.router.get.md @@ -4,7 +4,7 @@ ## Router.get() method -Register a `GET` request with the router +Register a route handler for `GET` request. Signature: diff --git a/docs/development/core/server/kibana-plugin-server.router.getroutes.md b/docs/development/core/server/kibana-plugin-server.router.getroutes.md deleted file mode 100644 index 3e4785a3a7c6c..0000000000000 --- a/docs/development/core/server/kibana-plugin-server.router.getroutes.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [Router](./kibana-plugin-server.router.md) > [getRoutes](./kibana-plugin-server.router.getroutes.md) - -## Router.getRoutes() method - -Returns all routes registered with the this router. - -Signature: - -```typescript -getRoutes(): Readonly[]; -``` -Returns: - -`Readonly[]` - -List of registered routes. - diff --git a/docs/development/core/server/kibana-plugin-server.router.md b/docs/development/core/server/kibana-plugin-server.router.md index 52193bbc553c7..a5eb1c66c4f0c 100644 --- a/docs/development/core/server/kibana-plugin-server.router.md +++ b/docs/development/core/server/kibana-plugin-server.router.md @@ -4,6 +4,7 @@ ## Router class +Provides ability to declare a handler function for a particular path and HTTP request method. Each route can have only one handler functions, which is executed when the route is matched. Signature: @@ -28,9 +29,18 @@ export declare class Router | Method | Modifiers | Description | | --- | --- | --- | -| [delete(route, handler)](./kibana-plugin-server.router.delete.md) | | Register a DELETE request with the router | -| [get(route, handler)](./kibana-plugin-server.router.get.md) | | Register a GET request with the router | -| [getRoutes()](./kibana-plugin-server.router.getroutes.md) | | Returns all routes registered with the this router. | -| [post(route, handler)](./kibana-plugin-server.router.post.md) | | Register a POST request with the router | -| [put(route, handler)](./kibana-plugin-server.router.put.md) | | Register a PUT request with the router | +| [delete(route, handler)](./kibana-plugin-server.router.delete.md) | | Register a route handler for DELETE request. | +| [get(route, handler)](./kibana-plugin-server.router.get.md) | | Register a route handler for GET request. | +| [post(route, handler)](./kibana-plugin-server.router.post.md) | | Register a route handler for POST request. | +| [put(route, handler)](./kibana-plugin-server.router.put.md) | | Register a route handler for PUT request. | + +## Example + + +```ts +const router = new Router('my-app'); +// handler is called when 'my-app/path' resource is requested with `GET` method +router.get({ path: '/path', validate: false }, (req, res) => res.ok({ content: 'ok' })); + +``` diff --git a/docs/development/core/server/kibana-plugin-server.router.post.md b/docs/development/core/server/kibana-plugin-server.router.post.md index a499a46b1ee79..7aca35466d643 100644 --- a/docs/development/core/server/kibana-plugin-server.router.post.md +++ b/docs/development/core/server/kibana-plugin-server.router.post.md @@ -4,7 +4,7 @@ ## Router.post() method -Register a `POST` request with the router +Register a route handler for `POST` request. Signature: diff --git a/docs/development/core/server/kibana-plugin-server.router.put.md b/docs/development/core/server/kibana-plugin-server.router.put.md index 7b1337279cca9..760ccf9ef88e8 100644 --- a/docs/development/core/server/kibana-plugin-server.router.put.md +++ b/docs/development/core/server/kibana-plugin-server.router.put.md @@ -4,7 +4,7 @@ ## Router.put() method -Register a `PUT` request with the router +Register a route handler for `PUT` request. Signature: diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.excludedwrappers.md b/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.excludedwrappers.md new file mode 100644 index 0000000000000..9eb036c01e26e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.excludedwrappers.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsClientProviderOptions](./kibana-plugin-server.savedobjectsclientprovideroptions.md) > [excludedWrappers](./kibana-plugin-server.savedobjectsclientprovideroptions.excludedwrappers.md) + +## SavedObjectsClientProviderOptions.excludedWrappers property + +Signature: + +```typescript +excludedWrappers?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.md new file mode 100644 index 0000000000000..29b872a30a373 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsclientprovideroptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsClientProviderOptions](./kibana-plugin-server.savedobjectsclientprovideroptions.md) + +## SavedObjectsClientProviderOptions interface + +Options to control the creation of the Saved Objects Client. + +Signature: + +```typescript +export interface SavedObjectsClientProviderOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [excludedWrappers](./kibana-plugin-server.savedobjectsclientprovideroptions.excludedwrappers.md) | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperfactory.md b/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperfactory.md index 321aefcba0ffd..3ef2fac727b01 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperfactory.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperfactory.md @@ -4,6 +4,8 @@ ## SavedObjectsClientWrapperFactory type +Describes the factory used to create instances of Saved Objects Client Wrappers. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperoptions.md index 1a096fd9e5264..65e7cfa64c2a6 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperoptions.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsclientwrapperoptions.md @@ -4,6 +4,8 @@ ## SavedObjectsClientWrapperOptions interface +Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. + Signature: ```typescript diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.exportsizelimit.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.exportsizelimit.md new file mode 100644 index 0000000000000..d8ff7b4c9e2ed --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.exportsizelimit.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [exportSizeLimit](./kibana-plugin-server.savedobjectsexportoptions.exportsizelimit.md) + +## SavedObjectsExportOptions.exportSizeLimit property + +Signature: + +```typescript +exportSizeLimit: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.includereferencesdeep.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.includereferencesdeep.md new file mode 100644 index 0000000000000..1972cc6634b75 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.includereferencesdeep.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [includeReferencesDeep](./kibana-plugin-server.savedobjectsexportoptions.includereferencesdeep.md) + +## SavedObjectsExportOptions.includeReferencesDeep property + +Signature: + +```typescript +includeReferencesDeep?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.md new file mode 100644 index 0000000000000..66f501a0f1433 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) + +## SavedObjectsExportOptions interface + +Options controlling the export operation. + +Signature: + +```typescript +export interface SavedObjectsExportOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [exportSizeLimit](./kibana-plugin-server.savedobjectsexportoptions.exportsizelimit.md) | number | | +| [includeReferencesDeep](./kibana-plugin-server.savedobjectsexportoptions.includereferencesdeep.md) | boolean | | +| [namespace](./kibana-plugin-server.savedobjectsexportoptions.namespace.md) | string | | +| [objects](./kibana-plugin-server.savedobjectsexportoptions.objects.md) | Array<{
id: string;
type: string;
}> | | +| [savedObjectsClient](./kibana-plugin-server.savedobjectsexportoptions.savedobjectsclient.md) | SavedObjectsClientContract | | +| [types](./kibana-plugin-server.savedobjectsexportoptions.types.md) | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.namespace.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.namespace.md new file mode 100644 index 0000000000000..b5abfba7f6910 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.namespace.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [namespace](./kibana-plugin-server.savedobjectsexportoptions.namespace.md) + +## SavedObjectsExportOptions.namespace property + +Signature: + +```typescript +namespace?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.objects.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.objects.md new file mode 100644 index 0000000000000..46cb62841d46c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.objects.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [objects](./kibana-plugin-server.savedobjectsexportoptions.objects.md) + +## SavedObjectsExportOptions.objects property + +Signature: + +```typescript +objects?: Array<{ + id: string; + type: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.savedobjectsclient.md new file mode 100644 index 0000000000000..fc206d0f7e877 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.savedobjectsclient.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [savedObjectsClient](./kibana-plugin-server.savedobjectsexportoptions.savedobjectsclient.md) + +## SavedObjectsExportOptions.savedObjectsClient property + +Signature: + +```typescript +savedObjectsClient: SavedObjectsClientContract; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.types.md b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.types.md new file mode 100644 index 0000000000000..204402fe355e3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsexportoptions.types.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsExportOptions](./kibana-plugin-server.savedobjectsexportoptions.md) > [types](./kibana-plugin-server.savedobjectsexportoptions.types.md) + +## SavedObjectsExportOptions.types property + +Signature: + +```typescript +types?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.md new file mode 100644 index 0000000000000..485500da504a9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportConflictError](./kibana-plugin-server.savedobjectsimportconflicterror.md) + +## SavedObjectsImportConflictError interface + +Represents a failure to import due to a conflict. + +Signature: + +```typescript +export interface SavedObjectsImportConflictError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-server.savedobjectsimportconflicterror.type.md) | 'conflict' | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.type.md new file mode 100644 index 0000000000000..bd85de140674a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportconflicterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportConflictError](./kibana-plugin-server.savedobjectsimportconflicterror.md) > [type](./kibana-plugin-server.savedobjectsimportconflicterror.type.md) + +## SavedObjectsImportConflictError.type property + +Signature: + +```typescript +type: 'conflict'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.error.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.error.md new file mode 100644 index 0000000000000..0828ca9e01c34 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.error.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) > [error](./kibana-plugin-server.savedobjectsimporterror.error.md) + +## SavedObjectsImportError.error property + +Signature: + +```typescript +error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.id.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.id.md new file mode 100644 index 0000000000000..0791d668f4626 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) > [id](./kibana-plugin-server.savedobjectsimporterror.id.md) + +## SavedObjectsImportError.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.md new file mode 100644 index 0000000000000..0d734c21c3151 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) + +## SavedObjectsImportError interface + +Represents a failure to import. + +Signature: + +```typescript +export interface SavedObjectsImportError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-server.savedobjectsimporterror.error.md) | SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError | | +| [id](./kibana-plugin-server.savedobjectsimporterror.id.md) | string | | +| [title](./kibana-plugin-server.savedobjectsimporterror.title.md) | string | | +| [type](./kibana-plugin-server.savedobjectsimporterror.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.title.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.title.md new file mode 100644 index 0000000000000..bd0beeb4d79dd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.title.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) > [title](./kibana-plugin-server.savedobjectsimporterror.title.md) + +## SavedObjectsImportError.title property + +Signature: + +```typescript +title?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.type.md new file mode 100644 index 0000000000000..0b48cc4bbaecf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimporterror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportError](./kibana-plugin-server.savedobjectsimporterror.md) > [type](./kibana-plugin-server.savedobjectsimporterror.type.md) + +## SavedObjectsImportError.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.blocking.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.blocking.md new file mode 100644 index 0000000000000..bbbd499ea5844 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.blocking.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.md) > [blocking](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.blocking.md) + +## SavedObjectsImportMissingReferencesError.blocking property + +Signature: + +```typescript +blocking: Array<{ + type: string; + id: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.md new file mode 100644 index 0000000000000..fb4e997fe17ba --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.md) + +## SavedObjectsImportMissingReferencesError interface + +Represents a failure to import due to missing references. + +Signature: + +```typescript +export interface SavedObjectsImportMissingReferencesError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [blocking](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.blocking.md) | Array<{
type: string;
id: string;
}> | | +| [references](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.references.md) | Array<{
type: string;
id: string;
}> | | +| [type](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.type.md) | 'missing_references' | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.references.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.references.md new file mode 100644 index 0000000000000..593d2b48d456c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.references.md @@ -0,0 +1,14 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.md) > [references](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.references.md) + +## SavedObjectsImportMissingReferencesError.references property + +Signature: + +```typescript +references: Array<{ + type: string; + id: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.type.md new file mode 100644 index 0000000000000..3e6e80f77c447 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportmissingreferenceserror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportMissingReferencesError](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.md) > [type](./kibana-plugin-server.savedobjectsimportmissingreferenceserror.type.md) + +## SavedObjectsImportMissingReferencesError.type property + +Signature: + +```typescript +type: 'missing_references'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.md new file mode 100644 index 0000000000000..a1ea33e11b14f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) + +## SavedObjectsImportOptions interface + +Options to control the import operation. + +Signature: + +```typescript +export interface SavedObjectsImportOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [namespace](./kibana-plugin-server.savedobjectsimportoptions.namespace.md) | string | | +| [objectLimit](./kibana-plugin-server.savedobjectsimportoptions.objectlimit.md) | number | | +| [overwrite](./kibana-plugin-server.savedobjectsimportoptions.overwrite.md) | boolean | | +| [readStream](./kibana-plugin-server.savedobjectsimportoptions.readstream.md) | Readable | | +| [savedObjectsClient](./kibana-plugin-server.savedobjectsimportoptions.savedobjectsclient.md) | SavedObjectsClientContract | | +| [supportedTypes](./kibana-plugin-server.savedobjectsimportoptions.supportedtypes.md) | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.namespace.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.namespace.md new file mode 100644 index 0000000000000..600a4dc1176f3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.namespace.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [namespace](./kibana-plugin-server.savedobjectsimportoptions.namespace.md) + +## SavedObjectsImportOptions.namespace property + +Signature: + +```typescript +namespace?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.objectlimit.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.objectlimit.md new file mode 100644 index 0000000000000..bb040a560b89b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.objectlimit.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [objectLimit](./kibana-plugin-server.savedobjectsimportoptions.objectlimit.md) + +## SavedObjectsImportOptions.objectLimit property + +Signature: + +```typescript +objectLimit: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.overwrite.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.overwrite.md new file mode 100644 index 0000000000000..4586a93568588 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.overwrite.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [overwrite](./kibana-plugin-server.savedobjectsimportoptions.overwrite.md) + +## SavedObjectsImportOptions.overwrite property + +Signature: + +```typescript +overwrite: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.readstream.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.readstream.md new file mode 100644 index 0000000000000..4b54f931797cf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.readstream.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [readStream](./kibana-plugin-server.savedobjectsimportoptions.readstream.md) + +## SavedObjectsImportOptions.readStream property + +Signature: + +```typescript +readStream: Readable; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.savedobjectsclient.md new file mode 100644 index 0000000000000..f0d439aedc005 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.savedobjectsclient.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [savedObjectsClient](./kibana-plugin-server.savedobjectsimportoptions.savedobjectsclient.md) + +## SavedObjectsImportOptions.savedObjectsClient property + +Signature: + +```typescript +savedObjectsClient: SavedObjectsClientContract; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.supportedtypes.md new file mode 100644 index 0000000000000..0359c53d8fcf1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportoptions.supportedtypes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportOptions](./kibana-plugin-server.savedobjectsimportoptions.md) > [supportedTypes](./kibana-plugin-server.savedobjectsimportoptions.supportedtypes.md) + +## SavedObjectsImportOptions.supportedTypes property + +Signature: + +```typescript +supportedTypes: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.errors.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.errors.md new file mode 100644 index 0000000000000..c59390c6d45e0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.errors.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportResponse](./kibana-plugin-server.savedobjectsimportresponse.md) > [errors](./kibana-plugin-server.savedobjectsimportresponse.errors.md) + +## SavedObjectsImportResponse.errors property + +Signature: + +```typescript +errors?: SavedObjectsImportError[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.md new file mode 100644 index 0000000000000..23f6526dc6367 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportResponse](./kibana-plugin-server.savedobjectsimportresponse.md) + +## SavedObjectsImportResponse interface + +The response describing the result of an import. + +Signature: + +```typescript +export interface SavedObjectsImportResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [errors](./kibana-plugin-server.savedobjectsimportresponse.errors.md) | SavedObjectsImportError[] | | +| [success](./kibana-plugin-server.savedobjectsimportresponse.success.md) | boolean | | +| [successCount](./kibana-plugin-server.savedobjectsimportresponse.successcount.md) | number | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.success.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.success.md new file mode 100644 index 0000000000000..5fd76959c556c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.success.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportResponse](./kibana-plugin-server.savedobjectsimportresponse.md) > [success](./kibana-plugin-server.savedobjectsimportresponse.success.md) + +## SavedObjectsImportResponse.success property + +Signature: + +```typescript +success: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.successcount.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.successcount.md new file mode 100644 index 0000000000000..4b49f57e8367d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportresponse.successcount.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportResponse](./kibana-plugin-server.savedobjectsimportresponse.md) > [successCount](./kibana-plugin-server.savedobjectsimportresponse.successcount.md) + +## SavedObjectsImportResponse.successCount property + +Signature: + +```typescript +successCount: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.id.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.id.md new file mode 100644 index 0000000000000..568185b2438b7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) > [id](./kibana-plugin-server.savedobjectsimportretry.id.md) + +## SavedObjectsImportRetry.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.md new file mode 100644 index 0000000000000..dc842afbf9f29 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) + +## SavedObjectsImportRetry interface + +Describes a retry operation for importing a saved object. + +Signature: + +```typescript +export interface SavedObjectsImportRetry +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-server.savedobjectsimportretry.id.md) | string | | +| [overwrite](./kibana-plugin-server.savedobjectsimportretry.overwrite.md) | boolean | | +| [replaceReferences](./kibana-plugin-server.savedobjectsimportretry.replacereferences.md) | Array<{
type: string;
from: string;
to: string;
}> | | +| [type](./kibana-plugin-server.savedobjectsimportretry.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.overwrite.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.overwrite.md new file mode 100644 index 0000000000000..36a31e836aebc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.overwrite.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) > [overwrite](./kibana-plugin-server.savedobjectsimportretry.overwrite.md) + +## SavedObjectsImportRetry.overwrite property + +Signature: + +```typescript +overwrite: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.replacereferences.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.replacereferences.md new file mode 100644 index 0000000000000..c3439bb554e13 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.replacereferences.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) > [replaceReferences](./kibana-plugin-server.savedobjectsimportretry.replacereferences.md) + +## SavedObjectsImportRetry.replaceReferences property + +Signature: + +```typescript +replaceReferences: Array<{ + type: string; + from: string; + to: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.type.md new file mode 100644 index 0000000000000..8f0408dcbc11e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportretry.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportRetry](./kibana-plugin-server.savedobjectsimportretry.md) > [type](./kibana-plugin-server.savedobjectsimportretry.type.md) + +## SavedObjectsImportRetry.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.md new file mode 100644 index 0000000000000..913038c5bc67d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnknownError](./kibana-plugin-server.savedobjectsimportunknownerror.md) + +## SavedObjectsImportUnknownError interface + +Represents a failure to import due to an unknown reason. + +Signature: + +```typescript +export interface SavedObjectsImportUnknownError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [message](./kibana-plugin-server.savedobjectsimportunknownerror.message.md) | string | | +| [statusCode](./kibana-plugin-server.savedobjectsimportunknownerror.statuscode.md) | number | | +| [type](./kibana-plugin-server.savedobjectsimportunknownerror.type.md) | 'unknown' | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.message.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.message.md new file mode 100644 index 0000000000000..96b8b98bf6a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.message.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnknownError](./kibana-plugin-server.savedobjectsimportunknownerror.md) > [message](./kibana-plugin-server.savedobjectsimportunknownerror.message.md) + +## SavedObjectsImportUnknownError.message property + +Signature: + +```typescript +message: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.statuscode.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.statuscode.md new file mode 100644 index 0000000000000..9cdef84ff4ea7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.statuscode.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnknownError](./kibana-plugin-server.savedobjectsimportunknownerror.md) > [statusCode](./kibana-plugin-server.savedobjectsimportunknownerror.statuscode.md) + +## SavedObjectsImportUnknownError.statusCode property + +Signature: + +```typescript +statusCode: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.type.md new file mode 100644 index 0000000000000..cf31166157ab7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunknownerror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnknownError](./kibana-plugin-server.savedobjectsimportunknownerror.md) > [type](./kibana-plugin-server.savedobjectsimportunknownerror.type.md) + +## SavedObjectsImportUnknownError.type property + +Signature: + +```typescript +type: 'unknown'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md new file mode 100644 index 0000000000000..cc775b20bb8f2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md) + +## SavedObjectsImportUnsupportedTypeError interface + +Represents a failure to import due to having an unsupported saved object type. + +Signature: + +```typescript +export interface SavedObjectsImportUnsupportedTypeError +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [type](./kibana-plugin-server.savedobjectsimportunsupportedtypeerror.type.md) | 'unsupported_type' | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.type.md new file mode 100644 index 0000000000000..ae69911020c18 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsimportunsupportedtypeerror.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-server.savedobjectsimportunsupportedtypeerror.md) > [type](./kibana-plugin-server.savedobjectsimportunsupportedtypeerror.type.md) + +## SavedObjectsImportUnsupportedTypeError.type property + +Signature: + +```typescript +type: 'unsupported_type'; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._id.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._id.md new file mode 100644 index 0000000000000..cd16eadf51931 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) > [\_id](./kibana-plugin-server.savedobjectsrawdoc._id.md) + +## SavedObjectsRawDoc.\_id property + +Signature: + +```typescript +_id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._primary_term.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._primary_term.md new file mode 100644 index 0000000000000..c5eef82322f58 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._primary_term.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) > [\_primary\_term](./kibana-plugin-server.savedobjectsrawdoc._primary_term.md) + +## SavedObjectsRawDoc.\_primary\_term property + +Signature: + +```typescript +_primary_term?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._seq_no.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._seq_no.md new file mode 100644 index 0000000000000..a3b9a943a708c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._seq_no.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) > [\_seq\_no](./kibana-plugin-server.savedobjectsrawdoc._seq_no.md) + +## SavedObjectsRawDoc.\_seq\_no property + +Signature: + +```typescript +_seq_no?: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._source.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._source.md new file mode 100644 index 0000000000000..1babaab14f14d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._source.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) > [\_source](./kibana-plugin-server.savedobjectsrawdoc._source.md) + +## SavedObjectsRawDoc.\_source property + +Signature: + +```typescript +_source: any; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._type.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._type.md new file mode 100644 index 0000000000000..31c40e15b53c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc._type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) > [\_type](./kibana-plugin-server.savedobjectsrawdoc._type.md) + +## SavedObjectsRawDoc.\_type property + +Signature: + +```typescript +_type?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc.md b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc.md new file mode 100644 index 0000000000000..5864a85465396 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsrawdoc.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsRawDoc](./kibana-plugin-server.savedobjectsrawdoc.md) + +## SavedObjectsRawDoc interface + +A raw document as represented directly in the saved object index. + +Signature: + +```typescript +export interface RawDoc +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [\_id](./kibana-plugin-server.savedobjectsrawdoc._id.md) | string | | +| [\_primary\_term](./kibana-plugin-server.savedobjectsrawdoc._primary_term.md) | number | | +| [\_seq\_no](./kibana-plugin-server.savedobjectsrawdoc._seq_no.md) | number | | +| [\_source](./kibana-plugin-server.savedobjectsrawdoc._source.md) | any | | +| [\_type](./kibana-plugin-server.savedobjectsrawdoc._type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md new file mode 100644 index 0000000000000..e3542714d96bb --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) + +## SavedObjectsResolveImportErrorsOptions interface + +Options to control the "resolve import" operation. + +Signature: + +```typescript +export interface SavedObjectsResolveImportErrorsOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [namespace](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.namespace.md) | string | | +| [objectLimit](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.objectlimit.md) | number | | +| [readStream](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.readstream.md) | Readable | | +| [retries](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.retries.md) | SavedObjectsImportRetry[] | | +| [savedObjectsClient](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md) | SavedObjectsClientContract | | +| [supportedTypes](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) | string[] | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.namespace.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.namespace.md new file mode 100644 index 0000000000000..6175e75a4bbec --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.namespace.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [namespace](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.namespace.md) + +## SavedObjectsResolveImportErrorsOptions.namespace property + +Signature: + +```typescript +namespace?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.objectlimit.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.objectlimit.md new file mode 100644 index 0000000000000..d5616851e1386 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.objectlimit.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [objectLimit](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.objectlimit.md) + +## SavedObjectsResolveImportErrorsOptions.objectLimit property + +Signature: + +```typescript +objectLimit: number; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.readstream.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.readstream.md new file mode 100644 index 0000000000000..e4b5d92d7b05a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.readstream.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [readStream](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.readstream.md) + +## SavedObjectsResolveImportErrorsOptions.readStream property + +Signature: + +```typescript +readStream: Readable; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.retries.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.retries.md new file mode 100644 index 0000000000000..7dc825f762fe9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.retries.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [retries](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.retries.md) + +## SavedObjectsResolveImportErrorsOptions.retries property + +Signature: + +```typescript +retries: SavedObjectsImportRetry[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md new file mode 100644 index 0000000000000..ae5edc98d3a9e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [savedObjectsClient](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.savedobjectsclient.md) + +## SavedObjectsResolveImportErrorsOptions.savedObjectsClient property + +Signature: + +```typescript +savedObjectsClient: SavedObjectsClientContract; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md new file mode 100644 index 0000000000000..5a92a8d0a9936 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsResolveImportErrorsOptions](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.md) > [supportedTypes](./kibana-plugin-server.savedobjectsresolveimporterrorsoptions.supportedtypes.md) + +## SavedObjectsResolveImportErrorsOptions.supportedTypes property + +Signature: + +```typescript +supportedTypes: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsschema.(constructor).md b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.(constructor).md new file mode 100644 index 0000000000000..abac3bc88fac1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.(constructor).md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) > [(constructor)](./kibana-plugin-server.savedobjectsschema.(constructor).md) + +## SavedObjectsSchema.(constructor) + +Constructs a new instance of the `SavedObjectsSchema` class + +Signature: + +```typescript +constructor(schemaDefinition?: SavedObjectsSchemaDefinition); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| schemaDefinition | SavedObjectsSchemaDefinition | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsschema.getindexfortype.md b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.getindexfortype.md new file mode 100644 index 0000000000000..3c9b810cfe1a6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.getindexfortype.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) > [getIndexForType](./kibana-plugin-server.savedobjectsschema.getindexfortype.md) + +## SavedObjectsSchema.getIndexForType() method + +Signature: + +```typescript +getIndexForType(type: string): string | undefined; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`string | undefined` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsschema.ishiddentype.md b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.ishiddentype.md new file mode 100644 index 0000000000000..f67b12a4d14c3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.ishiddentype.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) > [isHiddenType](./kibana-plugin-server.savedobjectsschema.ishiddentype.md) + +## SavedObjectsSchema.isHiddenType() method + +Signature: + +```typescript +isHiddenType(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsschema.isnamespaceagnostic.md b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.isnamespaceagnostic.md new file mode 100644 index 0000000000000..2ca0abd7e4aa7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.isnamespaceagnostic.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) > [isNamespaceAgnostic](./kibana-plugin-server.savedobjectsschema.isnamespaceagnostic.md) + +## SavedObjectsSchema.isNamespaceAgnostic() method + +Signature: + +```typescript +isNamespaceAgnostic(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsschema.md b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.md new file mode 100644 index 0000000000000..1b9cb2ad94c22 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsschema.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSchema](./kibana-plugin-server.savedobjectsschema.md) + +## SavedObjectsSchema class + +Signature: + +```typescript +export declare class SavedObjectsSchema +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(schemaDefinition)](./kibana-plugin-server.savedobjectsschema.(constructor).md) | | Constructs a new instance of the SavedObjectsSchema class | + +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [getIndexForType(type)](./kibana-plugin-server.savedobjectsschema.getindexfortype.md) | | | +| [isHiddenType(type)](./kibana-plugin-server.savedobjectsschema.ishiddentype.md) | | | +| [isNamespaceAgnostic(type)](./kibana-plugin-server.savedobjectsschema.isnamespaceagnostic.md) | | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.(constructor).md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.(constructor).md new file mode 100644 index 0000000000000..6524ff3e17caf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.(constructor).md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) > [(constructor)](./kibana-plugin-server.savedobjectsserializer.(constructor).md) + +## SavedObjectsSerializer.(constructor) + +Constructs a new instance of the `SavedObjectsSerializer` class + +Signature: + +```typescript +constructor(schema: SavedObjectsSchema); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| schema | SavedObjectsSchema | | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.generaterawid.md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.generaterawid.md new file mode 100644 index 0000000000000..4705f48a201ae --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.generaterawid.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) > [generateRawId](./kibana-plugin-server.savedobjectsserializer.generaterawid.md) + +## SavedObjectsSerializer.generateRawId() method + +Given a saved object type and id, generates the compound id that is stored in the raw document. + +Signature: + +```typescript +generateRawId(namespace: string | undefined, type: string, id?: string): string; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| namespace | string | undefined | | +| type | string | | +| id | string | | + +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.israwsavedobject.md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.israwsavedobject.md new file mode 100644 index 0000000000000..e190e7bce8c01 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.israwsavedobject.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) > [isRawSavedObject](./kibana-plugin-server.savedobjectsserializer.israwsavedobject.md) + +## SavedObjectsSerializer.isRawSavedObject() method + +Determines whether or not the raw document can be converted to a saved object. + +Signature: + +```typescript +isRawSavedObject(rawDoc: RawDoc): any; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| rawDoc | RawDoc | | + +Returns: + +`any` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.md new file mode 100644 index 0000000000000..205e29cb0727d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) + +## SavedObjectsSerializer class + +Signature: + +```typescript +export declare class SavedObjectsSerializer +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(schema)](./kibana-plugin-server.savedobjectsserializer.(constructor).md) | | Constructs a new instance of the SavedObjectsSerializer class | + +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [generateRawId(namespace, type, id)](./kibana-plugin-server.savedobjectsserializer.generaterawid.md) | | Given a saved object type and id, generates the compound id that is stored in the raw document. | +| [isRawSavedObject(rawDoc)](./kibana-plugin-server.savedobjectsserializer.israwsavedobject.md) | | Determines whether or not the raw document can be converted to a saved object. | +| [rawToSavedObject(doc)](./kibana-plugin-server.savedobjectsserializer.rawtosavedobject.md) | | Converts a document from the format that is stored in elasticsearch to the saved object client format. | +| [savedObjectToRaw(savedObj)](./kibana-plugin-server.savedobjectsserializer.savedobjecttoraw.md) | | Converts a document from the saved object client format to the format that is stored in elasticsearch. | + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.rawtosavedobject.md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.rawtosavedobject.md new file mode 100644 index 0000000000000..b36cdb3be64da --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.rawtosavedobject.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) > [rawToSavedObject](./kibana-plugin-server.savedobjectsserializer.rawtosavedobject.md) + +## SavedObjectsSerializer.rawToSavedObject() method + +Converts a document from the format that is stored in elasticsearch to the saved object client format. + +Signature: + +```typescript +rawToSavedObject(doc: RawDoc): SanitizedSavedObjectDoc; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| doc | RawDoc | | + +Returns: + +`SanitizedSavedObjectDoc` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.savedobjecttoraw.md b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.savedobjecttoraw.md new file mode 100644 index 0000000000000..4854a97a845b8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsserializer.savedobjecttoraw.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsSerializer](./kibana-plugin-server.savedobjectsserializer.md) > [savedObjectToRaw](./kibana-plugin-server.savedobjectsserializer.savedobjecttoraw.md) + +## SavedObjectsSerializer.savedObjectToRaw() method + +Converts a document from the saved object client format to the format that is stored in elasticsearch. + +Signature: + +```typescript +savedObjectToRaw(savedObj: SanitizedSavedObjectDoc): RawDoc; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| savedObj | SanitizedSavedObjectDoc | | + +Returns: + +`RawDoc` + diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsservice.importexport.md b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.importexport.md new file mode 100644 index 0000000000000..f9b4e46712f4a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.importexport.md @@ -0,0 +1,16 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsService](./kibana-plugin-server.savedobjectsservice.md) > [importExport](./kibana-plugin-server.savedobjectsservice.importexport.md) + +## SavedObjectsService.importExport property + +Signature: + +```typescript +importExport: { + objectLimit: number; + importSavedObjects(options: SavedObjectsImportOptions): Promise; + resolveImportErrors(options: SavedObjectsResolveImportErrorsOptions): Promise; + getSortedObjectsForExport(options: SavedObjectsExportOptions): Promise; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsservice.md b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.md index ad281002854b3..d9e23e6f15928 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsservice.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.md @@ -17,7 +17,9 @@ export interface SavedObjectsService | --- | --- | --- | | [addScopedSavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsservice.addscopedsavedobjectsclientwrapperfactory.md) | ScopedSavedObjectsClientProvider<Request>['addClientWrapperFactory'] | | | [getScopedSavedObjectsClient](./kibana-plugin-server.savedobjectsservice.getscopedsavedobjectsclient.md) | ScopedSavedObjectsClientProvider<Request>['getClient'] | | +| [importExport](./kibana-plugin-server.savedobjectsservice.importexport.md) | {
objectLimit: number;
importSavedObjects(options: SavedObjectsImportOptions): Promise<SavedObjectsImportResponse>;
resolveImportErrors(options: SavedObjectsResolveImportErrorsOptions): Promise<SavedObjectsImportResponse>;
getSortedObjectsForExport(options: SavedObjectsExportOptions): Promise<Readable>;
} | | | [SavedObjectsClient](./kibana-plugin-server.savedobjectsservice.savedobjectsclient.md) | typeof SavedObjectsClient | | +| [schema](./kibana-plugin-server.savedobjectsservice.schema.md) | SavedObjectsSchema | | | [types](./kibana-plugin-server.savedobjectsservice.types.md) | string[] | | ## Methods diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsservice.schema.md b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.schema.md new file mode 100644 index 0000000000000..be5682e6f034e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsservice.schema.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsService](./kibana-plugin-server.savedobjectsservice.md) > [schema](./kibana-plugin-server.savedobjectsservice.schema.md) + +## SavedObjectsService.schema property + +Signature: + +```typescript +schema: SavedObjectsSchema; +``` diff --git a/docs/development/core/server/kibana-plugin-server.scopedclusterclient.(constructor).md b/docs/development/core/server/kibana-plugin-server.scopedclusterclient.(constructor).md index 94b49e43a113c..0fea07320b2f9 100644 --- a/docs/development/core/server/kibana-plugin-server.scopedclusterclient.(constructor).md +++ b/docs/development/core/server/kibana-plugin-server.scopedclusterclient.(constructor).md @@ -9,7 +9,7 @@ Constructs a new instance of the `ScopedClusterClient` class Signature: ```typescript -constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Record | undefined); +constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Headers | undefined); ``` ## Parameters @@ -18,5 +18,5 @@ constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: | --- | --- | --- | | internalAPICaller | APICaller | | | scopedAPICaller | APICaller | | -| headers | Record<string, string | string[] | undefined> | undefined | | +| headers | Headers | undefined | | diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.encryptionkey.md b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.encryptionkey.md new file mode 100644 index 0000000000000..167ab03d7567f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.encryptionkey.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) > [encryptionKey](./kibana-plugin-server.sessionstoragecookieoptions.encryptionkey.md) + +## SessionStorageCookieOptions.encryptionKey property + +A key used to encrypt a cookie value. Should be at least 32 characters long. + +Signature: + +```typescript +encryptionKey: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.issecure.md b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.issecure.md new file mode 100644 index 0000000000000..824fc9d136a3f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.issecure.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) > [isSecure](./kibana-plugin-server.sessionstoragecookieoptions.issecure.md) + +## SessionStorageCookieOptions.isSecure property + +Flag indicating whether the cookie should be sent only via a secure connection. + +Signature: + +```typescript +isSecure: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.md b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.md new file mode 100644 index 0000000000000..de412818142f2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) + +## SessionStorageCookieOptions interface + +Configuration used to create HTTP session storage based on top of cookie mechanism. + +Signature: + +```typescript +export interface SessionStorageCookieOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [encryptionKey](./kibana-plugin-server.sessionstoragecookieoptions.encryptionkey.md) | string | A key used to encrypt a cookie value. Should be at least 32 characters long. | +| [isSecure](./kibana-plugin-server.sessionstoragecookieoptions.issecure.md) | boolean | Flag indicating whether the cookie should be sent only via a secure connection. | +| [name](./kibana-plugin-server.sessionstoragecookieoptions.name.md) | string | Name of the session cookie. | +| [validate](./kibana-plugin-server.sessionstoragecookieoptions.validate.md) | (sessionValue: T) => boolean | Promise<boolean> | Function called to validate a cookie content. | + diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.name.md b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.name.md new file mode 100644 index 0000000000000..e6bc7ea3fe00f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.name.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) > [name](./kibana-plugin-server.sessionstoragecookieoptions.name.md) + +## SessionStorageCookieOptions.name property + +Name of the session cookie. + +Signature: + +```typescript +name: string; +``` diff --git a/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.validate.md b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.validate.md new file mode 100644 index 0000000000000..f3cbfc0d84e18 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.sessionstoragecookieoptions.validate.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SessionStorageCookieOptions](./kibana-plugin-server.sessionstoragecookieoptions.md) > [validate](./kibana-plugin-server.sessionstoragecookieoptions.validate.md) + +## SessionStorageCookieOptions.validate property + +Function called to validate a cookie content. + +Signature: + +```typescript +validate: (sessionValue: T) => boolean | Promise; +``` diff --git a/docs/getting-started.asciidoc b/docs/getting-started.asciidoc index 80ccd4eadc23d..c1b31a5153d84 100644 --- a/docs/getting-started.asciidoc +++ b/docs/getting-started.asciidoc @@ -4,46 +4,56 @@ [partintro] -- -Ready to get some hands-on experience with {kib}? There are two ways to start: +You’re new to Kibana and want to give it a try. {kib} has sample data sets and +tutorials to help you get started. -* <> -+ -Load the Flights sample data and dashboard with one click and start -interacting with {kib} visualizations in seconds. +[float] +=== Sample data -* <> -+ -Manually load a data set and build your own visualizations and dashboard. +You can use the <> to take {kib} for a test ride without having +to go through the process of loading data yourself. With one click, +you can install a sample data set and start interacting with +{kib} visualizations in seconds. You can access the sample data +from the {kib} home page. -Before you begin, make sure you've <> and established -a {kibana-ref}/connect-to-elasticsearch.html[connection to Elasticsearch]. -You might also be interested in the -https://www.elastic.co/webinars/getting-started-kibana[Getting Started with Kibana] -video tutorial. +[float] -If you are running our https://cloud.elastic.co[hosted Elasticsearch Service] -on Elastic Cloud, you can access Kibana with a single click. --- +=== Add data tutorials +{kib} has built-in *Add Data* tutorials to help you set up +data flows in the Elastic Stack. These tutorials are available +from the Kibana home page. In *Add Data to Kibana*, find the data type +you’re interested in, and click its button to view a list of available tutorials. -include::getting-started/add-sample-data.asciidoc[] +[float] +=== Hands-on experience -include::getting-started/tutorial-sample-data.asciidoc[] +The following tutorials walk you through searching, analyzing, +and visualizing data. -include::getting-started/tutorial-sample-filter.asciidoc[] +* <>. You'll +learn to filter and query data, edit visualizations, and interact with dashboards. -include::getting-started/tutorial-sample-query.asciidoc[] +* <>. You'll manually load a data set and build +your own visualizations and dashboard. -include::getting-started/tutorial-sample-discover.asciidoc[] +[float] +=== Before you begin -include::getting-started/tutorial-sample-edit.asciidoc[] +Make sure you've <> and established +a <>. -include::getting-started/tutorial-sample-inspect.asciidoc[] +If you are running our https://cloud.elastic.co[hosted Elasticsearch Service] +on Elastic Cloud, you can access Kibana with a single click. -include::getting-started/tutorial-sample-remove.asciidoc[] -include::getting-started/tutorial-full-experience.asciidoc[] +-- + +include::getting-started/add-sample-data.asciidoc[] -include::getting-started/tutorial-load-dataset.asciidoc[] +include::getting-started/tutorial-sample-data.asciidoc[] + +include::getting-started/tutorial-full-experience.asciidoc[] include::getting-started/tutorial-define-index.asciidoc[] @@ -53,6 +63,3 @@ include::getting-started/tutorial-visualizing.asciidoc[] include::getting-started/tutorial-dashboard.asciidoc[] -include::getting-started/tutorial-inspect.asciidoc[] - -include::getting-started/wrapping-up.asciidoc[] diff --git a/docs/getting-started/add-sample-data.asciidoc b/docs/getting-started/add-sample-data.asciidoc index 341410989b923..ab43431601888 100644 --- a/docs/getting-started/add-sample-data.asciidoc +++ b/docs/getting-started/add-sample-data.asciidoc @@ -1,32 +1,28 @@ [[add-sample-data]] -== Get up and running with sample data +== Add sample data {kib} has several sample data sets that you can use to explore {kib} before loading your own data. -Sample data sets install prepackaged visualizations, dashboards, -{kibana-ref}/canvas-getting-started.html[Canvas workpads], -and {kibana-ref}/maps.html[Maps]. - -The sample data sets showcase a variety of use cases: +These sample data sets showcase a variety of use cases: * *eCommerce orders* includes visualizations for product-related information, such as cost, revenue, and price. +* *Flight data* enables you to view and interact with flight routes. * *Web logs* lets you analyze website traffic. -* *Flight data* enables you to view and interact with flight routes for four airlines. - -To get started, go to the home page and click the link next to *Add sample data*. - -Once you have loaded a data set, click *View data* to view visualizations in *Dashboard*. -*Note:* The timestamps in the sample data sets are relative to when they are installed. -If you uninstall and reinstall a data set, the timestamps will change to reflect the most recent installation. +To get started, go to the {kib} home page and click the link underneath *Add sample data*. +Once you've loaded a data set, click *View data* to view prepackaged +visualizations, dashboards, Canvas workpads, Maps, and Machine Learning jobs. [role="screenshot"] image::images/add-sample-data.png[] +NOTE: The timestamps in the sample data sets are relative to when they are installed. +If you uninstall and reinstall a data set, the timestamps will change to reflect the most recent installation. + [float] -==== Next steps +=== Next steps -Play with the sample flight data in the {kibana-ref}/tutorial-sample-data.html[flight dashboard tutorial]. +* Explore {kib} by following the <>. -Learn how to load data, define index patterns and build visualizations by {kibana-ref}/tutorial-build-dashboard.html[building your own dashboard]. +* Learn how to load data, define index patterns, and build visualizations by <>. diff --git a/docs/getting-started/tutorial-dashboard.asciidoc b/docs/getting-started/tutorial-dashboard.asciidoc index 5d1d923e6664c..aab93eb51ca23 100644 --- a/docs/getting-started/tutorial-dashboard.asciidoc +++ b/docs/getting-started/tutorial-dashboard.asciidoc @@ -1,27 +1,57 @@ [[tutorial-dashboard]] -=== Displaying your visualizations in a dashboard +=== Add visualizations to a dashboard A dashboard is a collection of visualizations that you can arrange and share. You'll build a dashboard that contains the visualizations you saved during this tutorial. . Open *Dashboard*. -. Click *Create new dashboard*. -. Click *Add*. +. On the Dashboard overview page, click *Create new dashboard*. +. Click *Add* in the menu bar. . Add *Bar Example*, *Map Example*, *Markdown Example*, and *Pie Example*. - - -Your sample dashboard look like this: - ++ +Your sample dashboard should look like this: ++ [role="screenshot"] image::images/tutorial-dashboard.png[] +. Try out the editing controls. ++ You can rearrange the visualizations by clicking a the header of a visualization and dragging. The gear icon in the top right of a visualization displays controls for editing and deleting the visualization. A resize control is on the lower right. -To get a link to share or HTML code to embed the dashboard in a web page, save -the dashboard and click *Share*. +. *Save* your dashboard. + +==== Inspect the data + +Seeing visualizations of your data is great, +but sometimes you need to look at the actual data to +understand what's really going on. You can inspect the data behind any visualization +and view the {es} query used to retrieve it. + +. In the dashboard, hover the pointer over the pie chart, and then click the icon in the upper right. +. From the *Options* menu, select *Inspect*. ++ +[role="screenshot"] +image::images/tutorial-full-inspect1.png[] + +. To look at the query used to fetch the data for the visualization, select *View > Requests* +in the upper right of the Inspect pane. + +[float] +=== Next steps + +Now that you have a handle on the basics, you're ready to start exploring +your own data with Kibana. + +* See {kibana-ref}/discover.html[Discover] for information about searching and filtering +your data. +* See {kibana-ref}/visualize.html[Visualize] for information about the visualization +types Kibana has to offer. +* See {kibana-ref}/management.html[Management] for information about configuring Kibana +and managing your saved objects. +* See {kibana-ref}/console-kibana.html[Console] to learn about the interactive +console you can use to submit REST requests to Elasticsearch. -*Save* your dashboard. diff --git a/docs/getting-started/tutorial-define-index.asciidoc b/docs/getting-started/tutorial-define-index.asciidoc index 032845058aac9..f8ffb47ab8c00 100644 --- a/docs/getting-started/tutorial-define-index.asciidoc +++ b/docs/getting-started/tutorial-define-index.asciidoc @@ -1,45 +1,53 @@ [[tutorial-define-index]] -=== Defining your index patterns +=== Define your index patterns Index patterns tell Kibana which Elasticsearch indices you want to explore. An index pattern can match the name of a single index, or include a wildcard -(*) to match multiple indices. +(*) to match multiple indices. For example, Logstash typically creates a series of indices in the format `logstash-YYYY.MMM.DD`. To explore all of the log data from May 2018, you could specify the index pattern `logstash-2018.05*`. -You'll create patterns for the Shakespeare data set, which has an + +[float] +==== Create your first index pattern + +First you'll create index patterns for the Shakespeare data set, which has an index named `shakespeare,` and the accounts data set, which has an index named -`bank.` These data sets don't contain time-series data. +`bank`. These data sets don't contain time series data. . In Kibana, open *Management*, and then click *Index Patterns.* . If this is your first index pattern, the *Create index pattern* page opens automatically. -Otherwise, click *Create index pattern* in the upper left. +Otherwise, click *Create index pattern*. . Enter `shakes*` in the *Index pattern* field. + [role="screenshot"] image::images/tutorial-pattern-1.png[] . Click *Next step*. -. In *Configure settings*, click *Create index pattern*. For this pattern, -you don't need to configure any settings. -. Define a second index pattern named `ba*` You don't need to configure any settings for this pattern. +. In *Configure settings*, click *Create index pattern*. ++ +You’re presented a table of all fields and associated data types in the index. + +. Return to the *Index patterns* overview page and define a second index pattern named `ba*`. + +[float] +==== Create an index pattern for time series data -Now create an index pattern for the Logstash data set. This data set -contains time-series data. +Now create an index pattern for the Logstash index, which +contains time series data. . Define an index pattern named `logstash*`. . Click *Next step*. -. In *Configure settings*, select *@timestamp* in the *Time Filter field name* dropdown menu. +. Open the *Time Filter field name* dropdown and select *@timestamp*. . Click *Create index pattern*. - - - NOTE: When you define an index pattern, the indices that match that pattern must exist in Elasticsearch and they must contain data. To check which indices are available, go to *Dev Tools > Console* and enter `GET _cat/indices`. Alternately, use `curl -XGET "http://localhost:9200/_cat/indices"`. + + diff --git a/docs/getting-started/tutorial-discovering.asciidoc b/docs/getting-started/tutorial-discovering.asciidoc index cddf09c2532ac..48e5bed6a4ba7 100644 --- a/docs/getting-started/tutorial-discovering.asciidoc +++ b/docs/getting-started/tutorial-discovering.asciidoc @@ -1,5 +1,5 @@ [[tutorial-discovering]] -=== Discovering your data +=== Discover your data Using the Discover application, you can enter an {ref}/query-dsl-query-string-query.html#query-string-syntax[Elasticsearch @@ -11,23 +11,26 @@ The current index pattern appears below the filter bar, in this case `shakes*`. You might need to click *New* in the menu bar to refresh the data. . Click the caret to the right of the current index pattern, and select `ba*`. ++ +By default, all fields are shown for each matching document. + . In the search field, enter the following string: + [source,text] account_number<100 AND balance>47500 - ++ The search returns all account numbers between zero and 99 with balances in -excess of 47,500. It returns results for account numbers 8, 32, 78, 85, and 97. - +excess of 47,500. Results appear for account numbers 8, 32, 78, 85, and 97. ++ [role="screenshot"] image::images/tutorial-discover-2.png[] - -By default, all fields are shown for each matching document. To choose which -fields to display, hover the pointer over the list of *Available Fields* ++ +. To choose which +fields to display, hover the pointer over the list of *Available fields* and then click *add* next to each field you want include as a column in the table. - ++ For example, if you add the `account_number` field, the display changes to a list of five account numbers. - ++ [role="screenshot"] image::images/tutorial-discover-3.png[] diff --git a/docs/getting-started/tutorial-full-experience.asciidoc b/docs/getting-started/tutorial-full-experience.asciidoc index 2096e0191f7c3..08f65b0a24091 100644 --- a/docs/getting-started/tutorial-full-experience.asciidoc +++ b/docs/getting-started/tutorial-full-experience.asciidoc @@ -1,12 +1,213 @@ [[tutorial-build-dashboard]] -== Building your own dashboard +== Build your own dashboard -Ready to load some data and build a dashboard? This tutorial shows you how to: +Want to load some data into Kibana and build a dashboard? This tutorial shows you how to: -* Load a data set into Elasticsearch -* Define an index pattern -* Discover and explore the data -* Visualize the data -* Add visualizations to a dashboard -* Inspect the data behind a visualization +* <> +* <> +* <> +* <> +* <> +When you complete this tutorial, you'll have a dashboard that looks like this. + +[role="screenshot"] +image::images/tutorial-dashboard.png[] + +[float] +[[tutorial-load-dataset]] +=== Load sample data + +This tutorial requires you to download three data sets: + +* The complete works of William Shakespeare, suitably parsed into fields +* A set of fictitious accounts with randomly generated data +* A set of randomly generated log files + +[float] +==== Download the data sets + +Create a new working directory where you want to download the files. From that directory, run the following commands: + +[source,shell] +curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/shakespeare.json +curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/accounts.zip +curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/logs.jsonl.gz + +Two of the data sets are compressed. To extract the files, use these commands: + +[source,shell] +unzip accounts.zip +gunzip logs.jsonl.gz + +[float] +==== Structure of the data sets + +The Shakespeare data set has this structure: + +[source,json] +{ + "line_id": INT, + "play_name": "String", + "speech_number": INT, + "line_number": "String", + "speaker": "String", + "text_entry": "String", +} + +The accounts data set is structured as follows: + +[source,json] +{ + "account_number": INT, + "balance": INT, + "firstname": "String", + "lastname": "String", + "age": INT, + "gender": "M or F", + "address": "String", + "employer": "String", + "email": "String", + "city": "String", + "state": "String" +} + +The logs data set has dozens of different fields. Here are the notable fields for this tutorial: + +[source,json] +{ + "memory": INT, + "geo.coordinates": "geo_point" + "@timestamp": "date" +} + +[float] +==== Set up mappings + +Before you load the Shakespeare and logs data sets, you must set up {ref}/mapping.html[_mappings_] for the fields. +Mappings divide the documents in the index into logical groups and specify the characteristics +of the fields. These characteristics include the searchability of the field +and whether it's _tokenized_, or broken up into separate words. + +NOTE: If security is enabled, you must have the `all` Kibana privilege to run this tutorial. +You must also have the `create`, `manage` `read`, `write,` and `delete` +index privileges. See {xpack-ref}/security-privileges.html[Security Privileges] +for more information. + +In Kibana *Dev Tools > Console*, set up a mapping for the Shakespeare data set: + +[source,js] +PUT /shakespeare +{ + "mappings": { + "properties": { + "speaker": {"type": "keyword"}, + "play_name": {"type": "keyword"}, + "line_id": {"type": "integer"}, + "speech_number": {"type": "integer"} + } + } +} + +//CONSOLE + +This mapping specifies field characteristics for the data set: + +* The `speaker` and `play_name` fields are keyword fields. These fields are not analyzed. +The strings are treated as a single unit even if they contain multiple words. +* The `line_id` and `speech_number` fields are integers. + +The logs data set requires a mapping to label the latitude and longitude pairs +as geographic locations by applying the `geo_point` type. + +[source,js] +PUT /logstash-2015.05.18 +{ + "mappings": { + "properties": { + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + } + } + } + } + } +} + +//CONSOLE + +[source,js] +PUT /logstash-2015.05.19 +{ + "mappings": { + "properties": { + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + } + } + } + } + } +} + +//CONSOLE + +[source,js] +PUT /logstash-2015.05.20 +{ + "mappings": { + "properties": { + "geo": { + "properties": { + "coordinates": { + "type": "geo_point" + } + } + } + } + } +} + +//CONSOLE + +The accounts data set doesn't require any mappings. + +[float] +==== Load the data sets + +At this point, you're ready to use the Elasticsearch {ref}/docs-bulk.html[bulk] +API to load the data sets: + +[source,shell] +curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/bank/account/_bulk?pretty' --data-binary @accounts.json +curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/shakespeare/_bulk?pretty' --data-binary @shakespeare.json +curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/_bulk?pretty' --data-binary @logs.jsonl + +Or for Windows users, in Powershell: +[source,shell] +Invoke-RestMethod "http://:/bank/account/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "accounts.json" +Invoke-RestMethod "http://:/shakespeare/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "shakespeare.json" +Invoke-RestMethod "http://:/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "logs.jsonl" + +These commands might take some time to execute, depending on the available computing resources. + +Verify successful loading: + +[source,js] +GET /_cat/indices?v + +//CONSOLE + +Your output should look similar to this: + +[source,shell] +health status index pri rep docs.count docs.deleted store.size pri.store.size +yellow open bank 1 1 1000 0 418.2kb 418.2kb +yellow open shakespeare 1 1 111396 0 17.6mb 17.6mb +yellow open logstash-2015.05.18 1 1 4631 0 15.6mb 15.6mb +yellow open logstash-2015.05.19 1 1 4624 0 15.7mb 15.7mb +yellow open logstash-2015.05.20 1 1 4750 0 16.4mb 16.4mb diff --git a/docs/getting-started/tutorial-inspect.asciidoc b/docs/getting-started/tutorial-inspect.asciidoc deleted file mode 100644 index 7b028a3b6c51a..0000000000000 --- a/docs/getting-started/tutorial-inspect.asciidoc +++ /dev/null @@ -1,24 +0,0 @@ -[[tutorial-inspect]] -=== Inspecting the data - -Seeing visualizations of your data is great, -but sometimes you need to look at the actual data to -understand what's really going on. You can inspect the data behind any visualization -and view the {es} query used to retrieve it. - -. In the dashboard, hover the pointer over the pie chart. -. Click the icon in the upper right. -. From the *Options* menu, select *Inspect*. -+ -[role="screenshot"] -image::images/tutorial-full-inspect1.png[] - -You can also look at the query used to fetch the data for the visualization. - -. Open the *View:Data* menu and select *Requests*. -. Click the tabs to look at the request statistics, the Elasticsearch request, -and the response in JSON. -. To close the Inspector, click X in the upper right. -+ -[role="screenshot"] -image::images/tutorial-full-inspect2.png[] diff --git a/docs/getting-started/tutorial-load-dataset.asciidoc b/docs/getting-started/tutorial-load-dataset.asciidoc deleted file mode 100644 index b0bcb876c7183..0000000000000 --- a/docs/getting-started/tutorial-load-dataset.asciidoc +++ /dev/null @@ -1,190 +0,0 @@ -[[tutorial-load-dataset]] -=== Loading sample data - -This tutorial requires three data sets: - -* The complete works of William Shakespeare, suitably parsed into fields -* A set of fictitious accounts with randomly generated data -* A set of randomly generated log files - -Create a new working directory where you want to download the files. From that directory, run the following commands: - -[source,shell] -curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/shakespeare.json -curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/accounts.zip -curl -O https://download.elastic.co/demos/kibana/gettingstarted/8.x/logs.jsonl.gz - -Two of the data sets are compressed. To extract the files, use these commands: - -[source,shell] -unzip accounts.zip -gunzip logs.jsonl.gz - -==== Structure of the data sets - -The Shakespeare data set has this structure: - -[source,json] -{ - "line_id": INT, - "play_name": "String", - "speech_number": INT, - "line_number": "String", - "speaker": "String", - "text_entry": "String", -} - -The accounts data set is structured as follows: - -[source,json] -{ - "account_number": INT, - "balance": INT, - "firstname": "String", - "lastname": "String", - "age": INT, - "gender": "M or F", - "address": "String", - "employer": "String", - "email": "String", - "city": "String", - "state": "String" -} - -The logs data set has dozens of different fields. Here are the notable fields for this tutorial: - -[source,json] -{ - "memory": INT, - "geo.coordinates": "geo_point" - "@timestamp": "date" -} - -==== Set up mappings - -Before you load the Shakespeare and logs data sets, you must set up {ref}/mapping.html[_mappings_] for the fields. -Mappings divide the documents in the index into logical groups and specify the characteristics -of the fields. These characteristics include the searchability of the field -and whether it's _tokenized_, or broken up into separate words. - -NOTE: If security is enabled, you must have the `all` Kibana privilege to run this tutorial. -You must also have the `create`, `manage` `read`, `write,` and `delete` -index privileges. See {xpack-ref}/security-privileges.html[Security Privileges] -for more information. - -In Kibana *Dev Tools > Console*, set up a mapping for the Shakespeare data set: - -[source,js] -PUT /shakespeare -{ - "mappings": { - "properties": { - "speaker": {"type": "keyword"}, - "play_name": {"type": "keyword"}, - "line_id": {"type": "integer"}, - "speech_number": {"type": "integer"} - } - } -} - -//CONSOLE - -This mapping specifies field characteristics for the data set: - -* The `speaker` and `play_name` fields are keyword fields. These fields are not analyzed. -The strings are treated as a single unit even if they contain multiple words. -* The `line_id` and `speech_number` fields are integers. - -The logs data set requires a mapping to label the latitude and longitude pairs -as geographic locations by applying the `geo_point` type. - -[source,js] -PUT /logstash-2015.05.18 -{ - "mappings": { - "properties": { - "geo": { - "properties": { - "coordinates": { - "type": "geo_point" - } - } - } - } - } -} - -//CONSOLE - -[source,js] -PUT /logstash-2015.05.19 -{ - "mappings": { - "properties": { - "geo": { - "properties": { - "coordinates": { - "type": "geo_point" - } - } - } - } - } -} - -//CONSOLE - -[source,js] -PUT /logstash-2015.05.20 -{ - "mappings": { - "properties": { - "geo": { - "properties": { - "coordinates": { - "type": "geo_point" - } - } - } - } - } -} - -//CONSOLE - -The accounts data set doesn't require any mappings. - -==== Load the data sets - -At this point, you're ready to use the Elasticsearch {ref}/docs-bulk.html[bulk] -API to load the data sets: - -[source,shell] -curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/bank/account/_bulk?pretty' --data-binary @accounts.json -curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/shakespeare/_bulk?pretty' --data-binary @shakespeare.json -curl -u elastic -H 'Content-Type: application/x-ndjson' -XPOST ':/_bulk?pretty' --data-binary @logs.jsonl - -Or for Windows users, in Powershell: -[source,shell] -Invoke-RestMethod "http://:/bank/account/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "accounts.json" -Invoke-RestMethod "http://:/shakespeare/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "shakespeare.json" -Invoke-RestMethod "http://:/_bulk?pretty" -Method Post -ContentType 'application/x-ndjson' -InFile "logs.jsonl" - -These commands might take some time to execute, depending on the available computing resources. - -Verify successful loading: - -[source,js] -GET /_cat/indices?v - -//CONSOLE - -Your output should look similar to this: - -[source,shell] -health status index pri rep docs.count docs.deleted store.size pri.store.size -yellow open bank 1 1 1000 0 418.2kb 418.2kb -yellow open shakespeare 1 1 111396 0 17.6mb 17.6mb -yellow open logstash-2015.05.18 1 1 4631 0 15.6mb 15.6mb -yellow open logstash-2015.05.19 1 1 4624 0 15.7mb 15.7mb -yellow open logstash-2015.05.20 1 1 4750 0 16.4mb 16.4mb diff --git a/docs/getting-started/tutorial-sample-data.asciidoc b/docs/getting-started/tutorial-sample-data.asciidoc index d064f41c65073..24cc176d5daf9 100644 --- a/docs/getting-started/tutorial-sample-data.asciidoc +++ b/docs/getting-started/tutorial-sample-data.asciidoc @@ -1,31 +1,207 @@ [[tutorial-sample-data]] -== Explore {kib} using the Flight dashboard +== Explore {kib} using sample data -You’re new to {kib} and want to try it out. With one click, you can install -the Flights sample data and start interacting with Kibana. +Ready to get some hands-on experience with Kibana? +In this tutorial, you’ll work +with Kibana sample data and learn to: -The Flights data set contains data for four airlines. -You can load the data and preconfigured dashboard from the {kib} home page. +* <> +* <> +* <> +* <> -. On the home page, click the link next to *Sample data*. -. On the *Sample flight data* card, click *Add*. -. Click *View data*. +NOTE: If security is enabled, you must have `read`, `write`, and `manage` privileges +on the `kibana_sample_data_*` indices. See {xpack-ref}/security-privileges.html[Security Privileges] +for more information. + + +[float] +=== Add sample data + +Install the Flights sample data set, if you haven't already. + +. On the {kib} home page, click the link underneath *Add sample data*. +. On the *Sample flight data* card, click *Add data*. +. Once the data is added, click *View data > Dashboard*. ++ You’re taken to the *Global Flight* dashboard, a collection of charts, graphs, maps, and other visualizations of the the data in the `kibana_sample_data_flights` index. - ++ [role="screenshot"] image::images/tutorial-sample-dashboard.png[] -In this tutorial, you’ll learn to: +[float] +[[tutorial-sample-filter]] +=== Filter and query the data + +You can use filters and queries to +narrow the view of the data. +For more detailed information on these actions, see +{ref}/query-filter-context.html[Query and filter context]. + +[float] +==== Filter the data + +. In the *Controls* visualization, set an *Origin City* and a *Destination City*. +. Click *Apply changes*. ++ +The `OriginCityName` and the `DestCityName` fields are filtered to match +the data you specified. ++ +For example, this dashboard shows the data for flights from London to Oslo. ++ +[role="screenshot"] +image::images/tutorial-sample-filter.png[] + +. To add a filter manually, click *Add filter* in the filter bar, +and specify the data you want to view. + +. When you are finished experimenting, remove all filters. + + +[float] +[[tutorial-sample-query]] +==== Query the data + +. To find all flights out of Rome, enter this query in the query bar and click *Update*: ++ +[source,text] +OriginCityName:Rome + +. For a more complex query with AND and OR, try this: ++ +[source,text] +OriginCityName:Rome AND (Carrier:JetBeats OR "Kibana Airlines") ++ +The dashboard updates to show data for the flights out of Rome on JetBeats and +{kib} Airlines. ++ +[role="screenshot"] +image::images/tutorial-sample-query.png[] + +. When you are finished exploring the dashboard, remove the query by +clearing the contents in the query bar and clicking *Update*. + +[float] +[[tutorial-sample-discover]] +=== Discover the data + +In Discover, you have access to every document in every index that +matches the selected index pattern. The index pattern tells {kib} which {es} index you are currently +exploring. You can submit search queries, filter the +search results, and view document data. + +. In the side navigation, click *Discover*. + +. Ensure `kibana_sample_data_flights` is the current index pattern. +You might need to click *New* in the menu bar to refresh the data. ++ +You'll see a histogram that shows the distribution of +documents over time. A table lists the fields for +each matching document. By default, all fields are shown. ++ +[role="screenshot"] +image::images/tutorial-sample-discover1.png[] + +. To choose which fields to display, +hover the pointer over the list of *Available fields*, and then click *add* next +to each field you want include as a column in the table. ++ +For example, if you add the `DestAirportID` and `DestWeather` fields, +the display includes columns for those two fields. ++ +[role="screenshot"] +image::images/tutorial-sample-discover2.png[] + +[float] +[[tutorial-sample-edit]] +=== Edit a visualization + +You have edit permissions for the *Global Flight* dashboard, so you can change +the appearance and behavior of the visualizations. For example, you might want +to see which airline has the lowest average fares. + +. In the side navigation, click *Recently viewed* and open the *Global Flight Dashboard*. +. In the menu bar, click *Edit*. +. In the *Average Ticket Price* visualization, click the gear icon in +the upper right. +. From the *Options* menu, select *Edit visualization*. ++ +*Average Ticket Price* is a metric visualization. +To specify which groups to display +in this visualization, you use an {es} {ref}/search-aggregations.html[bucket aggregation]. +This aggregation sorts the documents that match your search criteria into different +categories, or buckets. + +[float] +==== Create a bucket aggregation + +. In the *Buckets* pane, select *Add > Split group*. +. In the *Aggregation* dropdown, select *Terms*. +. In the *Field* dropdown, select *Carrier*. +. Set *Descending* to *4*. +. Click *Apply changes* image:images/apply-changes-button.png[]. ++ +You now see the average ticket price for all four airlines. ++ +[role="screenshot"] +image::images/tutorial-sample-edit1.png[] + +[float] +==== Save the visualization + +. In the menu bar, click *Save*. +. Leave the visualization name as is and confirm the save. +. Go to the *Global Flight* dashboard and scroll the *Average Ticket Price* visualization to see the four prices. +. Optionally, edit the dashboard. Resize the panel +for the *Average Ticket Price* visualization by dragging the +handle in the lower right. You can also rearrange the visualizations by clicking +the header and dragging. Be sure to save the dashboard. ++ +[role="screenshot"] +image::images/tutorial-sample-edit2.png[] + +[float] +[[tutorial-sample-inspect]] +=== Inspect the data + +Seeing visualizations of your data is great, +but sometimes you need to look at the actual data to +understand what's really going on. You can inspect the data behind any visualization +and view the {es} query used to retrieve it. + +. In the dashboard, hover the pointer over the pie chart, and then click the icon in the upper right. +. From the *Options* menu, select *Inspect*. ++ +The initial view shows the document count. ++ +[role="screenshot"] +image::images/tutorial-sample-inspect1.png[] + +. To look at the query used to fetch the data for the visualization, select *View > Requests* +in the upper right of the Inspect pane. + +[float] +[[tutorial-sample-remove]] +=== Remove the sample data set +When you’re done experimenting with the sample data set, you can remove it. + +. Go to the *Sample data* page. +. On the *Sample flight data* card, click *Remove*. + +[float] +=== Next steps + +Now that you have a handle on the {kib} basics, you might be interested in the +tutorial <>, where you'll learn to: + +* Load data +* Define an index pattern +* Discover and explore data +* Create visualizations +* Add visualizations to a dashboard + -* Filter the data -* Query the data -* Discover the data -* Edit a visualization -* Inspect the data behind the scenes -NOTE: If security is enabled, you must have `read`, `write`, and `manage` privileges -on the `kibana_sample_data_*` indices. See {xpack-ref}/security-privileges.html[Security Privileges] -for more information. diff --git a/docs/getting-started/tutorial-sample-discover.asciidoc b/docs/getting-started/tutorial-sample-discover.asciidoc deleted file mode 100644 index e455159f4d6cf..0000000000000 --- a/docs/getting-started/tutorial-sample-discover.asciidoc +++ /dev/null @@ -1,27 +0,0 @@ -[[tutorial-sample-discover]] -=== Using Discover - -In the Discover application, the Flight data is presented in a table. You can -interactively explore the data, including searching and filtering. - -* In the side navigation, select *Discover*. - -The current index pattern appears below the filter bar. An -<> tells {kib} which {es} indices you want to -explore. - -The `kibana_sample_data_flights` index contains a time field. A histogram -shows the distribution of documents over time. - -[role="screenshot"] -image::images/tutorial-sample-discover1.png[] - -By default, all fields are shown for each matching document. To choose which fields to display, -hover the pointer over the the list of *Available Fields* and then click *add* next -to each field you want include as a column in the table. - -For example, if you add the `DestAirportID` and `DestWeather` fields, -the display includes columns for those two fields: - -[role="screenshot"] -image::images/tutorial-sample-discover2.png[] diff --git a/docs/getting-started/tutorial-sample-edit.asciidoc b/docs/getting-started/tutorial-sample-edit.asciidoc deleted file mode 100644 index d009161716b31..0000000000000 --- a/docs/getting-started/tutorial-sample-edit.asciidoc +++ /dev/null @@ -1,45 +0,0 @@ -[[tutorial-sample-edit]] -=== Editing a visualization - -You have edit permissions for the *Global Flight* dashboard so you can change -the appearance and behavior of the visualizations. For example, you might want -to see which airline has the lowest average fares. - -. Go to the *Global Flight* dashboard. -. In the menu bar, click *Edit*. -. In the *Average Ticket Price* visualization, click the gear icon in -the upper right. -. From the *Options* menu, select *Edit visualization*. - -==== Edit a metric visualization - -*Average Ticket Price* is a metric visualization. -To specify which groups to display -in this visualization, you use an {es} {ref}/search-aggregations.html[bucket aggregation]. -This aggregation sorts the documents that match your search criteria into different -categories, or buckets. - -. In the *Buckets* pane, select *Split Group*. -. In the *Aggregation* dropdown menu, select *Terms*. -. In the *Field* dropdown, select *Carrier*. -. Set *Descending* to four. -. Click *Apply changes* image:images/apply-changes-button.png[]. - -You now see the average ticket price for all four airlines. - -[role="screenshot"] -image::images/tutorial-sample-edit1.png[] - -==== Save the changes - -. In the menu bar, click *Save*. -. Leave the visualization name unchanged and click *Save*. -. Go to the *Global Flight* dashboard. -. Resize the panel for the *Average Ticket Price* visualization by dragging the -handle in the lower right. -You can also rearrange the visualizations by clicking the header and dragging. -. In the menu bar, click *Save* and then confirm the save. -+ -[role="screenshot"] -image::images/tutorial-sample-edit2.png[] - diff --git a/docs/getting-started/tutorial-sample-filter.asciidoc b/docs/getting-started/tutorial-sample-filter.asciidoc deleted file mode 100644 index 3efca0e1d2b5d..0000000000000 --- a/docs/getting-started/tutorial-sample-filter.asciidoc +++ /dev/null @@ -1,23 +0,0 @@ -[[tutorial-sample-filter]] -=== Filtering the data - -Many visualizations in the *Global Flight* dashboard are interactive. You can -apply filters to modify the view of the data across all visualizations. - -. In the *Controls* visualization, set an *Origin City* and a *Destination City*. -. Click *Apply changes*. -+ -The `OriginCityName` and the `DestCityName` fields are filtered to match -the data you specified. -+ -For example, this dashboard shows the data for flights from London to Newark -and Pittsburgh. -+ -[role="screenshot"] -image::images/tutorial-sample-filter.png[] -+ -. To remove the filters, in the *Controls* visualization, click *Clear form*, and then -*Apply changes*. - -You can also add filters manually. In the filter bar, click *Add a Filter* -and specify the data you want to view. diff --git a/docs/getting-started/tutorial-sample-inspect.asciidoc b/docs/getting-started/tutorial-sample-inspect.asciidoc deleted file mode 100644 index 4ba74a3529a98..0000000000000 --- a/docs/getting-started/tutorial-sample-inspect.asciidoc +++ /dev/null @@ -1,24 +0,0 @@ -[[tutorial-sample-inspect]] -=== Inspecting the data - -Seeing visualizations of your data is great, -but sometimes you need to look at the actual data to -understand what's really going on. You can inspect the data behind any visualization -and view the {es} query used to retrieve it. - -. Hover the pointer over the *Flight Count and Average Ticket Price* visualization. -. Click the icon in the upper right. -. From the *Options* menu, select *Inspect*. -+ -[role="screenshot"] -image::images/tutorial-sample-inspect1.png[] - -You can also look at the query used to fetch the data for the visualization. - -. Open the *View: Data* menu and select *Requests*. -. Click the tabs to look at the request statistics, the Elasticsearch request, -and the response in JSON. -. To close the editor, click X in the upper right. -+ -[role="screenshot"] -image::images/tutorial-sample-inspect2.png[] \ No newline at end of file diff --git a/docs/getting-started/tutorial-sample-query.asciidoc b/docs/getting-started/tutorial-sample-query.asciidoc deleted file mode 100644 index 5a638bbe3a5c0..0000000000000 --- a/docs/getting-started/tutorial-sample-query.asciidoc +++ /dev/null @@ -1,30 +0,0 @@ -[[tutorial-sample-query]] -=== Querying the data - -You can enter an {es} query to narrow the view of the data. - -. To find all flights out of Rome, submit this query: -+ -[source,text] -OriginCityName:Rome - -. For a more complex query with AND and OR, try this: -+ -[source,text] -OriginCityName:Rome AND (Carrier:JetBeats OR "Kibana Airlines") -+ -The dashboard updates to show data for the flights out of Rome on JetBeats and -{kib} Airlines. -+ -[role="screenshot"] -image::images/tutorial-sample-query.png[] - -. When you are finished exploring the dashboard, remove the query by -clearing the contents in the query bar and pressing Enter. - -In general, filters are faster than queries. For more information, see {ref}/query-filter-context.html[Query and filter context]. - -TIP: {kib} has an experimental autocomplete feature that can -help jumpstart your queries. To turn on this feature, click *Options* on the -right of the query bar and opt in. With autocomplete enabled, -search suggestions are displayed when you start typing your query. \ No newline at end of file diff --git a/docs/getting-started/tutorial-sample-remove.asciidoc b/docs/getting-started/tutorial-sample-remove.asciidoc deleted file mode 100644 index 9761b3bdf1987..0000000000000 --- a/docs/getting-started/tutorial-sample-remove.asciidoc +++ /dev/null @@ -1,18 +0,0 @@ -[[tutorial-sample-remove]] -=== Wrapping up - -When you’re done experimenting with the sample data set, you can remove it. - -. Go to the {kib} home page and click the link next to *Sample data*. -. On the *Sample flight data* card, click *Remove*. - -Now that you have a handle on the {kib} basics, you might be interested in: - -* <>. You’ll learn how to load your own -data, define an index pattern, and create visualizations and dashboards. -* <>. You’ll find information about all the visualization types -{kib} has to offer. -* <>. You have the ability to share a dashboard, or embed the dashboard in a web page. -* <>. You'll learn more about searching data and filtering by field. - - diff --git a/docs/getting-started/tutorial-visualizing.asciidoc b/docs/getting-started/tutorial-visualizing.asciidoc index dda72467f9412..5e61475cf2839 100644 --- a/docs/getting-started/tutorial-visualizing.asciidoc +++ b/docs/getting-started/tutorial-visualizing.asciidoc @@ -1,46 +1,48 @@ [[tutorial-visualizing]] -=== Visualizing your data +=== Visualize your data In the Visualize application, you can shape your data using a variety -of charts, tables, and maps, and more. You'll create four -visualizations: a pie chart, bar chart, coordinate map, and Markdown widget. +of charts, tables, and maps, and more. In this tutorial, you'll create four +visualizations: -. Open *Visualize.* -. Click *Create a visualization* or the *+* button. You'll see all the visualization +* <> +* <> +* <> +* <> + +[float] +[[tutorial-visualize-pie]] +=== Pie chart + +You'll use the pie chart to +gain insight into the account balances in the bank account data. + +. Open *Visualize* to show the overview page. +. Click *Create new visualization*. You'll see all the visualization types in Kibana. + [role="screenshot"] image::images/tutorial-visualize-wizard-step-1.png[] . Click *Pie*. -. In *New Search*, select the `ba*` index pattern. You'll use the pie chart to -gain insight into the account balances in the bank account data. +. In *Choose a source*, select the `ba*` index pattern. + -[role="screenshot"] -image::images/tutorial-visualize-wizard-step-2.png[] - -=== Pie chart - Initially, the pie contains a single "slice." That's because the default search matched all documents. - -[role="screenshot"] -image::images/tutorial-visualize-pie-1.png[] - ++ To specify which slices to display in the pie, you use an Elasticsearch {ref}/search-aggregations.html[bucket aggregation]. This aggregation sorts the documents that match your search criteria into different -categories, also known as _buckets_. - -Use a bucket aggregation to establish +categories. You'll use a bucket aggregation to establish multiple ranges of account balances and find out how many accounts fall into each range. -. In the *Buckets* pane, click *Split Slices.* -. In the *Aggregation* dropdown menu, select *Range*. -. In the *Field* dropdown menu, select *balance*. -. Click *Add Range* four times to bring the total number of ranges to six. -. Define the following ranges: +. In the *Buckets* pane, click *Add > Split slices.* ++ +.. In the *Aggregation* dropdown, select *Range*. +.. In the *Field* dropdown, select *balance*. +.. Click *Add range* four times to bring the total number of ranges to six. +.. Define the following ranges: + [source,text] 0 999 @@ -51,120 +53,117 @@ each range. 31000 50000 . Click *Apply changes* image:images/apply-changes-button.png[]. - ++ Now you can see what proportion of the 1000 accounts fall into each balance range. - ++ [role="screenshot"] image::images/tutorial-visualize-pie-2.png[] -Add another bucket aggregation that looks at the ages of the account +. Add another bucket aggregation that looks at the ages of the account holders. -. At the bottom of the *Buckets* pane, click *Add sub-buckets*. -. In *Select buckets type,* click *Split Slices*. -. In the *Sub Aggregation* dropdown, select *Terms*. -. In the *Field* dropdown, select *age*. -. Click *Apply changes* image:images/apply-changes-button.png[]. +.. At the bottom of the *Buckets* pane, click *Add*. +.. For *sub-bucket type,* select *Split slices*. +.. In the *Sub aggregation* dropdown, select *Terms*. +.. In the *Field* dropdown, select *age*. +. Click *Apply changes* image:images/apply-changes-button.png[]. ++ Now you can see the break down of the ages of the account holders, displayed in a ring around the balance ranges. - ++ [role="screenshot"] image::images/tutorial-visualize-pie-3.png[] -To save this chart so you can use it later: - -Click *Save* in the top menu bar and enter `Pie Example`. +. To save this chart so you can use it later, click *Save* in +the top menu bar and enter `Pie Example`. +[float] +[[tutorial-visualize-bar]] === Bar chart You'll use a bar chart to look at the Shakespeare data set and compare the number of speaking parts in the plays. -Create a *Vertical Bar* chart and set the search source to `shakes*`. - +. Create a *Vertical Bar* chart and set the search source to `shakes*`. ++ Initially, the chart is a single bar that shows the total count of documents that match the default wildcard query. -[role="screenshot"] -image::images/tutorial-visualize-bar-1.png[] +. Show the number of speaking parts per play along the Y-axis. -Show the number of speaking parts per play along the Y-axis. -This requires you to configure the Y-axis -{ref}/search-aggregations.html[metric aggregation.] -This aggregation computes metrics based on values from the search results. +.. In the *Metrics* pane, expand *Y-axis*. +.. Set *Aggregation* to *Unique Count*. +.. Set *Field* to *speaker*. +.. In the *Custom label* box, enter `Speaking Parts`. -. In the *Metrics* pane, expand *Y-Axis*. -. Set *Aggregation* to *Unique Count*. -. Set *Field* to *speaker*. -. In the *Custom Label* box, enter `Speaking Parts`. . Click *Apply changes* image:images/apply-changes-button.png[]. +. Show the plays along the X-axis. -[role="screenshot"] -image::images/tutorial-visualize-bar-1.5.png[] - +.. In the *Buckets* pane, click *Add > X-axis*. +.. Set *Aggregation* to *Terms*. +.. Set *Field* to *play_name*. +.. To list plays alphabetically, in the *Order* dropdown, select *Ascending*. +.. Give the axis a custom label, `Play Name`. -Show the plays along the X-axis. - -. In the *Buckets* pane, click *X-Axis*. -. Set *Aggregation* to *Terms* and *Field* to *play_name*. -. To list plays alphabetically, in the *Order* dropdown menu, select *Ascending*. -. Give the axis a custom label, `Play Name`. . Click *Apply changes* image:images/apply-changes-button.png[]. - ++ +[role="screenshot"] +image::images/tutorial-visualize-bar-1.5.png[] +. *Save* this chart with the name `Bar Example`. ++ Hovering over a bar shows a tooltip with the number of speaking parts for that play. - ++ Notice how the individual play names show up as whole phrases, instead of broken into individual words. This is the result of the mapping you did at the beginning of the tutorial, when you marked the `play_name` field as `not analyzed`. -*Save* this chart with the name `Bar Example`. - +[float] +[[tutorial-visualize-map]] === Coordinate map Using a coordinate map, you can visualize geographic information in the log file sample data. . Create a *Coordinate map* and set the search source to `logstash*`. -. In the top menu bar, click the time picker on the far right. -. Click *Absolute*. -. Set the start time to May 18, 2015 and the end time to May 20, 2015. -. Click *Go*. - ++ You haven't defined any buckets yet, so the visualization is a map of the world. -[role="screenshot"] -image::images/tutorial-visualize-map-1.png[] +. Set the time. +.. In the time filter, click *Show dates*. +.. Click the start date, then *Absolute*. +.. Set the *Start date* to May 18, 2015. +.. In the time filter, click *now*, then *Absolute*. +.. Set the *End date* to May 20, 2015. -Now map the geo coordinates from the log files. +. Map the geo coordinates from the log files. -. In the *Buckets* pane, click *Geo Coordinates*. -. Set *Aggregation* to *Geohash* and *Field* to *geo.coordinates*. -. Click *Apply changes* image:images/apply-changes-button.png[]. +.. In the *Buckets* pane, click *Add > Geo coordinates*. +.. Set *Aggregation* to *Geohash*. +.. Set *Field* to *geo.coordinates*. +. Click *Apply changes* image:images/apply-changes-button.png[]. ++ The map now looks like this: - ++ [role="screenshot"] image::images/tutorial-visualize-map-2.png[] -You can navigate the map by clicking and dragging. The controls -on the top left of the map enable you to zoom the map and set filters. -Give them a try. - -[role="screenshot"] -image::images/tutorial-visualize-map-3.png[] - -*Save* this map with the name `Map Example`. +. Navigate the map by clicking and dragging. Use the controls +on the left to zoom the map and set filters. +. *Save* this map with the name `Map Example`. +[float] +[[tutorial-visualize-markdown]] === Markdown The final visualization is a Markdown widget that renders formatted text. . Create a *Markdown* visualization. -. In the text box, enter the following: +. Copy the following text into the text box. + [source,markdown] # This is a tutorial dashboard! @@ -172,10 +171,10 @@ The Markdown widget uses **markdown** syntax. > Blockquotes in Markdown use the > character. . Click *Apply changes* image:images/apply-changes-button.png[]. - -The Markdown renders in the preview pane: - ++ +The Markdown renders in the preview pane. ++ [role="screenshot"] image::images/tutorial-visualize-md-2.png[] -*Save* this visualization with the name `Markdown Example`. +. *Save* this visualization with the name `Markdown Example`. diff --git a/docs/getting-started/wrapping-up.asciidoc b/docs/getting-started/wrapping-up.asciidoc deleted file mode 100644 index d2801dad89dbb..0000000000000 --- a/docs/getting-started/wrapping-up.asciidoc +++ /dev/null @@ -1,14 +0,0 @@ -[[wrapping-up]] -=== Wrapping up - -Now that you have a handle on the basics, you're ready to start exploring -your own data with Kibana. - -* See {kibana-ref}/discover.html[Discover] for information about searching and filtering -your data. -* See {kibana-ref}/visualize.html[Visualize] for information about the visualization -types Kibana has to offer. -* See {kibana-ref}/management.html[Management] for information about configuring Kibana -and managing your saved objects. -* See {kibana-ref}/console-kibana.html[Console] to learn about the interactive -console you can use to submit REST requests to Elasticsearch. diff --git a/docs/images/Dashboard_Resize_Menu.png b/docs/images/Dashboard_Resize_Menu.png index 317ae381db0df..835d23afe40e9 100644 Binary files a/docs/images/Dashboard_Resize_Menu.png and b/docs/images/Dashboard_Resize_Menu.png differ diff --git a/docs/images/Dashboard_add_visualization.png b/docs/images/Dashboard_add_visualization.png index 957e828eff46e..bc705b66e17d1 100644 Binary files a/docs/images/Dashboard_add_visualization.png and b/docs/images/Dashboard_add_visualization.png differ diff --git a/docs/images/Dashboard_example.png b/docs/images/Dashboard_example.png index bf226f48944b0..5d18acb67bef5 100644 Binary files a/docs/images/Dashboard_example.png and b/docs/images/Dashboard_example.png differ diff --git a/docs/images/Dashboard_inspect.png b/docs/images/Dashboard_inspect.png new file mode 100644 index 0000000000000..80edcf3a49ca0 Binary files /dev/null and b/docs/images/Dashboard_inspect.png differ diff --git a/docs/images/add-sample-data.png b/docs/images/add-sample-data.png index 8c927cdde3ac7..6e771580b1e2f 100644 Binary files a/docs/images/add-sample-data.png and b/docs/images/add-sample-data.png differ diff --git a/docs/images/apply-changes-button.png b/docs/images/apply-changes-button.png index 7ec98e6ccdcb4..8625a87d1b2e9 100644 Binary files a/docs/images/apply-changes-button.png and b/docs/images/apply-changes-button.png differ diff --git a/docs/images/management_create_rollup_job.png b/docs/images/management_create_rollup_job.png old mode 100644 new mode 100755 index 398105e7af491..f06ce97010849 Binary files a/docs/images/management_create_rollup_job.png and b/docs/images/management_create_rollup_job.png differ diff --git a/docs/images/management_create_rollup_menu.png b/docs/images/management_create_rollup_menu.png old mode 100644 new mode 100755 index 21e19af0c90fc..c34dac5f30741 Binary files a/docs/images/management_create_rollup_menu.png and b/docs/images/management_create_rollup_menu.png differ diff --git a/docs/images/management_rolled_dashboard.png b/docs/images/management_rolled_dashboard.png old mode 100644 new mode 100755 index b6f797147781c..db731420fb96a Binary files a/docs/images/management_rolled_dashboard.png and b/docs/images/management_rolled_dashboard.png differ diff --git a/docs/images/management_rollup_job_dashboard.png b/docs/images/management_rollup_job_dashboard.png new file mode 100755 index 0000000000000..995fde2060ff7 Binary files /dev/null and b/docs/images/management_rollup_job_dashboard.png differ diff --git a/docs/images/management_rollup_job_details.png b/docs/images/management_rollup_job_details.png old mode 100644 new mode 100755 index 03983977239d7..63114adb8d63f Binary files a/docs/images/management_rollup_job_details.png and b/docs/images/management_rollup_job_details.png differ diff --git a/docs/images/management_rollup_job_vis.png b/docs/images/management_rollup_job_vis.png new file mode 100755 index 0000000000000..672a3045b335b Binary files /dev/null and b/docs/images/management_rollup_job_vis.png differ diff --git a/docs/images/management_rollup_list.png b/docs/images/management_rollup_list.png old mode 100644 new mode 100755 index 0ca3ce9940fe3..bbebb6140d1e7 Binary files a/docs/images/management_rollup_list.png and b/docs/images/management_rollup_list.png differ diff --git a/docs/images/management_rollups_visualization.png b/docs/images/management_rollups_visualization.png old mode 100644 new mode 100755 index d2c1adb67b942..bba3b6e91a953 Binary files a/docs/images/management_rollups_visualization.png and b/docs/images/management_rollups_visualization.png differ diff --git a/docs/images/tutorial-dashboard.png b/docs/images/tutorial-dashboard.png index 5d4056cf7b11a..48e75260e9f60 100644 Binary files a/docs/images/tutorial-dashboard.png and b/docs/images/tutorial-dashboard.png differ diff --git a/docs/images/tutorial-discover-2.png b/docs/images/tutorial-discover-2.png index 338d58e1da21e..4f4b2dc920ccb 100644 Binary files a/docs/images/tutorial-discover-2.png and b/docs/images/tutorial-discover-2.png differ diff --git a/docs/images/tutorial-discover-3.png b/docs/images/tutorial-discover-3.png index 7531accd9a7ef..7b3e12d74686b 100644 Binary files a/docs/images/tutorial-discover-3.png and b/docs/images/tutorial-discover-3.png differ diff --git a/docs/images/tutorial-full-inspect1.png b/docs/images/tutorial-full-inspect1.png index be76ca1acc8ce..8e756634af76a 100644 Binary files a/docs/images/tutorial-full-inspect1.png and b/docs/images/tutorial-full-inspect1.png differ diff --git a/docs/images/tutorial-pattern-1.png b/docs/images/tutorial-pattern-1.png index 651dcba33aaa5..8a289f93fc66e 100644 Binary files a/docs/images/tutorial-pattern-1.png and b/docs/images/tutorial-pattern-1.png differ diff --git a/docs/images/tutorial-sample-dashboard.png b/docs/images/tutorial-sample-dashboard.png index 654496420acd6..9f287640f201c 100644 Binary files a/docs/images/tutorial-sample-dashboard.png and b/docs/images/tutorial-sample-dashboard.png differ diff --git a/docs/images/tutorial-sample-discover-2.png b/docs/images/tutorial-sample-discover-2.png new file mode 100644 index 0000000000000..4f4b2dc920ccb Binary files /dev/null and b/docs/images/tutorial-sample-discover-2.png differ diff --git a/docs/images/tutorial-sample-discover1.png b/docs/images/tutorial-sample-discover1.png index defc97c3ea0e5..dc35ec41609e9 100644 Binary files a/docs/images/tutorial-sample-discover1.png and b/docs/images/tutorial-sample-discover1.png differ diff --git a/docs/images/tutorial-sample-discover2.png b/docs/images/tutorial-sample-discover2.png index f5e55b2b480b8..c5d7833db6126 100644 Binary files a/docs/images/tutorial-sample-discover2.png and b/docs/images/tutorial-sample-discover2.png differ diff --git a/docs/images/tutorial-sample-edit1.png b/docs/images/tutorial-sample-edit1.png index bf3605ee6d741..621fa39120851 100644 Binary files a/docs/images/tutorial-sample-edit1.png and b/docs/images/tutorial-sample-edit1.png differ diff --git a/docs/images/tutorial-sample-edit2.png b/docs/images/tutorial-sample-edit2.png index 47eb10d718d28..c289b3643a87d 100644 Binary files a/docs/images/tutorial-sample-edit2.png and b/docs/images/tutorial-sample-edit2.png differ diff --git a/docs/images/tutorial-sample-filter.png b/docs/images/tutorial-sample-filter.png index ef3cbd0a520fe..7c1d041448557 100644 Binary files a/docs/images/tutorial-sample-filter.png and b/docs/images/tutorial-sample-filter.png differ diff --git a/docs/images/tutorial-sample-inspect1.png b/docs/images/tutorial-sample-inspect1.png index ceba1ed3de592..71a608597338a 100644 Binary files a/docs/images/tutorial-sample-inspect1.png and b/docs/images/tutorial-sample-inspect1.png differ diff --git a/docs/images/tutorial-sample-query.png b/docs/images/tutorial-sample-query.png index f7312acc6a7e1..847542c0b17ff 100644 Binary files a/docs/images/tutorial-sample-query.png and b/docs/images/tutorial-sample-query.png differ diff --git a/docs/images/tutorial-visualize-bar-1.5.png b/docs/images/tutorial-visualize-bar-1.5.png index e6d5de20edb1b..4ec256959f14f 100644 Binary files a/docs/images/tutorial-visualize-bar-1.5.png and b/docs/images/tutorial-visualize-bar-1.5.png differ diff --git a/docs/images/tutorial-visualize-bar-1.png b/docs/images/tutorial-visualize-bar-1.png deleted file mode 100644 index 1aad4f16a4922..0000000000000 Binary files a/docs/images/tutorial-visualize-bar-1.png and /dev/null differ diff --git a/docs/images/tutorial-visualize-bar-2.png b/docs/images/tutorial-visualize-bar-2.png deleted file mode 100644 index 244d4960ebed5..0000000000000 Binary files a/docs/images/tutorial-visualize-bar-2.png and /dev/null differ diff --git a/docs/images/tutorial-visualize-map-1.png b/docs/images/tutorial-visualize-map-1.png deleted file mode 100644 index d9d8933d343a2..0000000000000 Binary files a/docs/images/tutorial-visualize-map-1.png and /dev/null differ diff --git a/docs/images/tutorial-visualize-map-2.png b/docs/images/tutorial-visualize-map-2.png index 9337bcdecd0e7..db9f0d56bc963 100644 Binary files a/docs/images/tutorial-visualize-map-2.png and b/docs/images/tutorial-visualize-map-2.png differ diff --git a/docs/images/tutorial-visualize-map-3.png b/docs/images/tutorial-visualize-map-3.png deleted file mode 100644 index be3a8a83c4de0..0000000000000 Binary files a/docs/images/tutorial-visualize-map-3.png and /dev/null differ diff --git a/docs/images/tutorial-visualize-md-2.png b/docs/images/tutorial-visualize-md-2.png index ec6d6f0278da8..9e9a670ba196f 100644 Binary files a/docs/images/tutorial-visualize-md-2.png and b/docs/images/tutorial-visualize-md-2.png differ diff --git a/docs/images/tutorial-visualize-pie-1.png b/docs/images/tutorial-visualize-pie-1.png index 229b061d00639..109829c01f28c 100644 Binary files a/docs/images/tutorial-visualize-pie-1.png and b/docs/images/tutorial-visualize-pie-1.png differ diff --git a/docs/images/tutorial-visualize-pie-2.png b/docs/images/tutorial-visualize-pie-2.png index b4d41d996db67..6d16c2dab2bfb 100644 Binary files a/docs/images/tutorial-visualize-pie-2.png and b/docs/images/tutorial-visualize-pie-2.png differ diff --git a/docs/images/tutorial-visualize-pie-3.png b/docs/images/tutorial-visualize-pie-3.png index 4fb0c2c5ef459..324d8f8c07a29 100644 Binary files a/docs/images/tutorial-visualize-pie-3.png and b/docs/images/tutorial-visualize-pie-3.png differ diff --git a/docs/images/tutorial-visualize-wizard-step-1.png b/docs/images/tutorial-visualize-wizard-step-1.png index f9aa5635fb197..fa353ae528318 100644 Binary files a/docs/images/tutorial-visualize-wizard-step-1.png and b/docs/images/tutorial-visualize-wizard-step-1.png differ diff --git a/docs/images/tutorial-visualize-wizard-step-2.png b/docs/images/tutorial-visualize-wizard-step-2.png deleted file mode 100644 index 32a915e422337..0000000000000 Binary files a/docs/images/tutorial-visualize-wizard-step-2.png and /dev/null differ diff --git a/docs/infrastructure/metrics-explorer.asciidoc b/docs/infrastructure/metrics-explorer.asciidoc index 82520e99b42bd..008f7ee1bacc6 100644 --- a/docs/infrastructure/metrics-explorer.asciidoc +++ b/docs/infrastructure/metrics-explorer.asciidoc @@ -1,58 +1,70 @@ [role="xpack"] [[metrics-explorer]] -The metrics explorer allows you to easily visualize Metricbeat data and group it by arbitary attributes. This empowers you to visualize multiple metrics and can be a jumping off point for further investigations. +== Metrics Explorer + +Metrics Explorer allows you to visualize metrics data collected by Metricbeat and group it in various ways to visualize multiple metrics. +It can be a starting point for further investigations. [role="screenshot"] image::infrastructure/images/metrics-explorer-screen.png[Metrics Explorer in Kibana] [float] [[metrics-explorer-requirements]] -=== Metrics explorer requirements and considerations +=== Metrics Explorer requirements and considerations -* The Metric explorer assumes you have data collected from {metricbeat-ref}/metricbeat-overview.html[Metricbeat]. -* You will need read permissions on `metricbeat-*` or the metric index specified in the Infrastructure configuration UI. -* Metrics explorer uses the timestamp field set in the Infrastructure configuration UI. By default that is set to `@timestmap`. -* The interval for the X Axis is set to `auto`. The bucket size is determined by the time range. -* **Open in Visualize** requires the user to have access to the Visualize app, otherwise it will not be available. +* The Metrics Explorer uses data collected from {metricbeat-ref}/metricbeat-overview.html[Metricbeat]. +* You need read permissions on `metricbeat-*` or the metric index specified in the Infrastructure configuration UI. +* Metrics Explorer uses the timestamp field set in the Infrastructure configuration UI. +By default that is set to `@timestamp`. +* The interval for the X Axis is set to `auto`. +The bucket size is determined by the time range. +* *Open in Visualize* requires you to have access to the Visualize app, otherwise it is not available. [float] [[metrics-explorer-tutorial]] -=== Metrics explorer tutorial - -In this tutorial we are going to use the Metrics explorer to create system load charts for each host we are monitoring with Metricbeat. -Once we've explored the system load metrics, -we'll show you how to filter down to a specific host and start exploring outbound network traffic for each interface. -Before we get started, if you don't have any Metricbeat data, you'll need to head over to our -{metricbeat-ref}/metricbeat-overview.html[Metricbeat documentation] and learn how to install and start collection. - -1. Navigate to the Infrastructure UI in Kibana and select **Metrics Explorer** -The initial screen should be empty with the metric field selection open. -2. Start typing `system.load.1` and select the field. -Once you've selected the field, you can add additional metrics for `system.load.5` and `system.load.15`. -3. You should now have a chart with 3 different series for each metric. -By default, the metric explorer will take the average of each field. -To the left of the metric dropdown you will see the aggregation dropdown. -You can use this to change the aggregation. -For now, we'll leave it set to `Average`, but take some time to play around with the different aggregations. -4. To the right of the metric input field you will see **graph per** and a dropdown. -Enter `host.name` in this dropdown and select the field. -This input will create a chart for every value it finds in the selected field. -5. By now, your UI should look similar to the screenshot above. -If you only have one host, then it will display the chart across the entire screen. -For multiple hosts, the metric explorer divides the screen into three columns. -Configurations, you've explored your first metric! -6. Let's go for some bonus points. Select the **Actions** dropdown in the upper right hand corner of one of the charts. -Select **Add Filter** to change the KQL expression to filter for that specific host. -From here we can start exploring other metrics specific to this host. -7. Let's delete each of the system load metrics by clicking the little **X** icon next to each of them. -8. Set `system.network.out.bytes` as the metric. -Because `system.network.out.bytes` is a monotonically increasing number, we need to change the aggregation to `Rate`. -While this chart might appear correct, there is one critical problem: hosts have multiple interfaces. -9. To fix our chart, set the group by dropdown to `system.network.name`. -You should now see a chart per network interface. -10. Let's imagine you want to put one of these charts on a dashboard. -Click the **Actions** menu next to one of the interface charts and select **Open In Visualize**. -This will open the same chart in Time Series Visual Builder. From here you can save the chart and add it to a dashboard. - -Who's the Metrics explorer now? You are! +=== Metrics Explorer tutorial + +In this tutorial we'll use Metrics Explorer to view the system load metrics for each host we're monitoring with Metricbeat. +After that, we'll filter down to a specific host and explore the outbound traffic for each network interface. +Before we start, if you don't have any Metricbeat data, you'll need to head over to our +{metricbeat-ref}/metricbeat-overview.html[Metricbeat documentation] to install Metricbeat and start collecting data. + +1. When you have Metricbeat running and collecting data, open Kibana and navigate to *Infrastructure*. +The *Inventory* tab shows the host or hosts you are monitoring. + +2. Select the *Metrics Explorer* tab. +The initial configuration has the *Average* aggregation selected, the *of* field populated with some default metrics, and the *graph per* dropdown set to `Everything`. + +3. To select the metrics to view, firstly delete all the metrics currently shown in the *of* field by clicking the *X* by each metric name. +Then, in this field, start typing `system.load.1` and select this metric. +Also add metrics for `system.load.5` and `system.load.15`. +You will see a graph showing the average values of the metrics you selected. +In this step we'll leave the aggregation dropdown set to *Average* but you can try different values later if you like. + +4. In the *graph per* dropdown, enter `host.name` and select this field. +You will see a separate graph for each host you are monitoring. +If you are collecting metrics for multiple hosts, you will see something like the screenshot above. +If you only have metrics for a single host, you will see a single graph. +Congratulations! Either way, you've explored your first metric. + +5. Let's explore a bit further. +In the upper right hand corner of the graph for one of the hosts, select the *Actions* dropdown and click *Add Filter* to show ony the metrics for that host. +This adds a {kibana-ref}/kuery-query.html[Kibana Query Language] filter for `host.name` in the second row of the Metrics Explorer configuration. +If you only have one host, the graph will not change as you are already exploring metrics for a single host. + +6. Now you can start exploring some host-specific metrics. +First, delete each of the system load metrics in the *of* field by clicking the *X* by the metric name. +Then enter the metric `system.network.out.bytes` to explore the outbound network traffic. +This is a monotonically increasing value, so change the aggregation dropdown to `Rate`. + +7. Since hosts have multiple network interfaces, it is more meaningful to display one graph for each network interface. +To do this, select the *graph per* dropdown, start typing `system.network.name` and select this field. +You will now see a separate graph for each network interface. + +8. If you like, you can put one of these graphs in a dashboard. +Choose a graph, click the *Actions* dropdown and select *Open In Visualize*. +This opens the graph in {kibana-ref}/TSVB.html[TSVB]. +From here you can save the graph and add it to a dashboard as usual. + +Who's the Metrics Explorer now? You are! diff --git a/docs/management.asciidoc b/docs/management.asciidoc index 0b1ac4c8ae624..ee8fc0d72e713 100644 --- a/docs/management.asciidoc +++ b/docs/management.asciidoc @@ -17,8 +17,6 @@ include::management/index-patterns.asciidoc[] include::management/rollups/create_and_manage_rollups.asciidoc[] -include::management/rollups/visualize_rollup_data.asciidoc[] - include::management/index-lifecycle-policies/intro-to-lifecycle-policies.asciidoc[] include::management/index-lifecycle-policies/create-policy.asciidoc[] diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 1d6020e02dcc6..894f9fd7318a6 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -43,6 +43,7 @@ removes it from {kib} permanently. `dateFormat:scaled`:: The values that define the format to use to render ordered time-based data. Formatted timestamps must adapt to the interval between measurements. Keys are http://en.wikipedia.org/wiki/ISO_8601#Time_intervals[ISO8601 intervals]. `dateFormat:tz`:: The timezone that Kibana uses. The default value of `Browser` uses the timezone detected by the browser. +`dateNanosFormat`:: The format to use for displaying https://momentjs.com/docs/#/displaying/format/[pretty formatted dates] of {ref}/date_nanos.html[Elasticsearch date_nanos type]. `defaultIndex`:: The index to access if no index is set. The default is `null`. `fields:popularLimit`:: The top N most popular fields to show. `filterEditor:suggestValues`:: Set this property to `false` to prevent the filter editor from suggesting values for fields. @@ -132,6 +133,8 @@ The default is `_source`. the Visualize button in the field drop down. The default is `20`. `discover:sampleSize`:: The number of rows to show in the Discover table. `discover:sort:defaultOrder`:: The default sort direction for time-based index patterns. +`discover:searchOnPageLoad`:: Controls whether a search is executed when Discover first loads. +This setting does not have an effect when loading a saved search. `doc_table:hideTimeColumn`:: Hides the "Time" column in Discover and in all saved searches on dashboards. `doc_table:highlight`:: Highlights results in Discover and saved searches on dashboards. Highlighting slows requests when diff --git a/docs/management/rollups/create_and_manage_rollups.asciidoc b/docs/management/rollups/create_and_manage_rollups.asciidoc index ee25d93e032fe..06983c01f926d 100644 --- a/docs/management/rollups/create_and_manage_rollups.asciidoc +++ b/docs/management/rollups/create_and_manage_rollups.asciidoc @@ -1,90 +1,148 @@ [role="xpack"] [[data-rollups]] -== Working with rollup indices +== Rollup jobs -The {ref}/xpack-rollup.html[rollup feature in {es}] -enables you to summarize historical data and store it compactly for future analysis, -so you can query, aggregate, and visualize the data using a fraction of the storage. -This is a good way to keep costs down when you need to store months or years of -historical data for use in visualizations and reports. -{kib} supports rolled up data in two ways: +A rollup job is a periodic task that aggregates data from indices specified +by an index pattern and rolls it into a new index. Rollup indices are a good way to +compactly store months or years of historical +data for use in visualizations and reports. -* You can create and manage a rollup job in Management -* You can create a visualization using rolled up data in -Visualize and view it in a dashboard +You’ll find *Rollup Jobs* under *Management > Elasticsearch*. With this UI, +you can: - -[role="xpack"] -[[create-and-manage-rollup-job]] -=== Create and manage rollup jobs - -In Management, you'll find a UI for viewing, creating, starting, stopping, and -deleting rollup jobs. A rollup job is a periodic task that summarizes data from -indices specified by an index pattern and rolls it into a new index. To navigate -to the UI, go to *Management*, and under *Elasticsearch*, click *Rollup Jobs*. +* <> +* <> [role="screenshot"] image::images/management_rollup_list.png[][List of currently active rollup jobs] +Before using this feature, you should be familiar with how rollups work. +{ref}/xpack-rollup.html[Rolling up historical data] is a good source for more detailed information. + [float] -[[create-rollup-job]] -==== Creating a rollup job +[[create-and-manage-rollup-job]] +=== Create a rollup job -{kib} makes it easy for you to create a rollup job by walking you through the -process step by step. The first step is to define the job logistics. These include -the name of the rollup job, the index or indices to summarize, and the output rollup index. +{kib} makes it easy for you to create a rollup job by walking you through +the process. You fill in the name, data flow, and how often you want to roll +up the data. Then you define a date histogram aggregation for the rollup job +and optionally terms, histogram, and metrics aggregations. -The index pattern cannot match the name of the output rollup index. For example, -if your index pattern is `metricbeat-*`, you cannot name your rollup index -`metricbeat-rollup`. Otherwise, the job will attempt to capture the data in the -rollup index. +When defining the index pattern, you must enter a name that is different than +the output rollup index. Otherwise, the job +will attempt to capture the data in the rollup index. For example, if your index pattern is `metricbeat-*`, +you can name your rollup index `rollup-metricbeat`, but not `metricbeat-rollup`. [role="screenshot"] image::images/management_create_rollup_job.png[][Wizard that walks you through creation of a rollup job] -You must set a schedule for the rollup job: how often to collect the data, -the number of documents to roll up at a time, and the duration of its latency. -The latency buffer field is provided to protect against the late arrival of data -from Beats or other sources. By delaying the rollup for the specified amount of -time from when the job starts, you allow for the inclusion of late-arriving data -in the rollup. +[float] +[[manage-rollup-job]] +=== Start, stop, and delete rollup jobs -In the subsequent phases, you define the Date Histogram aggregation for the job -and optionally the Terms and Histogram aggregations. +Once you’ve saved a rollup job, you’ll see it the *Rollup Jobs* overview page, +where you can drill down for further investigation. The *Manage* menu in +the lower right enables you to start, stop, and delete the rollup job. +You must first stop a rollup job before deleting it. -* The Date Histogram aggregation defines the time intervals for summarizing the data. -This value is important because you cannot search the data with a smaller value -than this interval. However, you can aggregate buckets in a larger time interval. +[role="screenshot"] +image::images/management_rollup_job_details.png[][Rollup job details] -* The Terms histogram enables you to split the time buckets into sub buckets for -term field values. +You can’t change a rollup job after you’ve created it. To select additional fields +or redefine terms, you must delete the existing job, and then create a new one +with the updated specifications. Be sure to use a different name for the new rollup +job—reusing the same name can lead to problems with mismatched job configurations. +You can read more at {ref}/rollup-job-config.html[rollup job configuration]. -* The Histogram aggregation enables you to split the time buckets into sub buckets -for numeric field values. +[float] +=== Try it: Create and visualize rolled up data -The final step is to specify the fields for calculating metrics. For each selected -field, you can collect any or all of the following: value count, average, sum, min, and max. +This example creates a rollup job to capture log data from sample web logs. +To follow along, add the <>. -Before you save the rollup job, {kib} displays a summary of the rollup job for -validation. +In this example, you want data that is older than 7 days in the target index pattern `kibana_sample_data_logs` +to roll up once a day into the index `rollup_logstash`. You’ll bucket the +rolled up data on an hourly basis, using 60m for the time bucket configuration. +This allows for more granular queries, such as 2h and 12h. [float] -[[manage-rollup-job]] -==== Managing rollup jobs +==== Create the rollup job -Selecting a job on the *Rollup jobs* page shows its details. The Manage menu in -the lower right enables you to start, stop, and delete the rollup job. -You must first stop a rollup job before deleting it. +As you walk through the *Create rollup job* UI, enter the data shown in +the table below. The terms, histogram, and metrics fields reflect +the key information to retain in the rolled up data: where visitors are from (geo.src), +what operating system they are using (machine.os.keyword), +and how much data is being sent (bytes). + +|=== +|*Field* |*Value* + +|Name +|logs_job + +|Index pattern +|`kibana_sample_data_logs` + +|Rollup index name +|`rollup_logstash` + +|Frequency +|Every day at midnight + +|Page size +|1000 + +|Delay (latency buffer)|7d + +|Date field +|@timestamp + +|Time bucket size +|60m + +|Time zone +|UTC + +|Terms +|geo.src, machine.os.keyword + +|Histogram +|bytes, memory + +|Histogram interval +|1000 + +|Metrics +|bytes (average) +|=== + + +You can now use the rolled up data for analysis at a fraction of the storage cost +of the original index. The original data can live side by side with the new +rollup index, or you can remove or archive it using <>. + +[float] +==== Visualize the rolled up data + +Your next step is to visualize your rolled up data in a vertical bar chart. +Most visualizations support rolled up data, with the exception of Timelion, TSVB, and Vega visualizations. + +Using the information from the example rollup configuration described above, +you can use `rollup_logstash` to match the rolled up index pattern, +and `kibana_sample_data_logs` to match the index pattern for raw data. +The notation for a combination index pattern with both raw and rolled up data +is `rollup_logstash,kibana_sample_data_logs`. [role="screenshot"] -image::images/management_rollup_job_details.png[][Rollup job details] +image::images/management_rollup_job_vis.png[][Visualization of rolled up data] + +You can then create a dashboard that contains visualizations of the rolled up +data, raw data, or both. See <> +for more information. + +[role="screenshot"] +image::images/management_rollup_job_dashboard.png[][Dashboard with rolled up data] -You can start, stop, and delete an existing rollup job, but edits are not supported. -If you want to make any changes, delete the existing job and create a new one with -the updated specifications. Be sure to use a different name for the new rollup job; -reusing the same name could lead to problems with mismatched job configurations. -More about logistical details for the {ref}/rollup-job-config.html[rollup job configuration] -can be found in the {es} documentation. diff --git a/docs/maps/images/read-only-badge.png b/docs/maps/images/read-only-badge.png new file mode 100644 index 0000000000000..50289ea80f60c Binary files /dev/null and b/docs/maps/images/read-only-badge.png differ diff --git a/docs/maps/maps-getting-started.asciidoc b/docs/maps/maps-getting-started.asciidoc index 4925ec49897df..cec1938881416 100644 --- a/docs/maps/maps-getting-started.asciidoc +++ b/docs/maps/maps-getting-started.asciidoc @@ -13,6 +13,16 @@ light to dark. [role="screenshot"] image::maps/images/sample_data_web_logs.png[] +[float] +[[maps-read-only-access]] +NOTE: If you have insufficient privileges to create or save maps, a read-only icon +appears in the application header. The buttons to create new maps or edit +existing maps won't be visible. For more information on granting access to +Kibana see <>. + +[role="screenshot"] +image::maps/images/read-only-badge.png[Example of Maps' read only access indicator in Kibana's header] + [float] === Prerequisites Before you start this tutorial, <>. Each diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index 9598c33dfaa11..e833911fab2dd 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -36,6 +36,12 @@ for example, `logstash-*`. === Settings changes // tag::notable-breaking-changes[] +[float] +==== Legacy browsers are now rejected by default +*Details:* `csp.strict` is now enabled by default, so Kibana will fail to load for older, legacy browsers that do not enforce basic Content Security Policy protections - notably Internet Explorer 11. + +*Impact:* To allow Kibana to function for these legacy browsers, set `csp.strict: false`. Since this is about enforcing a security protocol, we *strongly discourage* disabling `csp.strict` unless it is critical that you support Internet Explorer 11. + [float] ==== Default logging timezone is now the system's timezone *Details:* In prior releases the timezone used in logs defaulted to UTC. We now use the host machine's timezone by default. diff --git a/docs/ml/creating-df-kib.asciidoc b/docs/ml/creating-df-kib.asciidoc index 9c51fd29bc646..872c9d5dda8b3 100644 --- a/docs/ml/creating-df-kib.asciidoc +++ b/docs/ml/creating-df-kib.asciidoc @@ -1,3 +1,4 @@ +[role="xpack"] [[creating-df-kib]] == Creating {dataframe-transforms} diff --git a/docs/ml/creating-jobs.asciidoc b/docs/ml/creating-jobs.asciidoc index e3bdde6514686..98f175041719a 100644 --- a/docs/ml/creating-jobs.asciidoc +++ b/docs/ml/creating-jobs.asciidoc @@ -1,8 +1,8 @@ [role="xpack"] [[ml-jobs]] -== Creating machine learning jobs +== Creating {anomaly-jobs} -Machine learning jobs contain the configuration information and metadata +{anomaly-jobs-cap} contain the configuration information and metadata necessary to perform an analytics task. {kib} provides the following wizards to make it easier to create jobs: @@ -33,7 +33,7 @@ appears: [role="screenshot"] image::ml/images/ml-data-recognizer-sample.jpg[A screenshot of the {kib} sample data web log job creation wizard] -TIP: Alternatively, after you load a sample data set on the {kib} home page, you can click *View data* > *ML jobs*. There are {ml} jobs for both the sample eCommerce orders data set and the sample web logs data set. +TIP: Alternatively, after you load a sample data set on the {kib} home page, you can click *View data* > *ML jobs*. There are {anomaly-jobs} for both the sample eCommerce orders data set and the sample web logs data set. If you use {filebeat-ref}/index.html[{filebeat}] to ship access logs from your @@ -57,17 +57,17 @@ wizards appear: [role="screenshot"] image::ml/images/ml-data-recognizer-metricbeat.jpg[A screenshot of the {metricbeat} job creation wizards] -These wizards create {ml} jobs, dashboards, searches, and visualizations that -are customized to help you analyze your {auditbeat}, {filebeat}, and +These wizards create {anomaly-jobs}, dashboards, searches, and visualizations +that are customized to help you analyze your {auditbeat}, {filebeat}, and {metricbeat} data. [NOTE] =============================== If your data is located outside of {es}, you cannot use {kib} to create your jobs and you cannot use {dfeeds} to retrieve your data in real time. -Machine learning analysis is still possible, however, by using APIs to +{anomal-detect-cap} is still possible, however, by using APIs to create and manage jobs and post data to them. For more information, see -{ref}/ml-apis.html[Machine Learning APIs]. +{ref}/ml-apis.html[{ml-cap} {anomaly-detect} APIs]. =============================== //// diff --git a/docs/ml/index.asciidoc b/docs/ml/index.asciidoc index 4c3c5d4617895..eac51d06a51f9 100644 --- a/docs/ml/index.asciidoc +++ b/docs/ml/index.asciidoc @@ -1,35 +1,36 @@ [role="xpack"] [[xpack-ml]] -= Machine Learning += {ml-cap} [partintro] -- As datasets increase in size and complexity, the human effort required to inspect dashboards or maintain rules for spotting infrastructure problems, -cyber attacks, or business issues becomes impractical. The Elastic {ml-features} -automatically model the normal behavior of your time series data — learning -trends, periodicity, and more — in real time to identify anomalies, streamline -root cause analysis, and reduce false positives. +cyber attacks, or business issues becomes impractical. The Elastic {ml} +{anomaly-detect} feature automatically models the normal behavior of your time +series data — learning trends, periodicity, and more — in real time to identify +anomalies, streamline root cause analysis, and reduce false positives. -The {ml-features} run in and scale with {es}, and include an -intuitive UI on the {kib} *Machine Learning* page for creating anomaly detection -jobs and understanding results. +{anomaly-detect-cap} runs in and scales with {es}, and includes an +intuitive UI on the {kib} *Machine Learning* page for creating {anomaly-jobs} +and understanding results. If you have a basic license, you can use the *Data Visualizer* to learn more about your data. In particular, if your data is stored in {es} and contains a time field, you can use the *Data Visualizer* to identify possible fields for -{ml} analysis: +{anomaly-detect}: [role="screenshot"] image::ml/images/ml-data-visualizer-sample.jpg[Data Visualizer for sample flight data] -experimental[] You can also upload a CSV, NDJSON, or log file (up to 100 MB in size). -The {ml-features} identify the file format and field mappings. You can then -optionally import that data into an {es} index. +experimental[] You can also upload a CSV, NDJSON, or log file (up to 100 MB in +size). The *Data Visualizer* identifies the file format and field mappings. You +can then optionally import that data into an {es} index. -If you have a trial or platinum license, you can <> -and manage jobs and {dfeeds} from the *Job Management* pane: +If you have a trial or platinum license, you can +<> and manage jobs and {dfeeds} from the *Job +Management* pane: [role="screenshot"] image::ml/images/ml-job-management.jpg[Job Management] @@ -42,7 +43,7 @@ You can use the *Settings* pane to create and edit image::ml/images/ml-settings.jpg[Calendar Management] The *Anomaly Explorer* and *Single Metric Viewer* display the results of your -{ml} jobs. For example: +{anomaly-jobs}. For example: [role="screenshot"] image::ml/images/ml-single-metric-viewer.jpg[Single Metric Viewer] @@ -56,17 +57,17 @@ occurring in your operational environment at that time: image::ml/images/ml-annotations-list.jpg[Single Metric Viewer with annotations] In some circumstances, annotations are also added automatically. For example, if -the {ml} analytics detect that there is missing data, it annotates the affected +the {anomaly-job} detects that there is missing data, it annotates the affected time period. For more information, see -{stack-ov}/ml-delayed-data-detection.html[Handling delayed data]. -The *Job Management* pane shows the full list of annotations for each job. +{stack-ov}/ml-delayed-data-detection.html[Handling delayed data]. The +*Job Management* pane shows the full list of annotations for each job. -NOTE: The {kib} {ml-features} use pop-ups. You must configure your -web browser so that it does not block pop-up windows or create an exception for -your {kib} URL. +NOTE: The {kib} {ml-features} use pop-ups. You must configure your web +browser so that it does not block pop-up windows or create an exception for your +{kib} URL. -For more information about {ml}, see -{stack-ov}/xpack-ml.html[Machine learning in the {stack}]. +For more information about the {anomaly-detect} feature, see +{stack-ov}/xpack-ml.html[{ml-cap} {anomaly-detect}]. -- diff --git a/docs/ml/job-tips.asciidoc b/docs/ml/job-tips.asciidoc index 2e5df33d727c2..3451d45bd17aa 100644 --- a/docs/ml/job-tips.asciidoc +++ b/docs/ml/job-tips.asciidoc @@ -5,16 +5,17 @@ Job tips ++++ -When you are creating a job in {kib}, the job creation wizards can provide -advice based on the characteristics of your data. By heeding these suggestions, -you can create jobs that are more likely to produce insightful {ml} results. +When you create an {anomaly-job} in {kib}, the job creation wizards can +provide advice based on the characteristics of your data. By heeding these +suggestions, you can create jobs that are more likely to produce insightful {ml} +results. [[bucket-span]] ==== Bucket span The bucket span is the time interval that {ml} analytics use to summarize and -model data for your job. When you create a job in {kib}, you can choose to -estimate a bucket span value based on your data characteristics. +model data for your job. When you create an {anomaly-job} in {kib}, you can +choose to estimate a bucket span value based on your data characteristics. NOTE: The bucket span must contain a valid time interval. For more information, see {ref}/ml-job-resource.html#ml-analysisconfig[Analysis configuration objects]. @@ -22,7 +23,7 @@ see {ref}/ml-job-resource.html#ml-analysisconfig[Analysis configuration objects] If you choose a value that is larger than one day or is significantly different than the estimated value, you receive an informational message. For more information about choosing an appropriate bucket span, see -{xpack-ref}/ml-buckets.html[Buckets]. +{stack-ov}/ml-buckets.html[Buckets]. [[cardinality]] ==== Cardinality @@ -40,14 +41,14 @@ job uses more memory resources. In particular, if the cardinality of the Likewise if you are performing population analysis and the cardinality of the `over_field_name` is below 10, you are advised that this might not be a suitable field to use. For more information, see -{xpack-ref}/ml-configuring-pop.html[Performing Population Analysis]. +{stack-ov}/ml-configuring-pop.html[Performing Population Analysis]. [[detectors]] ==== Detectors -Each job must have one or more _detectors_. A detector applies an analytical -function to specific fields in your data. If your job does not contain a -detector or the detector does not contain a +Each {anomaly-job} must have one or more _detectors_. A detector applies an +analytical function to specific fields in your data. If your job does not +contain a detector or the detector does not contain a {stack-ov}/ml-functions.html[valid function], you receive an error. If a job contains duplicate detectors, you also receive an error. Detectors are @@ -57,9 +58,9 @@ duplicates if they have the same `function`, `field_name`, `by_field_name`, [[influencers]] ==== Influencers -When you create a job, you can specify _influencers_, which are also sometimes -referred to as _key fields_. Picking an influencer is strongly recommended for -the following reasons: +When you create an {anomaly-job}, you can specify _influencers_, which are also +sometimes referred to as _key fields_. Picking an influencer is strongly +recommended for the following reasons: * It allows you to more easily assign blame for the anomaly * It simplifies and aggregates the results @@ -78,11 +79,11 @@ The job creation wizards in {kib} can suggest which fields to use as influencers [[model-memory-limits]] ==== Model memory limits -For each job, you can optionally specify a `model_memory_limit`, which is the -approximate maximum amount of memory resources that are required for analytical -processing. The default value is 1 GB. Once this limit is approached, data -pruning becomes more aggressive. Upon exceeding this limit, new entities are not -modeled. +For each {anomaly-job}, you can optionally specify a `model_memory_limit`, which +is the approximate maximum amount of memory resources that are required for +analytical processing. The default value is 1 GB. Once this limit is approached, +data pruning becomes more aggressive. Upon exceeding this limit, new entities +are not modeled. You can also optionally specify the `xpack.ml.max_model_memory_limit` setting. By default, it's not set, which means there is no upper bound on the acceptable @@ -92,9 +93,9 @@ TIP: If you set the `model_memory_limit` too high, it will be impossible to open the job; jobs cannot be allocated to nodes that have insufficient memory to run them. -If the estimated model memory limit for a job is greater than the model memory -limit for the job or the maximum model memory limit for the cluster, the job -creation wizards in {kib} generate a warning. If the estimated memory +If the estimated model memory limit for an {anomaly-job} is greater than the +model memory limit for the job or the maximum model memory limit for the cluster, +the job creation wizards in {kib} generate a warning. If the estimated memory requirement is only a little higher than the `model_memory_limit`, the job will probably produce useful results. Otherwise, the actions you take to address these warnings vary depending on the resources available in your cluster: diff --git a/docs/monitoring/cluster-alerts.asciidoc b/docs/monitoring/cluster-alerts.asciidoc index e10e4835f1c42..f36cbaf4d54ff 100644 --- a/docs/monitoring/cluster-alerts.asciidoc +++ b/docs/monitoring/cluster-alerts.asciidoc @@ -46,8 +46,18 @@ include::cluster-alerts-license.asciidoc[] ==== Email Notifications To receive email notifications for the Cluster Alerts: -1. Configure an email account as described in -{stack-ov}/actions-email.html#configuring-email[Configuring Email Accounts]. -2. Configure the `xpack.monitoring.cluster_alerts.email_notifications.email_address` setting in `kibana.yml` with your email address. +. Configure an email account as described in +{stack-ov}/actions-email.html#configuring-email[Configuring email accounts]. +. Configure the +`xpack.monitoring.cluster_alerts.email_notifications.email_address` setting in +`kibana.yml` with your email address. ++ +-- +TIP: If you have separate production and monitoring clusters and separate {kib} +instances for those clusters, you must put the +`xpack.monitoring.cluster_alerts.email_notifications.email_address` setting in +the {kib} instance that is associated with the production cluster. + +-- Email notifications are sent only when Cluster Alerts are triggered and resolved. diff --git a/docs/settings/code-settings.asciidoc b/docs/settings/code-settings.asciidoc index 57cbaa53f1d61..e01858e6230b1 100644 --- a/docs/settings/code-settings.asciidoc +++ b/docs/settings/code-settings.asciidoc @@ -35,11 +35,11 @@ Whitelist of protocols for git clone address. Defaults to `[ 'https', 'git', 'ss `xpack.code.security.enableGitCertCheck`:: Whether enable HTTPS certificate check when clone from HTTPS URL. -`xpack.code.disableIndexScheduler`:: -Whether automatic index update is disabled. Defaults to `true`. - `xpack.code.maxWorkspace`:: Maximal number of workspaces each language server allows to span. Defaults to `5`. `xpack.code.codeNodeUrl`:: URL of the Code node. This config is only needed when multiple Kibana instances are set up as a cluster. Defaults to `` + +`xpack.code.verbose`:: +Set this config to `true` to log all events. Defaults to `false` diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 8434ed469695d..2ba1369369a66 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -13,17 +13,18 @@ You do not need to configure any additional settings to use the ==== General security settings `xpack.security.enabled`:: -Set to `true` (default) to enable {security-features}. + -+ -Do not set this to `false`. To disable {security-features} entirely, see -{ref}/security-settings.html[{es} security settings]. + -+ -If set to `false` in `kibana.yml`, the login form, user and role management screens, and -authorization using <> are disabled. + +By default, {kib} automatically detects whether to enable the +{security-features} based on the license and whether {es} {security-features} +are enabled. + +Do not set this to `false`; it disables the login form, user and role management +screens, and authorization using <>. To disable +{security-features} entirely, see +{ref}/security-settings.html[{es} security settings]. + `xpack.security.audit.enabled`:: -Set to `true` to enable audit logging for security events. This is set to `false` by default. -For more details see <>. +Set to `true` to enable audit logging for security events. By default, it is set +to `false`. For more details see <>. [float] [[security-ui-settings]] diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 316a4bda65c63..d8ea76d202595 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -32,7 +32,7 @@ instances of `{nonce}` will be replaced with an automatically generated nonce at load time. We strongly recommend that you keep the default CSP rules that ship with Kibana. -`csp.strict:`:: *Default: `false`* Blocks access to Kibana to any browser that +`csp.strict:`:: *Default: `true`* Blocks access to Kibana to any browser that does not enforce even rudimentary CSP rules. In practice, this will disable support for older, less safe browsers like Internet Explorer. diff --git a/docs/siem/images/ml-ui.png b/docs/siem/images/ml-ui.png new file mode 100644 index 0000000000000..168ff6363186a Binary files /dev/null and b/docs/siem/images/ml-ui.png differ diff --git a/docs/siem/index.asciidoc b/docs/siem/index.asciidoc index 4606f351d6d6e..6d8aea1f7fe63 100644 --- a/docs/siem/index.asciidoc +++ b/docs/siem/index.asciidoc @@ -52,3 +52,4 @@ SIEM can ingest and normalize events from ECS-compatible data sources. include::siem-ui.asciidoc[] +include::machine-learning.asciidoc[] diff --git a/docs/siem/machine-learning.asciidoc b/docs/siem/machine-learning.asciidoc new file mode 100644 index 0000000000000..dd1016d8550ef --- /dev/null +++ b/docs/siem/machine-learning.asciidoc @@ -0,0 +1,16 @@ +[role="xpack"] +[[machine-learning]] +== Anomaly Detection with Machine Learning + +For *https://www.elastic.co/cloud/elasticsearch-service/signup[Free Trial]* +and *https://www.elastic.co/subscriptions[Platinum License]* deployments, +Machine Learning functionality is available throughout the SIEM app. You can +view the details of detected anomalies within the `Anomalies` table widget +shown on the Hosts, Network and associated Details pages, or even narrow to +the specific daterange of an anomaly from the `Max Anomaly Score` details in +the overview of the Host and IP Details pages. Each of these interfaces also +offer the ability to drag and drop details of the anomaly to Timeline, such +as the `Entity` itself, or any of the associated `Influencers`. + +[role="screenshot"] +image::siem/images/ml-ui.png[Machine Learning - Max Anomaly Score] diff --git a/docs/uptime-guide/install.asciidoc b/docs/uptime-guide/install.asciidoc index ffb310913f7ce..c8c8220225002 100644 --- a/docs/uptime-guide/install.asciidoc +++ b/docs/uptime-guide/install.asciidoc @@ -63,7 +63,7 @@ image::images/uptime-setup.png[Installation instructions on the Uptime page in K * Index patterns tell Kibana which Elasticsearch indices you want to explore. The Uptime UI requires a +heartbeat-{short-version}*+ index pattern. -If you have configured a different index pattern, you can use {ref}/indices-aliases.html[field aliases] to ensure data is recognized by the UI. +If you have configured a different index pattern, you can use {ref}/indices-aliases.html[index aliases] to ensure data is recognized by the UI. After you install and configure Heartbeat, the {kibana-ref}/xpack-uptime.html[Uptime UI] will automatically populate with the Heartbeat monitors. diff --git a/docs/visualize.asciidoc b/docs/visualize.asciidoc index 88e1ccdf8ab64..535d07cedf7e2 100644 --- a/docs/visualize.asciidoc +++ b/docs/visualize.asciidoc @@ -140,6 +140,8 @@ Aggregation Execution Order, and You]. include::visualize/saving.asciidoc[] +include::visualize/visualize_rollup_data.asciidoc[] + include::visualize/xychart.asciidoc[] include::visualize/controls.asciidoc[] diff --git a/docs/management/rollups/visualize_rollup_data.asciidoc b/docs/visualize/visualize_rollup_data.asciidoc similarity index 52% rename from docs/management/rollups/visualize_rollup_data.asciidoc rename to docs/visualize/visualize_rollup_data.asciidoc index 5f64e4bdd0ac5..c2707e2d67102 100644 --- a/docs/management/rollups/visualize_rollup_data.asciidoc +++ b/docs/visualize/visualize_rollup_data.asciidoc @@ -1,6 +1,6 @@ [role="xpack"] [[visualize-rollup-data]] -=== Create a visualization using rolled up data +== Using rolled up data in a visualization beta[] @@ -8,9 +8,9 @@ You can visualize your rolled up data in a variety of charts, tables, maps, and more. Most visualizations support rolled up data, with the exception of Timelion, TSVB, and Vega visualizations. -You create an index pattern for rolled up data the same way you do for any data, -in *Management > Kibana > Index patterns*. Clicking *Create index pattern* includes -an item for creating a rollup index pattern, if a rollup index is detected in the cluster. +To get started, go to *Management > Kibana > Index patterns.* +If a rollup index is detected in the cluster, *Create index pattern* +includes an item for creating a rollup index pattern. [role="screenshot"] image::images/management_create_rollup_menu.png[Create index pattern menu] @@ -18,17 +18,8 @@ image::images/management_create_rollup_menu.png[Create index pattern menu] You can match an index pattern to only rolled up data, or mix both rolled up and raw data to visualize all data together. An index pattern can match only one rolled up index, not multiple. There is no restriction on the number of standard -indices that an index pattern can match. - -Combination index patterns use the same -notation as other multiple indices in {es}. To match multiple indices to create a -combination index pattern, use a comma to separate the names, with no space after the comma. -The notation for wildcards (`*`) and the ability to "exclude" (`-`) also apply -(for example, `test*,-test3`). - -When creating an index pattern, you’re asked to set a time field for filtering. -With a rollup index, the time filter field is the same field used for -the rolled up date histogram aggregation. +indices that an index pattern can match. When matching multiple indices, +use a comma to separate the names, with no space after the comma. Keep the following in mind when creating a visualization from rolled up data: @@ -39,15 +30,14 @@ numeric field values or terms. You can ask for a time aggregation that takes several time buckets and combines them to lower granularity. For example, if the rollup job was aggregated by hours, you can ask for buckets of days. -The data represented in this visualization comes from a rollup index and -standard indices. +The following visualization of rolled up data shows the date histogram +interval multiple and the limited metrics aggregations. [role="screenshot"] image::images/management_rollups_visualization.png[][Rollups in visualizations] -You can mix rollup visualizations and regular visualizations in a dashboard. -The following dashboard shows this mix, along with a field filter. Note -that not all queries and filters are supported by rollups. +Dashboards can have a mixture of rollup visualizations and regular visualizations, +as shown in the following figure. Note that not all queries and filters support rollups. [role="screenshot"] image::images/management_rolled_dashboard.png[][Rollups in dashboards] diff --git a/package.json b/package.json index 2f8a07ab7b29a..02f476a62810c 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@babel/register": "7.4.4", "@elastic/charts": "^7.2.1", "@elastic/datemath": "5.0.2", - "@elastic/eui": "13.0.0", + "@elastic/eui": "13.1.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "2.3.3", @@ -141,6 +141,7 @@ "brace": "0.11.1", "cache-loader": "^4.0.1", "chalk": "^2.4.1", + "check-disk-space": "^2.1.0", "color": "1.0.3", "commander": "2.20.0", "compare-versions": "3.4.0", @@ -150,7 +151,6 @@ "d3": "3.5.17", "d3-cloud": "1.2.5", "del": "^4.0.0", - "dragula": "3.7.2", "elasticsearch": "^16.2.0", "elasticsearch-browser": "^16.2.0", "encode-uri-query": "1.0.1", @@ -197,7 +197,6 @@ "moment-timezone": "^0.5.14", "mustache": "2.3.2", "ngreact": "0.5.1", - "no-ui-slider": "1.2.0", "node-fetch": "1.7.3", "opn": "^5.4.0", "oppsy": "^2.0.0", diff --git a/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js b/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js index 5c27a204ef263..fde3d063caaa6 100644 --- a/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js +++ b/packages/kbn-es-query/src/es_query/__tests__/build_es_query.js @@ -60,10 +60,10 @@ describe('build query', function () { bool: { must: [ decorateQuery(luceneStringToDsl('bar:baz'), config.queryStringOptions), - { match_all: {} }, ], filter: [ toElasticsearchQuery(fromKueryExpression('extension:jpg'), indexPattern), + { match_all: {} }, ], should: [], must_not: [], @@ -90,9 +90,8 @@ describe('build query', function () { bool: { must: [ decorateQuery(luceneStringToDsl('extension:jpg'), config.queryStringOptions), - { match_all: {} }, ], - filter: [], + filter: [{ match_all: {} }], should: [], must_not: [], } @@ -122,9 +121,11 @@ describe('build query', function () { bool: { must: [ decorateQuery(luceneStringToDsl('@timestamp:"2019-03-23T13:18:00"'), config.queryStringOptions, config.dateFormatTZ), + ], + filter: [ + toElasticsearchQuery(fromKueryExpression('@timestamp:"2019-03-23T13:18:00"'), indexPattern, config), { match_all: {} } ], - filter: [toElasticsearchQuery(fromKueryExpression('@timestamp:"2019-03-23T13:18:00"'), indexPattern, config)], should: [], must_not: [], } diff --git a/packages/kbn-es-query/src/es_query/__tests__/from_filters.js b/packages/kbn-es-query/src/es_query/__tests__/from_filters.js index 53016f33dc6cd..59e5f4d6faf8a 100644 --- a/packages/kbn-es-query/src/es_query/__tests__/from_filters.js +++ b/packages/kbn-es-query/src/es_query/__tests__/from_filters.js @@ -52,7 +52,7 @@ describe('build query', function () { const result = buildQueryFromFilters(filters); - expect(result.must).to.eql(expectedESQueries); + expect(result.filter).to.eql(expectedESQueries); }); it('should place negated filters in the must_not clause', function () { @@ -86,7 +86,7 @@ describe('build query', function () { const result = buildQueryFromFilters(filters); - expect(result.must).to.eql(expectedESQueries); + expect(result.filter).to.eql(expectedESQueries); }); it('should migrate deprecated match syntax', function () { @@ -105,7 +105,7 @@ describe('build query', function () { const result = buildQueryFromFilters(filters); - expect(result.must).to.eql(expectedESQueries); + expect(result.filter).to.eql(expectedESQueries); }); it('should not add query:queryString:options to query_string filters', function () { @@ -119,7 +119,7 @@ describe('build query', function () { const result = buildQueryFromFilters(filters); - expect(result.must).to.eql(expectedESQueries); + expect(result.filter).to.eql(expectedESQueries); }); }); }); diff --git a/packages/kbn-es-query/src/es_query/from_filters.js b/packages/kbn-es-query/src/es_query/from_filters.js index 07f3211b3fc55..b8193b7469a20 100644 --- a/packages/kbn-es-query/src/es_query/from_filters.js +++ b/packages/kbn-es-query/src/es_query/from_filters.js @@ -61,7 +61,8 @@ const cleanFilter = function (filter) { export function buildQueryFromFilters(filters = [], indexPattern, ignoreFilterIfFieldNotInIndex) { return { - must: filters + must: [], + filter: filters .filter(filterNegate(false)) .filter(filter => !ignoreFilterIfFieldNotInIndex || filterMatchesIndex(filter, indexPattern)) .map(translateToQuery) @@ -69,7 +70,6 @@ export function buildQueryFromFilters(filters = [], indexPattern, ignoreFilterIf .map(filter => { return migrateFilter(filter, indexPattern); }), - filter: [], should: [], must_not: filters .filter(filterNegate(true)) diff --git a/packages/kbn-es-query/src/filters/index.d.ts b/packages/kbn-es-query/src/filters/index.d.ts index c46a767e38ea4..39f30fa6e7dfe 100644 --- a/packages/kbn-es-query/src/filters/index.d.ts +++ b/packages/kbn-es-query/src/filters/index.d.ts @@ -17,12 +17,16 @@ * under the License. */ -import { Field, IndexPattern } from 'ui/index_patterns'; import { CustomFilter, ExistsFilter, PhraseFilter, PhrasesFilter, RangeFilter } from './lib'; import { RangeFilterParams } from './lib/range_filter'; export * from './lib'; +// We can't import the real types from the data plugin, so need to either duplicate +// them here or figure out another solution, perhaps housing them in this package +type Field = any; +type IndexPattern = any; + export function buildExistsFilter(field: Field, indexPattern: IndexPattern): ExistsFilter; export function buildPhraseFilter( diff --git a/packages/kbn-es-query/src/kuery/ast/ast.d.ts b/packages/kbn-es-query/src/kuery/ast/ast.d.ts index 484abac809bcd..915c024f2ab48 100644 --- a/packages/kbn-es-query/src/kuery/ast/ast.d.ts +++ b/packages/kbn-es-query/src/kuery/ast/ast.d.ts @@ -21,8 +21,6 @@ * WARNING: these typings are incomplete */ -import { StaticIndexPattern } from 'ui/index_patterns'; - export type KueryNode = any; export interface KueryParseOptions { @@ -46,6 +44,6 @@ export function fromKueryExpression( parseOptions?: KueryParseOptions ): KueryNode; -export function toElasticsearchQuery(node: KueryNode, indexPattern: StaticIndexPattern): JsonObject; +export function toElasticsearchQuery(node: KueryNode, indexPattern: any): JsonObject; export function doesKueryExpressionHaveLuceneSyntaxError(expression: string): boolean; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 3d2b9956a64c5..e602fbe25d0f2 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -32,6 +32,7 @@ const { const { createCliError } = require('./errors'); const { promisify } = require('util'); const treeKillAsync = promisify(require('tree-kill')); +const { parseSettings, SettingsFilter } = require('./settings'); // listen to data on stream until map returns anything but undefined const first = (stream, map) => @@ -250,9 +251,13 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const args = extractConfigFiles(options.esArgs || [], installPath, { - log: this._log, - }).reduce((acc, cur) => acc.concat(['-E', cur]), []); + const args = parseSettings( + extractConfigFiles(options.esArgs || [], installPath, { log: this._log }), + { filter: SettingsFilter.NonSecureOnly } + ).reduce( + (acc, [settingName, settingValue]) => acc.concat(['-E', `${settingName}=${settingValue}`]), + [] + ); this._log.debug('%s %s', ES_BIN, args.join(' ')); diff --git a/packages/kbn-es/src/install/archive.js b/packages/kbn-es/src/install/archive.js index df4a8502e4343..ba675ed6ac20d 100644 --- a/packages/kbn-es/src/install/archive.js +++ b/packages/kbn-es/src/install/archive.js @@ -26,6 +26,7 @@ const url = require('url'); const { log: defaultLog, decompress } = require('../utils'); const { BASE_PATH, ES_CONFIG, ES_KEYSTORE_BIN } = require('../paths'); const { Artifact } = require('../artifact'); +const { parseSettings, SettingsFilter } = require('../settings'); /** * Extracts an ES archive and optionally installs plugins @@ -45,6 +46,7 @@ exports.installArchive = async function installArchive(archive, options = {}) { installPath = path.resolve(basePath, path.basename(archive, '.tar.gz')), log = defaultLog, bundledJDK = false, + esArgs = [], } = options; let dest = archive; @@ -69,7 +71,10 @@ exports.installArchive = async function installArchive(archive, options = {}) { await appendToConfig(installPath, 'xpack.security.enabled', 'true'); await appendToConfig(installPath, 'xpack.license.self_generated.type', license); - await configureKeystore(installPath, password, log, bundledJDK); + await configureKeystore(installPath, log, bundledJDK, [ + ['bootstrap.password', password], + ...parseSettings(esArgs, { filter: SettingsFilter.SecureOnly }), + ]); } return { installPath }; @@ -90,21 +95,33 @@ async function appendToConfig(installPath, key, value) { * Creates and configures Keystore * * @param {String} installPath - * @param {String} password * @param {ToolingLog} log + * @param {boolean} bundledJDK + * @param {Array<[string, string]>} secureSettings List of custom Elasticsearch secure settings to + * add into the keystore. */ -async function configureKeystore(installPath, password, log = defaultLog, bundledJDK = false) { - log.info('setting bootstrap password to %s', chalk.bold(password)); - +async function configureKeystore( + installPath, + log = defaultLog, + bundledJDK = false, + secureSettings +) { const env = {}; if (bundledJDK) { env.JAVA_HOME = ''; } await execa(ES_KEYSTORE_BIN, ['create'], { cwd: installPath, env }); - await execa(ES_KEYSTORE_BIN, ['add', 'bootstrap.password', '-x'], { - input: password, - cwd: installPath, - env, - }); + for (const [secureSettingName, secureSettingValue] of secureSettings) { + log.info( + `setting secure setting %s to %s`, + chalk.bold(secureSettingName), + chalk.bold(secureSettingValue) + ); + await execa(ES_KEYSTORE_BIN, ['add', secureSettingName, '-x'], { + input: secureSettingValue, + cwd: installPath, + env, + }); + } } diff --git a/packages/kbn-es/src/install/snapshot.js b/packages/kbn-es/src/install/snapshot.js index bfe2c8833ec80..57aa276de09c9 100644 --- a/packages/kbn-es/src/install/snapshot.js +++ b/packages/kbn-es/src/install/snapshot.js @@ -73,6 +73,7 @@ exports.installSnapshot = async function installSnapshot({ installPath = path.resolve(basePath, version), log = defaultLog, bundledJDK = true, + esArgs, }) { const { downloadPath } = await exports.downloadSnapshot({ license, @@ -89,5 +90,6 @@ exports.installSnapshot = async function installSnapshot({ installPath, log, bundledJDK, + esArgs, }); }; diff --git a/packages/kbn-es/src/install/source.js b/packages/kbn-es/src/install/source.js index e8ef43a897da4..e78e9f1ff4b25 100644 --- a/packages/kbn-es/src/install/source.js +++ b/packages/kbn-es/src/install/source.js @@ -45,6 +45,7 @@ exports.installSource = async function installSource({ basePath = BASE_PATH, installPath = path.resolve(basePath, 'source'), log = defaultLog, + esArgs, }) { log.info('source path: %s', chalk.bold(sourcePath)); log.info('install path: %s', chalk.bold(installPath)); @@ -70,6 +71,7 @@ exports.installSource = async function installSource({ basePath, installPath, log, + esArgs, }); }; diff --git a/packages/kbn-es/src/settings.test.ts b/packages/kbn-es/src/settings.test.ts new file mode 100644 index 0000000000000..0a6aa4a97d76b --- /dev/null +++ b/packages/kbn-es/src/settings.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 { parseSettings, SettingsFilter } from './settings'; + +const mockSettings = [ + 'abc.def=1', + 'xpack.security.authc.realms.oidc.oidc1.rp.client_secret=secret', + 'xpack.security.authc.realms.oidc.oidc1.rp.client_id=client id', + 'discovery.type=single-node', +]; + +test('`parseSettings` parses and returns all settings by default', () => { + expect(parseSettings(mockSettings)).toEqual([ + ['abc.def', '1'], + ['xpack.security.authc.realms.oidc.oidc1.rp.client_secret', 'secret'], + ['xpack.security.authc.realms.oidc.oidc1.rp.client_id', 'client id'], + ['discovery.type', 'single-node'], + ]); +}); + +test('`parseSettings` parses and returns all settings with `SettingsFilter.All` filter', () => { + expect(parseSettings(mockSettings, { filter: SettingsFilter.All })).toEqual([ + ['abc.def', '1'], + ['xpack.security.authc.realms.oidc.oidc1.rp.client_secret', 'secret'], + ['xpack.security.authc.realms.oidc.oidc1.rp.client_id', 'client id'], + ['discovery.type', 'single-node'], + ]); +}); + +test('`parseSettings` parses and returns only secure settings with `SettingsFilter.SecureOnly` filter', () => { + expect(parseSettings(mockSettings, { filter: SettingsFilter.SecureOnly })).toEqual([ + ['xpack.security.authc.realms.oidc.oidc1.rp.client_secret', 'secret'], + ]); +}); + +test('`parseSettings` parses and returns only non-secure settings with `SettingsFilter.NonSecureOnly` filter', () => { + expect(parseSettings(mockSettings, { filter: SettingsFilter.NonSecureOnly })).toEqual([ + ['abc.def', '1'], + ['xpack.security.authc.realms.oidc.oidc1.rp.client_id', 'client id'], + ['discovery.type', 'single-node'], + ]); +}); diff --git a/packages/kbn-es/src/settings.ts b/packages/kbn-es/src/settings.ts new file mode 100644 index 0000000000000..58eedff207b4d --- /dev/null +++ b/packages/kbn-es/src/settings.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * List of the patterns for the settings names that are supposed to be secure and stored in the keystore. + */ +const SECURE_SETTINGS_LIST = [ + /^xpack\.security\.authc\.realms\.oidc\.[a-zA-Z0-9_]+\.rp\.client_secret$/, +]; + +function isSecureSetting(settingName: string) { + return SECURE_SETTINGS_LIST.some(secureSettingNameRegex => + secureSettingNameRegex.test(settingName) + ); +} + +export enum SettingsFilter { + All = 'all', + SecureOnly = 'secure-only', + NonSecureOnly = 'non-secure-only', +} + +/** + * Accepts an array of `esSettingName=esSettingValue` strings and parses them into an array of + * [esSettingName, esSettingValue] tuples optionally filter out secure or non-secure settings. + * @param rawSettingNameValuePairs Array of strings to parse + * @param [filter] Optional settings filter. + */ +export function parseSettings( + rawSettingNameValuePairs: string[], + { filter }: { filter: SettingsFilter } = { filter: SettingsFilter.All } +) { + const settings: Array<[string, string]> = []; + for (const rawSettingNameValuePair of rawSettingNameValuePairs) { + const [settingName, settingValue] = rawSettingNameValuePair.split('='); + + const includeSetting = + filter === SettingsFilter.All || + (filter === SettingsFilter.SecureOnly && isSecureSetting(settingName)) || + (filter === SettingsFilter.NonSecureOnly && !isSecureSetting(settingName)); + if (includeSetting) { + settings.push([settingName, settingValue]); + } + } + + return settings; +} diff --git a/packages/kbn-es/tsconfig.json b/packages/kbn-es/tsconfig.json new file mode 100644 index 0000000000000..6bb61453c99e7 --- /dev/null +++ b/packages/kbn-es/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/kbn-test/src/es/es_test_cluster.js b/packages/kbn-test/src/es/es_test_cluster.js index be24c6dbf0f31..b0615d30c2c91 100644 --- a/packages/kbn-test/src/es/es_test_cluster.js +++ b/packages/kbn-test/src/es/es_test_cluster.js @@ -37,6 +37,7 @@ export function createEsTestCluster(options = {}) { basePath = resolve(KIBANA_ROOT, '.es'), esFrom = esTestConfig.getBuildFrom(), dataArchive, + esArgs, } = options; const randomHash = Math.random() @@ -50,6 +51,7 @@ export function createEsTestCluster(options = {}) { password, license, basePath, + esArgs, }; const cluster = new Cluster(log); diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js index 2e290222b1a9d..c049782c4d874 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js @@ -40,6 +40,7 @@ export async function runElasticsearch({ config, options }) { basePath: resolve(KIBANA_ROOT, '.es'), esFrom: esFrom || config.get('esTestCluster.from'), dataArchive: config.get('esTestCluster.dataArchive'), + esArgs, }); await cluster.start(esArgs, esEnvVars); diff --git a/rfcs/text/0005_route_handler.md b/rfcs/text/0005_route_handler.md new file mode 100644 index 0000000000000..291da688bc5a6 --- /dev/null +++ b/rfcs/text/0005_route_handler.md @@ -0,0 +1,185 @@ +- Start Date: 2019-06-29 +- RFC PR: (leave this empty) +- Kibana Issue: https://github.com/elastic/kibana/issues/33779 + +# Summary + +Http Service in New platform should provide the ability to execute some logic in response to an incoming request and send the result of this operation back. + +# Basic example +Declaring a route handler for `/url` endpoint: +```typescript +router.get( + { path: '/url', ...[otherRouteParameters] }, + (context: Context, request: KibanaRequest, t: KibanaResponseToolkit) => { + // logic to handle request ... + return t.ok(result); +); + +``` + +# Motivation +The new platform is built with library-agnostic philosophy and we cannot transfer the current solution for Network layer from Hapi. To avoid vendor lock-in in the future, we have to define route handler logic and request/response objects formats that can be implemented in any low-level library such as Express, Hapi, etc. It means that we are going to operate our own abstractions for such Http domain entities as Router, Route, Route Handler, Request, Response. + +# Detailed design +The new platform doesn't support the Legacy platform `Route Handler` format nor exposes implementation details, such as [Hapi.ResponseToolkit](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/hapi/v17/index.d.ts#L984). +Rather `Route Handler` in New platform has the next signature: +```typescript +type RequestHandler = ( + context: Context, + request: KibanaRequest, + t: KibanaResponseToolkit +) => KibanaResponse | Promise; +``` +and accepts next Kibana specific parameters as arguments: +- context: [Context](https://github.com/elastic/kibana/blob/master/rfcs/text/0003_handler_interface.md#handler-context). A handler context contains core service and plugin functionality already scoped to the incoming request. +- request: [KibanaRequest](https://github.com/elastic/kibana/blob/master/src/core/server/http/router/request.ts). An immutable representation of the incoming request details, such as body, parameters, query, url and route information. Note: you **must** to specify route schema during route declaration to have access to `body, parameters, query` in the request object. You cannot extend KibanaRequest with arbitrary data nor remove any properties from it. +```typescript +interface KibanaRequest { + url: url.Url; + headers: Record; + params?: Record; + body?: Record; + query?: Record; + route: { + path: string; + method: 'get' | 'post' | ... + options: { + authRequired: boolean; + tags: string []; + } + } +} +``` +- t: [KibanaResponseToolkit](https://github.com/elastic/kibana/blob/master/src/core/server/http/router/response.ts#L27) +Provides a set of pre-configured methods to respond to an incoming request. It is expected that handler **always** returns a result of one of `KibanaResponseToolkit` methods as an output: +```typescript +interface KibanaResponseToolkit { + [method:string]: (...params: any) => KibanaResponse +} +router.get(..., + (context: Context, request: KibanaRequest, t: KibanaResponseToolkit): KibanaResponse => { + return t.ok(); + // or + return t.redirected('/url'); + // or + return t.badRequest(error); +); +``` +*KibanaResponseToolkit* methods allow an end user to adjust the next response parameters: +- Body. Supported values:`undefined | string | JSONValue | Buffer | Stream`. +- Status code. +- Headers. Supports adjusting [known values](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v10/http.d.ts#L8) and attaching [custom values as well](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v10/http.d.ts#L67) + +Other response parameters, such as `etag`, `MIME-type`, `bytes` that used in the Legacy platform could be adjusted via Headers. + +The router handler doesn't expect that logic inside can throw or return something different from `KibanaResponse`. In this case, Http service will respond with `Server error` to prevent exposure of internal logic details. + +#### KibanaResponseToolkit methods +Basic primitives: +```typescript +type HttpResponsePayload = undefined | string | JSONValue | Buffer | Stream; +interface HttpResponseOptions { + headers?: { + // list of known headers + ... + // for custom headers: + [header: string]: string | string[]; + } +} + +``` + +##### Success +Server indicated that request was accepted: +```typescript +type SuccessResponse = ( + payload: T, + options?: HttpResponseOptions +) => KibanaResponse; + +const kibanaResponseToolkit = { + ok: (payload: T, options?: HttpResponseOptions) => + new KibanaResponse(200, payload, options), + accepted: (payload: T, options?: HttpResponseOptions) => + new KibanaResponse(202, payload, options), + noContent: (options?: HttpResponseOptions) => new KibanaResponse(204, undefined, options) +``` + +##### Redirection +The server wants a user to perform additional actions. +```typescript +const kibanaResponseToolkit = { + redirected: (url: string, options?: HttpResponseOptions) => new KibanaResponse(302, url, options), + notModified: (options?: HttpResponseOptions) => new KibanaResponse(304, undefined, options), +``` + +##### Error +Server signals that request cannot be handled and explains details of the error situation +```typescript +// Supports attaching additional data to send to the client +interface ResponseError extends Error { + meta?: { + data?: JSONValue; + errorCode?: string; // error code to simplify search, translations in i18n, etc. + docLink?: string; // link to the docs + } +} + +export const createResponseError = (error: Error | string, meta?: ResponseErrorType['meta']) => + new ResponseError(error, meta) + +const kibanaResponseToolkit = { + // Client errors + badRequest: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(400, err, options), + unauthorized: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(401, err, options), + + forbidden: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(403, err, options), + notFound: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(404, err, options), + conflict: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(409, err, options), + + // Server errors + internal: (err: T, options?: HttpResponseOptions) => + new KibanaResponse(500, err, options), +``` + +##### Custom +If a custom response is required +```typescript +interface CustomOptions extends HttpResponseOptions { + statusCode: number; +} +export const kibanaResponseToolkit = { + custom: (payload: T, {statusCode, ...options}: CustomOptions) => + new KibanaResponse(statusCode, payload, options), +``` +# Drawbacks +- `Handler` is not compatible with Legacy platform implementation when anything can be returned or thrown from handler function and server send it as a valid result. Transition to the new format may require additional work in plugins. +- `Handler` doesn't cover **all** functionality of the Legacy server at the current moment. For example, we cannot render a view in New platform yet and in this case, we have to proxy the request to the Legacy platform endpoint to perform rendering. All such cases should be considered in an individual order. +- `KibanaResponseToolkit` may not cover all use cases and requires an extension for specific use-cases. +- `KibanaResponseToolkit` operates low-level Http primitives, such as Headers e.g., and it is not always handy to work with them directly. +- `KibanaResponse` cannot be extended with arbitrary data. + +# Alternatives + +- `Route Handler` may adopt well-known Hapi-compatible format. +- `KibanaResponseToolkit` can expose only one method that allows specifying any type of response body, headers, status without creating additional abstractions and restrictions. +- `KibanaResponseToolkit` may provide helpers for more granular use-cases, say ` +binary(data: Buffer, type: MimeType, size: number) => KibanaResponse` + +# Adoption strategy + +Breaking changes are expected during migration to the New platform. To simplify adoption we could provide an extended set of type definitions for primitives with high variability of possible values (such as content-type header, all headers in general). + +# How we teach this + +`Route Handler`, `Request`, `Response` terms are familiar to all Kibana developers. Even if their interface is different from existing ones, it shouldn't be a problem to adopt the code to the new format. Adding a section to the Migration guide should be sufficient. + +# Unresolved questions + +Is proposed functionality cover all the use cases of the `Route Handler` and responding to a request? diff --git a/scripts/es.js b/scripts/es.js index 26e8ed5f7f691..bd64857d81ad5 100644 --- a/scripts/es.js +++ b/scripts/es.js @@ -17,12 +17,12 @@ * under the License. */ +require('../src/setup_node_env'); + var resolve = require('path').resolve; var pkg = require('../package.json'); var kbnEs = require('@kbn/es'); -require('../src/setup_node_env'); - kbnEs .run({ license: 'basic', diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index a3b6b715b1311..295602b6e1486 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -604,9 +604,155 @@ It is generally a much greater challenge preparing legacy browser-side code for To complicate matters further, a significant amount of the business logic in Kibana's client-side code exists inside the `ui/public` directory (aka ui modules), and all of that must be migrated as well. Unlike the server-side code where the order in which you migrated plugins was not particularly important, it's important that UI modules be addressed as soon as possible. -Also unlike the server-side migration, we won't concern ourselves with creating shimmed plugin definitions that then get copied over to complete the migration. +Because usage of angular and `ui/public` modules varies widely between legacy plugins, there is no "one size fits all" solution to migrating your browser-side code to the new platform. The best place to start is by checking with the platform team to help identify the best migration path for your particular plugin. -### Move UI modules into plugins +That said, we've seen a series of patterns emerge as teams begin migrating browser code. In practice, most migrations will follow a path that looks something like this: + +#### 1. Create a plugin definition file + +We've found that doing this right away helps you start thinking about your plugin in terms of lifecycle methods and services, which makes the rest of the migration process feel more natural. It also forces you to identify which actions "kick off" your plugin, since you'll need to execute those when the `setup/start` methods are called. + +This definition isn't going to do much for us just yet, but as we get further into the process, we will gradually start returning contracts from our `setup` and `start` methods, while also injecting dependencies as arguments to these methods. + +```ts +// public/plugin.ts +import { CoreSetup, CoreStart, Plugin } from '../../../../core/public'; +import { FooSetup, FooStart } from '../../../../legacy/core_plugins/foo/public'; + +/** + * These are the private interfaces for the services your plugin depends on. + * @internal + */ +export interface DemoSetupDeps { + foo: FooSetup; +} +export interface DemoStartDeps { + foo: FooStart; +} + +/** + * These are the interfaces with your public contracts. You should export these + * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. + * @public + */ +export type DemoSetup = {} +export type DemoStart = {} + +/** @internal */ +export class DemoPlugin implements Plugin { + public setup(core: CoreSetup, plugins: DemoSetupDeps): DemoSetup { + // kick off your plugin here... + return { + fetchConfig: () => ({}), + }; + } + + public start(core: CoreStart, plugins: DemoStartDeps): DemoStart { + // ...or here + return { + initDemo: () => ({}), + }; + } + + public stop() {} +} +``` + +#### 2. Export all static code and types from `public/index.ts` + +If your plugin needs to share static code with other plugins, this code must be exported from your top-level `public/index.ts`. This includes any type interfaces that you wish to make public. For details on the types of code that you can safely share outside of the runtime lifecycle contracts, see [Can static code be shared between plugins?](#can-static-code-be-shared-between-plugins) + +```ts +// public/index.ts +import { DemoSetup, DemoStart } from './plugin'; + +const myPureFn = (x: number): number => x + 1; +const MyReactComponent = (props) => { + return

Hello, {props.name}

; +} + +// These are your public types & static code +export { + myPureFn, + MyReactComponent, + DemoSetup, + DemoStart, +} +``` + +While you're at it, you can also add your plugin initializer to this file: + +```ts +// public/index.ts +import { PluginInitializer, PluginInitializerContext } from '../../../../core/public'; +import { DemoSetup, DemoStart, DemoSetupDeps, DemoStartDeps, DemoPlugin } from './plugin'; + +// Core will be looking for this when loading our plugin in the new platform +export const plugin: PluginInitializer = ( + initializerContext: PluginInitializerContext +) => { + return new DemoPlugin(); +}; + +const myPureFn = (x: number): number => x + 1; +const MyReactComponent = (props) => { + return

Hello, {props.name}

; +} + +/** @public */ +export { + myPureFn, + MyReactComponent, + DemoSetup, + DemoStart, +} +``` + +Great! So you have your plugin definition, and you've moved all of your static exports to the top level of your plugin... now let's move on to the runtime contract your plugin will be exposing. + +#### 3. Export your runtime contract + +Next, we need a way to expose your runtime dependencies. In the new platform, core will handle this for you. But while we are still in the legacy world, other plugins will need a way to consume your plugin's contract without the help of core. + +So we will take a similar approach to what was described above in the server section: actually call the `Plugin.setup()` and `Plugin.start()` methods, and export the values those return for other legacy plugins to consume. By convention, we've been placing this in a `legacy.ts` file, which also serves as our shim where we import our legacy dependencies and reshape them into what we are expecting in the new platform: + +```ts +// public/legacy.ts +import { PluginInitializerContext } from '../../../../core/public'; +import { npSetup, npStart } from 'ui/new_platform'; +import { plugin } from '.'; + +import { setup as fooSetup, start as fooStart } from '../../foo/public/legacy'; // assumes `foo` lives in `legacy/core_plugins` + +const pluginInstance = plugin({} as PluginInitializerContext); +const shimCoreSetup = { + ...npSetup.core, + bar: {}, // shim for a core service that hasn't migrated yet +}; +const shimCoreStart = { + ...npStart.core, + bar: {}, +}; +const shimSetupPlugins = { + ...npSetup.plugins, + foo: fooSetup, +}; +const shimStartPlugins = { + ...npStart.plugins, + foo: fooStart, +}; + +export const setup = pluginInstance.setup(shimCoreSetup, shimSetupPlugins); +export const start = pluginInstance.start(shimCoreStart, shimStartPlugins); +``` + +> As you build your shims, you may be wondering where you will find some legacy services in the new platform. Skip to [the tables below](#how-do-i-build-my-shim-for-new-platform-services) for a list of some of the more common legacy services and where we currently expect them to live. + +Notice how in the example above, we are importing the `setup` and `start` contracts from the legacy shim provided by `foo` plugin; we could just as easily be importing modules from `ui/public` here as well. + +The point is that, over time, this becomes the one file in our plugin containing stateful imports from the legacy world. And _that_ is where things start to get interesting... + +#### 4. Move "owned" UI modules into your plugin and expose them from your public contract Everything inside of the `ui/public` directory is going to be dealt with in one of the following ways: @@ -621,7 +767,20 @@ Concerns around ownership or duplication of a given module should be raised and A great outcome is a module being deleted altogether because it isn't used or it was used so lightly that it was easy to refactor away. -### Provide plugin extension points decoupled from angular.js +If it is determined that your plugin is going to own any UI modules that other plugins depend on, you'll want to migrate these quickly so that there's time for downstream plugins to update their imports. This will ultimately involve moving the module code into your plugin, and exposing it via your setup/start contracts, or as static code from your `plugin/index.ts`. We have identified owners for most of the legacy UI modules; if you aren't sure where you should move something that you own, please consult with the platform team. + +Depending on the module's level of complexity and the number of other places in Kibana that rely on it, there are a number of strategies you could use for this: + +* **Do it all at once.** Move the code, expose it from your plugin, and update all imports across Kibana. + - This works best for small pieces of code that aren't widely used. +* **Shim first, move later.** Expose the code from your plugin by importing it in your shim and then re-exporting it from your plugin first, then gradually update imports to pull from the new location, leaving the actual moving of the code as a final step. + - This works best for the largest, most widely used modules that would otherwise result in huge, hard-to-review PRs. + - It makes things easier by splitting the process into small, incremental PRs, but is probably overkill for things with a small surface area. +* **Hybrid approach.** As a middle ground, you can also move the code to your plugin immediately, and then re-export your plugin code from the original `ui/public` directory. + - This eliminates any concerns about backwards compatibility by allowing you to update the imports across Kibana later. + - Works best when the size of the PR is such that moving the code can be done without much refactoring. + +#### 5. Provide plugin extension points decoupled from angular.js There will be no global angular module in the new platform, which means none of the functionality provided by core will be coupled to angular. Since there is no global angular module shared by all applications, plugins providing extension points to be used by other plugins can not couple those extension points to angular either. @@ -633,7 +792,7 @@ Another way to address this problem is to create an entirely new set of plugin A Please talk with the platform team when formalizing _any_ client-side extension points that you intend to move to the new platform as there are some bundling considerations to consider. -### Move all webpack alias imports into uiExport entry files +#### 6. Move all webpack alias imports into uiExport entry files Existing plugins import three things using webpack aliases today: services from ui/public (`ui/`), services from other plugins (`plugins/`), and uiExports themselves (`uiExports/`). These webpack aliases will not exist once we remove the legacy plugin system, so part of our migration effort is addressing all of the places where they are used today. @@ -643,7 +802,29 @@ With the legacy plugin system, extensions of core and other plugins are handled Each uiExport path is an entry file into one specific set of functionality provided by a client-side plugin. All webpack alias-based imports should be moved to these entry files, where they are appropriate. Moving a deeply nested webpack alias-based import in a plugin to one of the uiExport entry files might require some refactoring to ensure the dependency is now passed down to the appropriate place as function arguments instead of via import statements. -### Switch to new platform services +For stateful dependencies using the `plugins/` and `ui/` webpack aliases, you should be able to take advantage of the `legacy.ts` shim you created earlier. By placing these imports directly in your shim, you can pass the dependencies you need into your `Plugin.start` and `Plugin.setup` methods, from which point they can be passed down to the rest of your plugin's entry files. + +For items that don't yet have a clear "home" in the new platform, it may also be helpful to somehow indicate this in your shim to make it easier to remember that you'll need to change this later. One convention we've found helpful for this is simply using a namespace like `__LEGACY`: + +```ts +// public/legacy.ts +import { uiThing } from 'ui/thing'; +... + +const pluginInstance = plugin({} as PluginInitializerContext); +const shimSetupPlugins = { + ...npSetup.plugins, + foo: fooSetup, + __LEGACY: { + uiThing, // eventually this will move out of __LEGACY and into a proper plugin + }, +}; + +... +export const setup = pluginInstance.setup(npSetup.core, shimSetupPlugins); +``` + +#### 7. Switch to new platform services At this point, your plugin has one or more uiExport entry files that together contain all of the webpack alias-based import statements needed to run your plugin. Each one of these import statements is either a service that is or will be provided by core or a service provided by another plugin. @@ -651,14 +832,20 @@ As new non-angular-based APIs are added, update your entry files to import the c Once all of the existing webpack alias-based imports in your plugin switch to `ui/new_platform`, it no longer depends directly on the legacy "core" features or other legacy plugins, so it is ready to officially migrate to the new platform. -### Migrate to the new plugin system +#### 8. Migrate to the new plugin system With all of your services converted, you are now ready to complete your migration to the new platform. -Many plugins at this point will create a new plugin definition class and copy and paste the code from their various uiExport entry files directly into the new plugin class. The legacy uiExport entry files can then simply be deleted. +Many plugins at this point will copy over their plugin definition class & the code from their various service/uiExport entry files directly into the new plugin directory. The `legacy.ts` shim file can then simply be deleted. With the previous steps resolved, this final step should be easy, but the exact process may vary plugin by plugin, so when you're at this point talk to the platform team to figure out the exact changes you need. +#### Bonus: Tips for complex migration scenarios + +For a few plugins, some of these steps (such as angular removal) could be a months-long process. In those cases, it may be helpful from an organizational perspective to maintain a clear separation of code that is and isn't "ready" for the new platform. + +One convention that is useful for this is creating a dedicated `public/np_ready` directory to house the code that is ready to migrate, and gradually move more and more code into it until the rest of your plugin is essentially empty. At that point, you'll be able to copy your `index.ts`, `plugin.ts`, and the contents of `./np_ready` over into your plugin in the new platform, leaving your legacy shim behind. This carries the added benefit of providing a way for us to introduce helpful tooling in the future, such as [custom eslint rules](https://github.com/elastic/kibana/pull/40537), which could be run against that specific directory to ensure your code is ready to migrate. + ## Frequently asked questions ### Is migrating a plugin an all-or-nothing thing? diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index 872980154544f..85d997f3dc9aa 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -28,7 +28,6 @@ const createSetupContractMock = (): jest.Mocked => ({ }); const createStartContractMock = (): jest.Mocked => ({ - mount: jest.fn(), ...capabilitiesServiceMock.createStartContract(), }); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx index af100ab5f5f5b..d2266671367a2 100644 --- a/src/core/public/application/application_service.test.tsx +++ b/src/core/public/application/application_service.test.tsx @@ -28,11 +28,16 @@ describe('#start()', () => { setup.registerApp({ id: 'app1' } as any); setup.registerLegacyApp({ id: 'app2' } as any); const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - expect((await service.start({ injectedMetadata })).availableApps).toMatchInlineSnapshot(` + const startContract = await service.start({ injectedMetadata }); + expect(startContract.availableApps).toMatchInlineSnapshot(` Array [ Object { "id": "app1", }, +] +`); + expect(startContract.availableLegacyApps).toMatchInlineSnapshot(` +Array [ Object { "id": "app2", }, @@ -48,6 +53,7 @@ Array [ await service.start({ injectedMetadata }); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ apps: [{ id: 'app1' }], + legacyApps: [], injectedMetadata, }); }); @@ -59,7 +65,8 @@ Array [ const injectedMetadata = injectedMetadataServiceMock.createStartContract(); await service.start({ injectedMetadata }); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: [{ id: 'legacyApp1' }], + apps: [], + legacyApps: [{ id: 'legacyApp1' }], injectedMetadata, }); }); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index e7f18cf7bfcdd..528b81ad40be7 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -18,8 +18,9 @@ */ import { Observable, BehaviorSubject } from 'rxjs'; -import { CapabilitiesStart, CapabilitiesService, Capabilities } from './capabilities'; +import { CapabilitiesService, Capabilities } from './capabilities'; import { InjectedMetadataStart } from '../injected_metadata'; +import { RecursiveReadonly } from '../../utils'; interface BaseApp { id: string; @@ -59,11 +60,6 @@ interface BaseApp { /** @public */ export interface App extends BaseApp { - /** - * The root route to mount this application at. - */ - rootRoute: string; - /** * A mount function called when the user navigates to this app's `rootRoute`. * @param targetDomElement An HTMLElement to mount the application onto. @@ -98,10 +94,27 @@ export interface ApplicationSetup { registerLegacyApp(app: LegacyApp): void; } +/** + * @public + */ export interface ApplicationStart { - mount: (mountHandler: Function) => void; - availableApps: CapabilitiesStart['availableApps']; - capabilities: CapabilitiesStart['capabilities']; + /** + * Gets the read-only capabilities. + */ + capabilities: RecursiveReadonly; + + /** + * Apps available based on the current capabilities. Should be used + * to show navigation links and make routing decisions. + */ + availableApps: readonly App[]; + + /** + * Apps available based on the current capabilities. Should be used + * to show navigation links and make routing decisions. + * @internal + */ + availableLegacyApps: readonly LegacyApp[]; } interface StartDeps { @@ -132,17 +145,11 @@ export class ApplicationService { this.apps$.complete(); this.legacyApps$.complete(); - const apps = [...this.apps$.value, ...this.legacyApps$.value]; - const { capabilities, availableApps } = await this.capabilities.start({ - apps, + return this.capabilities.start({ + apps: this.apps$.value, + legacyApps: this.legacyApps$.value, injectedMetadata, }); - - return { - mount() {}, - capabilities, - availableApps, - }; } public stop() {} diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index b6f5323d1907d..71b069fd80434 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -18,12 +18,14 @@ */ import { CapabilitiesService, CapabilitiesStart } from './capabilities_service'; import { deepFreeze } from '../../../utils/'; -import { MixedApp } from '../application_service'; +import { App, LegacyApp } from '../application_service'; const createStartContractMock = ( - apps: readonly MixedApp[] = [] + apps: readonly App[] = [], + legacyApps: readonly LegacyApp[] = [] ): jest.Mocked => ({ availableApps: apps, + availableLegacyApps: legacyApps, capabilities: deepFreeze({ catalogue: {}, management: {}, @@ -33,7 +35,9 @@ const createStartContractMock = ( type CapabilitiesServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ - start: jest.fn().mockImplementation(({ apps }) => createStartContractMock(apps)), + start: jest + .fn() + .mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)), }); export const capabilitiesServiceMock = { diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index d7d3c4c7ef72b..1c60c1eeb195a 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -30,25 +30,33 @@ describe('#start', () => { navLinks: { app1: true, app2: false, + legacyApp1: true, + legacyApp2: false, }, foo: { feature: true }, bar: { feature: true }, }, } as any, }).start(); + const apps = [{ id: 'app1' }, { id: 'app2', capabilities: { app2: { feature: true } } }] as any; + const legacyApps = [ + { id: 'legacyApp1' }, + { id: 'legacyApp2', capabilities: { app2: { feature: true } } }, + ] as any; it('filters available apps based on returned navLinks', async () => { const service = new CapabilitiesService(); - expect((await service.start({ apps, injectedMetadata })).availableApps).toEqual([ - { id: 'app1' }, - ]); + const startContract = await service.start({ apps, legacyApps, injectedMetadata }); + expect(startContract.availableApps).toEqual([{ id: 'app1' }]); + expect(startContract.availableLegacyApps).toEqual([{ id: 'legacyApp1' }]); }); it('does not allow Capabilities to be modified', async () => { const service = new CapabilitiesService(); const { capabilities } = await service.start({ apps, + legacyApps, injectedMetadata, }); diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index a341ed5e14177..51c5a218e70bd 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -18,11 +18,12 @@ */ import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { MixedApp } from '../application_service'; +import { LegacyApp, App } from '../application_service'; import { InjectedMetadataStart } from '../../injected_metadata'; interface StartDeps { - apps: readonly MixedApp[]; + apps: readonly App[]; + legacyApps: readonly LegacyApp[]; injectedMetadata: InjectedMetadataStart; } @@ -49,35 +50,28 @@ export interface Capabilities { [key: string]: Record>; } -/** - * Capabilities Setup. - * @public - */ +/** @internal */ export interface CapabilitiesStart { - /** - * Gets the read-only capabilities. - */ capabilities: RecursiveReadonly; - - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: readonly MixedApp[]; + availableApps: readonly App[]; + availableLegacyApps: readonly LegacyApp[]; } -/** @internal */ - /** * Service that is responsible for UI Capabilities. + * @internal */ export class CapabilitiesService { - public async start({ apps, injectedMetadata }: StartDeps): Promise { + public async start({ + apps, + legacyApps, + injectedMetadata, + }: StartDeps): Promise { const capabilities = deepFreeze(injectedMetadata.getCapabilities()); - const availableApps = apps.filter(app => capabilities.navLinks[app.id]); return { - availableApps, + availableApps: apps.filter(app => capabilities.navLinks[app.id]), + availableLegacyApps: legacyApps.filter(app => capabilities.navLinks[app.id]), capabilities, }; } diff --git a/src/core/public/application/capabilities/index.ts b/src/core/public/application/capabilities/index.ts index 4cabc3770ec26..9d8bec955eb97 100644 --- a/src/core/public/application/capabilities/index.ts +++ b/src/core/public/application/capabilities/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { Capabilities, CapabilitiesService, CapabilitiesStart } from './capabilities_service'; +export { Capabilities, CapabilitiesService } from './capabilities_service'; diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 35ac102c7a40c..74f2a09b895de 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -26,7 +26,7 @@ import { } from './chrome_service'; const createStartContractMock = () => { - const startContract: jest.Mocked = { + const startContract: DeeplyMockedKeys = { getComponent: jest.fn(), navLinks: { getNavLinks$: jest.fn(), @@ -66,6 +66,7 @@ const createStartContractMock = () => { getHelpExtension$: jest.fn(), setHelpExtension: jest.fn(), }; + startContract.navLinks.getAll.mockReturnValue([]); startContract.getBrand$.mockReturnValue(new BehaviorSubject({} as ChromeBrand)); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); startContract.getIsCollapsed$.mockReturnValue(new BehaviorSubject(false)); diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index 055343a50039c..b323bf5318b23 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -28,29 +28,6 @@ export interface ChromeNavLink { */ readonly id: string; - /** - * Indicates whether or not this app is currently on the screen. - * - * NOTE: remove this when ApplicationService is implemented and managing apps. - */ - readonly active?: boolean; - - /** - * Disables a link from being clickable. - * - * NOTE: this is only used by the ML and Graph plugins currently. They use this field - * to disable the nav link when the license is expired. - */ - readonly disabled?: boolean; - - /** - * Hides a link from the navigation. - * - * NOTE: remove this when ApplicationService is implemented. Instead, plugins should only - * register an Application if needed. - */ - readonly hidden?: boolean; - /** * An ordinal used to sort nav links relative to one another for display. */ @@ -62,14 +39,14 @@ export interface ChromeNavLink { readonly title: string; /** - * A tooltip shown when hovering over an app link. + * The base route used to open the root of an application. */ - readonly tooltip?: string; + readonly baseUrl: string; /** - * The base route used to open the root of an application. + * A tooltip shown when hovering over an app link. */ - readonly baseUrl: string; + readonly tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -88,25 +65,70 @@ export interface ChromeNavLink { /** * A url base that legacy apps can set to match deep URLs to an applcation. * - * NOTE: this should be removed once legacy apps are gone. + * @internalRemarks + * This should be removed once legacy apps are gone. + * + * @deprecated */ readonly subUrlBase?: string; /** * Whether or not the subUrl feature should be enabled. * - * NOTE: only read by legacy platform. + * @internalRemarks + * Only read by legacy platform. + * + * @deprecated */ readonly linkToLastSubUrl?: boolean; /** * A url that legacy apps can set to deep link into their applications. * - * NOTE: Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should + * @internalRemarks + * Currently used by the "lastSubUrl" feature legacy/ui/chrome. This should * be removed once the ApplicationService is implemented and mounting apps. At that * time, each app can handle opening to the previous location when they are mounted. + * + * @deprecated */ readonly url?: string; + + /** + * Indicates whether or not this app is currently on the screen. + * + * @internalRemarks + * Remove this when ApplicationService is implemented and managing apps. + * + * @deprecated + */ + readonly active?: boolean; + + /** + * Disables a link from being clickable. + * + * @internalRemarks + * This is only used by the ML and Graph plugins currently. They use this field + * to disable the nav link when the license is expired. + * + * @deprecated + */ + readonly disabled?: boolean; + + /** + * Hides a link from the navigation. + * + * @internalRemarks + * Remove this when ApplicationService is implemented. Instead, plugins should only + * register an Application if needed. + */ + readonly hidden?: boolean; + + /** + * Used to separate links to legacy applications from NP applications + * @internal + */ + readonly legacy: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index fe74ae3a7d9a2..dfef8dc7989f6 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -21,10 +21,17 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; const mockAppService = { - availableApps: [ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1', rootRoute: '/app1' }, - { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp', rootRoute: '/app2' }, - { id: 'legacyApp', order: 20, title: 'Legacy App', appUrl: '/legacy-app' }, + availableApps: [], + availableLegacyApps: [ + { id: 'legacyApp1', order: 0, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + }, + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3' }, ], } as any; @@ -53,17 +60,20 @@ describe('NavLinksService', () => { map(links => links.map(l => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'legacyApp']); + ).toEqual(['legacyApp2', 'legacyApp1', 'legacyApp3']); }); it('emits multiple values', async () => { const navLinkIds$ = start.getNavLinks$().pipe(map(links => links.map(l => l.id))); const emittedLinks: string[][] = []; navLinkIds$.subscribe(r => emittedLinks.push(r)); - start.update('app1', { active: true }); + start.update('legacyApp1', { active: true }); service.stop(); - expect(emittedLinks).toEqual([['app2', 'app1', 'legacyApp'], ['app2', 'app1', 'legacyApp']]); + expect(emittedLinks).toEqual([ + ['legacyApp2', 'legacyApp1', 'legacyApp3'], + ['legacyApp2', 'legacyApp1', 'legacyApp3'], + ]); }); it('completes when service is stopped', async () => { @@ -78,7 +88,7 @@ describe('NavLinksService', () => { describe('#get()', () => { it('returns link if exists', () => { - expect(start.get('app1')!.title).toEqual('App 1'); + expect(start.get('legacyApp1')!.title).toEqual('Legacy App 1'); }); it('returns undefined if it does not exist', () => { @@ -88,13 +98,13 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map(l => l.id)).toEqual(['app2', 'app1', 'legacyApp']); + expect(start.getAll().map(l => l.id)).toEqual(['legacyApp2', 'legacyApp1', 'legacyApp3']); }); }); describe('#has()', () => { it('returns true if exists', () => { - expect(start.has('app1')).toBe(true); + expect(start.has('legacyApp1')).toBe(true); }); it('returns false if it does not exist', () => { @@ -113,11 +123,11 @@ describe('NavLinksService', () => { map(links => links.map(l => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'legacyApp']); + ).toEqual(['legacyApp2', 'legacyApp1', 'legacyApp3']); }); it('removes all other links', async () => { - start.showOnly('app1'); + start.showOnly('legacyApp1'); expect( await start .getNavLinks$() @@ -126,23 +136,24 @@ describe('NavLinksService', () => { map(links => links.map(l => l.id)) ) .toPromise() - ).toEqual(['app1']); + ).toEqual(['legacyApp1']); }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('app1', { hidden: true })).toMatchInlineSnapshot(` -Object { - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "app1", - "id": "app1", - "order": 0, - "rootRoute": "/app1", - "title": "App 1", -} -`); + expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` + Object { + "appUrl": "/app1", + "baseUrl": "http://localhost/wow/app1", + "hidden": true, + "icon": "legacyApp1", + "id": "legacyApp1", + "legacy": true, + "order": 0, + "title": "Legacy App 1", + } + `); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -150,7 +161,7 @@ Object { map(links => links.filter(l => l.hidden).map(l => l.id)) ) .toPromise(); - expect(hiddenLinkIds).toEqual(['app1']); + expect(hiddenLinkIds).toEqual(['legacyApp1']); }); it('returns undefined if link does not exist', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 8dd5525db29da..2250ec40f0f44 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -99,20 +99,20 @@ export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { + const legacyAppLinks = application.availableLegacyApps.map( + app => + [ + app.id, + new NavLinkWrapper({ + ...app, + legacy: true, + baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), + }), + ] as [string, NavLinkWrapper] + ); + const navLinks$ = new BehaviorSubject>( - new Map( - application.availableApps.map( - app => - [ - app.id, - new NavLinkWrapper({ - ...app, - // Either rootRoute or appUrl must be defined. - baseUrl: relativeToAbsolute(http.basePath.prepend((app.rootRoute || app.appUrl)!)), - }), - ] as [string, NavLinkWrapper] - ) - ) + new Map(legacyAppLinks) ); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); diff --git a/src/legacy/core_plugins/tagcloud/index.js b/src/core/public/context/context.mock.ts similarity index 68% rename from src/legacy/core_plugins/tagcloud/index.js rename to src/core/public/context/context.mock.ts index 6ae53f745f82a..a3849fb77f830 100644 --- a/src/legacy/core_plugins/tagcloud/index.js +++ b/src/core/public/context/context.mock.ts @@ -17,15 +17,18 @@ * under the License. */ -import { resolve } from 'path'; +import { IContextContainer } from './context'; -export default function (kibana) { +export type ContextContainerMock = jest.Mocked>; - return new kibana.Plugin({ - uiExports: { - visTypes: ['plugins/tagcloud/tag_cloud_vis'], - interpreter: ['plugins/tagcloud/tag_cloud_fn'], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - } - }); -} +const createContextMock = () => { + const contextMock: ContextContainerMock = { + registerContext: jest.fn(), + createHandler: jest.fn(), + }; + return contextMock; +}; + +export const contextMock = { + create: createContextMock, +}; diff --git a/src/core/public/context/context.test.ts b/src/core/public/context/context.test.ts new file mode 100644 index 0000000000000..d05662af73908 --- /dev/null +++ b/src/core/public/context/context.test.ts @@ -0,0 +1,232 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ContextContainer } from './context'; +import { PluginOpaqueId } from '../plugins'; + +const pluginA = Symbol('pluginA'); +const pluginB = Symbol('pluginB'); +const pluginC = Symbol('pluginC'); +const pluginD = Symbol('pluginD'); +const plugins: ReadonlyMap = new Map([ + [pluginA, []], + [pluginB, [pluginA]], + [pluginC, [pluginA, pluginB]], + [pluginD, []], +]); + +interface MyContext { + core1: string; + core2: number; + ctxFromA: string; + ctxFromB: number; + ctxFromC: boolean; + ctxFromD: object; +} + +const coreId = Symbol(); + +describe('ContextContainer', () => { + it('does not allow the same context to be registered twice', () => { + const contextContainer = new ContextContainer(plugins, coreId); + contextContainer.registerContext(coreId, 'ctxFromA', () => 'aString'); + + expect(() => + contextContainer.registerContext(coreId, 'ctxFromA', () => 'aString') + ).toThrowErrorMatchingInlineSnapshot( + `"Context provider for ctxFromA has already been registered."` + ); + }); + + describe('registerContext', () => { + it('throws error if called with an unknown symbol', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + await expect(() => + contextContainer.registerContext(Symbol('unknown'), 'ctxFromA', jest.fn()) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot register context for unknown plugin: Symbol(unknown)"` + ); + }); + }); + + describe('context building', () => { + it('resolves dependencies', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + expect.assertions(8); + contextContainer.registerContext(coreId, 'core1', context => { + expect(context).toEqual({}); + return 'core'; + }); + + contextContainer.registerContext(pluginA, 'ctxFromA', context => { + expect(context).toEqual({ core1: 'core' }); + return 'aString'; + }); + contextContainer.registerContext(pluginB, 'ctxFromB', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString' }); + return 299; + }); + contextContainer.registerContext(pluginC, 'ctxFromC', context => { + expect(context).toEqual({ core1: 'core', ctxFromA: 'aString', ctxFromB: 299 }); + return false; + }); + contextContainer.registerContext(pluginD, 'ctxFromD', context => { + expect(context).toEqual({ core1: 'core' }); + return {}; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginC, rawHandler1); + + const rawHandler2 = jest.fn(() => 'handler2'); + const handler2 = contextContainer.createHandler(pluginD, rawHandler2); + + await handler1(); + await handler2(); + + // Should have context from pluginC, its deps, and core + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + ctxFromA: 'aString', + ctxFromB: 299, + ctxFromC: false, + }); + + // Should have context from pluginD, and core + expect(rawHandler2).toHaveBeenCalledWith({ + core1: 'core', + ctxFromD: {}, + }); + }); + + it('exposes all core context to core providers', async () => { + expect.assertions(4); + const contextContainer = new ContextContainer(plugins, coreId); + + contextContainer + .registerContext(coreId, 'core1', context => { + expect(context).toEqual({}); + return 'core'; + }) + .registerContext(coreId, 'core2', context => { + expect(context).toEqual({ core1: 'core' }); + return 101; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, rawHandler1); + + expect(await handler1()).toEqual('handler1'); + + // If no context is registered for pluginA, only core contexts should be exposed + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + core2: 101, + }); + }); + + it('does not expose plugin contexts to core handler', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + + contextContainer + .registerContext(coreId, 'core1', context => 'core') + .registerContext(pluginA, 'ctxFromA', context => 'aString'); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(coreId, rawHandler1); + + expect(await handler1()).toEqual('handler1'); + // pluginA context should not be present in a core handler + expect(rawHandler1).toHaveBeenCalledWith({ + core1: 'core', + }); + }); + + it('passes additional arguments to providers', async () => { + expect.assertions(6); + const contextContainer = new ContextContainer( + plugins, + coreId + ); + + contextContainer.registerContext(coreId, 'core1', (context, str, num) => { + expect(str).toEqual('passed string'); + expect(num).toEqual(77); + return `core ${str}`; + }); + + contextContainer.registerContext(pluginD, 'ctxFromD', (context, str, num) => { + expect(str).toEqual('passed string'); + expect(num).toEqual(77); + return { + num: 77, + }; + }); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginD, rawHandler1); + + expect(await handler1('passed string', 77)).toEqual('handler1'); + + expect(rawHandler1).toHaveBeenCalledWith( + { + core1: 'core passed string', + ctxFromD: { + num: 77, + }, + }, + 'passed string', + 77 + ); + }); + }); + + describe('createHandler', () => { + it('throws error if called with an unknown symbol', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + await expect(() => + contextContainer.createHandler(Symbol('unknown'), jest.fn()) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot create handler for unknown plugin: Symbol(unknown)"` + ); + }); + + it('returns value from original handler', async () => { + const contextContainer = new ContextContainer(plugins, coreId); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, rawHandler1); + + expect(await handler1()).toEqual('handler1'); + }); + + it('passes additional arguments to handlers', async () => { + const contextContainer = new ContextContainer( + plugins, + coreId + ); + + const rawHandler1 = jest.fn(() => 'handler1'); + const handler1 = contextContainer.createHandler(pluginA, rawHandler1); + + await handler1('passed string', 77); + expect(rawHandler1).toHaveBeenCalledWith({}, 'passed string', 77); + }); + }); +}); diff --git a/src/core/public/context/context.ts b/src/core/public/context/context.ts new file mode 100644 index 0000000000000..28f1b8e6ea878 --- /dev/null +++ b/src/core/public/context/context.ts @@ -0,0 +1,293 @@ +/* + * 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 { flatten } from 'lodash'; +import { pick } from '../../utils'; +import { CoreId } from '../core_system'; +import { PluginOpaqueId } from '../plugins'; + +/** + * A function that returns a context value for a specific key of given context type. + * + * @remarks + * This function will be called each time a new context is built for a handler invocation. + * + * @param context - A partial context object containing only the keys for values provided by plugin dependencies + * @param rest - Additional parameters provided by the service owner of this context + * @returns The context value associated with this key. May also return a Promise which will be resolved before + * attaching to the context object. + * + * @public + */ +export type IContextProvider< + TContext extends Record, + TContextName extends keyof TContext, + TProviderParameters extends any[] = [] +> = ( + context: Partial, + ...rest: TProviderParameters +) => Promise | TContext[TContextName]; + +/** + * A function registered by a plugin to perform some action. + * + * @remarks + * A new `TContext` will be built for each handler before invoking. + * + * @public + */ +export type IContextHandler = ( + context: TContext, + ...rest: THandlerParameters +) => TReturn; + +type Promisify = T extends Promise ? Promise : Promise; + +/** + * An object that handles registration of context providers and configuring handlers with context. + * + * @remarks + * A {@link IContextContainer} can be used by any Core service or plugin (known as the "service owner") which wishes to + * expose APIs in a handler function. The container object will manage registering context providers and configuring a + * handler with all of the contexts that should be exposed to the handler's plugin. This is dependent on the + * dependencies that the handler's plugin declares. + * + * Contexts providers are executed in the order they were registered. Each provider gets access to context values + * provided by any plugins that it depends on. + * + * In order to configure a handler with context, you must call the {@link IContextContainer.createHandler} function and + * use the returned handler which will automatically build a context object when called. + * + * When registering context or creating handlers, the _calling plugin's opaque id_ must be provided. This id is passed + * in via the plugin's initializer and can be accessed from the {@link PluginInitializerContext.opaqueId} Note this + * should NOT be the context service owner's id, but the plugin that is actually registering the context or handler. + * + * ```ts + * // Correct + * class MyPlugin { + * private readonly handlers = new Map(); + * + * setup(core) { + * this.contextContainer = core.context.createContextContainer(); + * return { + * registerContext(pluginOpaqueId, contextName, provider) { + * this.contextContainer.registerContext(pluginOpaqueId, contextName, provider); + * }, + * registerRoute(pluginOpaqueId, path, handler) { + * this.handlers.set( + * path, + * this.contextContainer.createHandler(pluginOpaqueId, handler) + * ); + * } + * } + * } + * } + * + * // Incorrect + * class MyPlugin { + * private readonly handlers = new Map(); + * + * constructor(private readonly initContext: PluginInitializerContext) {} + * + * setup(core) { + * this.contextContainer = core.context.createContextContainer(); + * return { + * registerContext(contextName, provider) { + * // BUG! + * // This would leak this context to all handlers rather that only plugins that depend on the calling plugin. + * this.contextContainer.registerContext(this.initContext.opaqueId, contextName, provider); + * }, + * registerRoute(path, handler) { + * this.handlers.set( + * path, + * // BUG! + * // This handler will not receive any contexts provided by other dependencies of the calling plugin. + * this.contextContainer.createHandler(this.initContext.opaqueId, handler) + * ); + * } + * } + * } + * } + * ``` + * + * @public + */ +export interface IContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] +> { + /** + * Register a new context provider. + * + * @remarks + * The value (or resolved Promise value) returned by the `provider` function will be attached to the context object + * on the key specified by `contextName`. + * + * Throws an exception if more than one provider is registered for the same `contextName`. + * + * @param pluginOpaqueId - The plugin opaque ID for the plugin that registers this context. + * @param contextName - The key of the `TContext` object this provider supplies the value for. + * @param provider - A {@link IContextProvider} to be called each time a new context is created. + * @returns The {@link IContextContainer} for method chaining. + */ + registerContext( + pluginOpaqueId: PluginOpaqueId, + contextName: TContextName, + provider: IContextProvider + ): this; + + /** + * Create a new handler function pre-wired to context for the plugin. + * + * @param pluginOpaqueId - The plugin opaque ID for the plugin that registers this handler. + * @param handler - Handler function to pass context object to. + * @returns A function that takes `THandlerParameters`, calls `handler` with a new context, and returns a Promise of + * the `handler` return value. + */ + createHandler( + pluginOpaqueId: PluginOpaqueId, + handler: IContextHandler + ): (...rest: THandlerParameters) => Promisify; +} + +/** @internal */ +export class ContextContainer< + TContext extends Record, + THandlerReturn, + THandlerParameters extends any[] = [] +> implements IContextContainer { + /** + * Used to map contexts to their providers and associated plugin. In registration order which is tightly coupled to + * plugin load order. + */ + private readonly contextProviders = new Map< + keyof TContext, + { + provider: IContextProvider; + source: symbol; + } + >(); + /** Used to keep track of which plugins registered which contexts for dependency resolution. */ + private readonly contextNamesBySource: Map>; + + /** + * @param pluginDependencies - A map of plugins to an array of their dependencies. + */ + constructor( + private readonly pluginDependencies: ReadonlyMap, + private readonly coreId: CoreId + ) { + this.contextNamesBySource = new Map>([[coreId, []]]); + } + + public registerContext = ( + source: symbol, + contextName: TContextName, + provider: IContextProvider + ): this => { + if (this.contextProviders.has(contextName)) { + throw new Error(`Context provider for ${contextName} has already been registered.`); + } + if (source !== this.coreId && !this.pluginDependencies.has(source)) { + throw new Error(`Cannot register context for unknown plugin: ${source.toString()}`); + } + + this.contextProviders.set(contextName, { provider, source }); + this.contextNamesBySource.set(source, [ + ...(this.contextNamesBySource.get(source) || []), + contextName, + ]); + + return this; + }; + + public createHandler = ( + source: symbol, + handler: IContextHandler + ) => { + if (source !== this.coreId && !this.pluginDependencies.has(source)) { + throw new Error(`Cannot create handler for unknown plugin: ${source.toString()}`); + } + + return (async (...args: THandlerParameters) => { + const context = await this.buildContext(source, ...args); + return handler(context, ...args); + }) as (...args: THandlerParameters) => Promisify; + }; + + private async buildContext( + source: symbol, + ...contextArgs: THandlerParameters + ): Promise { + const contextsToBuild: ReadonlySet = new Set( + this.getContextNamesForSource(source) + ); + + return [...this.contextProviders] + .filter(([contextName]) => contextsToBuild.has(contextName)) + .reduce( + async (contextPromise, [contextName, { provider, source: providerSource }]) => { + const resolvedContext = await contextPromise; + + // For the next provider, only expose the context available based on the dependencies of the plugin that + // registered that provider. + const exposedContext = pick(resolvedContext, [ + ...this.getContextNamesForSource(providerSource), + ]); + + return { + ...resolvedContext, + [contextName]: await provider(exposedContext as Partial, ...contextArgs), + }; + }, + Promise.resolve({}) as Promise + ); + } + + private getContextNamesForSource(source: symbol): ReadonlySet { + if (source === this.coreId) { + return this.getContextNamesForCore(); + } else { + return this.getContextNamesForPluginId(source); + } + } + + private getContextNamesForCore() { + return new Set(this.contextNamesBySource.get(this.coreId)!); + } + + private getContextNamesForPluginId(pluginId: symbol) { + // If the source is a plugin... + const pluginDeps = this.pluginDependencies.get(pluginId); + if (!pluginDeps) { + // This case should never be hit, but let's be safe. + throw new Error(`Cannot create context for unknown plugin: ${pluginId.toString()}`); + } + + return new Set([ + // Core contexts + ...this.contextNamesBySource.get(this.coreId)!, + // Contexts source created + ...(this.contextNamesBySource.get(pluginId) || []), + // Contexts sources's dependencies created + ...flatten(pluginDeps.map(p => this.contextNamesBySource.get(p) || [])), + ]); + } +} diff --git a/src/core/public/context/context_service.mock.ts b/src/core/public/context/context_service.mock.ts new file mode 100644 index 0000000000000..289732247b379 --- /dev/null +++ b/src/core/public/context/context_service.mock.ts @@ -0,0 +1,42 @@ +/* + * 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 { ContextService, ContextSetup } from './context_service'; +import { contextMock } from './context.mock'; + +const createSetupContractMock = () => { + const setupContract: jest.Mocked = { + createContextContainer: jest.fn().mockImplementation(() => contextMock.create()), + }; + return setupContract; +}; + +type ContextServiceContract = PublicMethodsOf; +const createMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn(), + }; + mocked.setup.mockReturnValue(createSetupContractMock()); + return mocked; +}; + +export const contextServiceMock = { + create: createMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/core/public/context/context_service.test.mocks.ts b/src/core/public/context/context_service.test.mocks.ts new file mode 100644 index 0000000000000..765d7d94b19c5 --- /dev/null +++ b/src/core/public/context/context_service.test.mocks.ts @@ -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. + */ + +import { contextMock } from './context.mock'; + +export const MockContextConstructor = jest.fn(contextMock.create); +jest.doMock('./context', () => ({ + ContextContainer: MockContextConstructor, +})); diff --git a/src/legacy/ui/public/index_patterns/fields_fetcher.js b/src/core/public/context/context_service.test.ts similarity index 53% rename from src/legacy/ui/public/index_patterns/fields_fetcher.js rename to src/core/public/context/context_service.test.ts index ef543e8804ade..4441a1c5ae6b2 100644 --- a/src/legacy/ui/public/index_patterns/fields_fetcher.js +++ b/src/core/public/context/context_service.test.ts @@ -17,25 +17,20 @@ * under the License. */ -export class FieldsFetcher { - constructor(apiClient, metaFields) { - this.apiClient = apiClient; - this.metaFields = metaFields; - } - fetch(indexPattern, options) { - return this.fetchForWildcard(indexPattern.title, { - ...options, - type: indexPattern.type, - params: indexPattern.typeMeta && indexPattern.typeMeta.params, - }); - } +import { MockContextConstructor } from './context_service.test.mocks'; +import { ContextService } from './context_service'; +import { PluginOpaqueId } from '../plugins'; + +const pluginDependencies = new Map(); - fetchForWildcard(indexPatternId, options = {}) { - return this.apiClient.getFieldsForWildcard({ - pattern: indexPatternId, - metaFields: this.metaFields, - type: options.type, - params: options.params || {}, +describe('ContextService', () => { + describe('#setup()', () => { + test('createContextContainer returns a new container configured with pluginDependencies', () => { + const coreId = Symbol(); + const service = new ContextService({ coreId }); + const setup = service.setup({ pluginDependencies }); + expect(setup.createContextContainer()).toBeDefined(); + expect(MockContextConstructor).toHaveBeenCalledWith(pluginDependencies, coreId); }); - } -} + }); +}); diff --git a/src/core/public/context/context_service.ts b/src/core/public/context/context_service.ts new file mode 100644 index 0000000000000..7c2d151177f19 --- /dev/null +++ b/src/core/public/context/context_service.ts @@ -0,0 +1,117 @@ +/* + * 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 { IContextContainer, ContextContainer } from './context'; +import { CoreContext } from '../core_system'; +import { PluginOpaqueId } from '../plugins'; + +interface StartDeps { + pluginDependencies: ReadonlyMap; +} + +/** @internal */ +export class ContextService { + constructor(private readonly core: CoreContext) {} + + public setup({ pluginDependencies }: StartDeps): ContextSetup { + return { + createContextContainer: < + TContext extends {}, + THandlerReturn, + THandlerParameters extends any[] = [] + >() => + new ContextContainer( + pluginDependencies, + this.core.coreId + ), + }; + } +} + +/** + * {@inheritdoc IContextContainer} + * + * @example + * Say we're creating a plugin for rendering visualizations that allows new rendering methods to be registered. If we + * want to offer context to these rendering methods, we can leverage the ContextService to manage these contexts. + * ```ts + * export interface VizRenderContext { + * core: { + * i18n: I18nStart; + * uiSettings: UISettingsClientContract; + * } + * [contextName: string]: unknown; + * } + * + * export type VizRenderer = (context: VizRenderContext, domElement: HTMLElement) => () => void; + * + * class VizRenderingPlugin { + * private readonly vizRenderers = new Map () => void)>(); + * + * setup(core) { + * this.contextContainer = core.createContextContainer< + * VizRenderContext, + * ReturnType, + * [HTMLElement] + * >(); + * + * return { + * registerContext: this.contextContainer.registerContext, + * registerVizRenderer: (plugin: PluginOpaqueId, renderMethod: string, renderer: VizTypeRenderer) => + * this.vizRenderers.set(renderMethod, this.contextContainer.createHandler(plugin, renderer)), + * }; + * } + * + * start(core) { + * // Register the core context available to all renderers. Use the VizRendererContext's pluginId as the first arg. + * this.contextContainer.registerContext('viz_rendering', 'core', () => ({ + * i18n: core.i18n, + * uiSettings: core.uiSettings + * })); + * + * return { + * registerContext: this.contextContainer.registerContext, + * + * renderVizualization: (renderMethod: string, domElement: HTMLElement) => { + * if (!this.vizRenderer.has(renderMethod)) { + * throw new Error(`Render method '${renderMethod}' has not been registered`); + * } + * + * // The handler can now be called directly with only an `HTMLElement` and will automatically + * // have a new `context` object created and populated by the context container. + * const handler = this.vizRenderers.get(renderMethod) + * return handler(domElement); + * } + * }; + * } + * } + * ``` + * + * @public + */ +export interface ContextSetup { + /** + * Creates a new {@link IContextContainer} for a service owner. + */ + createContextContainer< + TContext extends {}, + THandlerReturn, + THandlerParmaters extends any[] = [] + >(): IContextContainer; +} diff --git a/src/core/public/context/index.ts b/src/core/public/context/index.ts new file mode 100644 index 0000000000000..0e63e1a6426f0 --- /dev/null +++ b/src/core/public/context/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 { ContextService, ContextSetup } from './context_service'; +export { IContextContainer, IContextProvider, IContextHandler } from './context'; diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index 4a96214b3e5de..d2494badfacdb 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -30,6 +30,7 @@ import { pluginsServiceMock } from './plugins/plugins_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { renderingServiceMock } from './rendering/rendering_service.mock'; +import { contextServiceMock } from './context/context_service.mock'; export const MockLegacyPlatformService = legacyPlatformServiceMock.create(); export const LegacyPlatformServiceConstructor = jest @@ -120,3 +121,9 @@ export const RenderingServiceConstructor = jest.fn().mockImplementation(() => Mo jest.doMock('./rendering', () => ({ RenderingService: RenderingServiceConstructor, })); + +export const MockContextService = contextServiceMock.create(); +export const ContextServiceConstructor = jest.fn().mockImplementation(() => MockContextService); +jest.doMock('./context', () => ({ + ContextService: ContextServiceConstructor, +})); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 044a40b275993..7310a8f33eba4 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -41,6 +41,7 @@ import { MockDocLinksService, MockRenderingService, RenderingServiceConstructor, + MockContextService, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -51,6 +52,7 @@ const defaultCoreSystemParams = { rootDomElement: document.createElement('div'), browserSupportsCsp: true, injectedMetadata: { + uiPlugins: [], csp: { warnLegacyBrowsers: true, }, @@ -160,6 +162,11 @@ describe('#setup()', () => { expect(MockApplicationService.setup).toHaveBeenCalledTimes(1); }); + it('calls context#setup()', async () => { + await setupCore(); + expect(MockContextService.setup).toHaveBeenCalledTimes(1); + }); + it('calls injectedMetadata#setup()', async () => { await setupCore(); expect(MockInjectedMetadataService.setup).toHaveBeenCalledTimes(1); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index f3f466df8a78e..a8d071746085c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -34,6 +34,7 @@ import { ApplicationService } from './application'; import { mapToObject } from '../utils/'; import { DocLinksService } from './doc_links'; import { RenderingService } from './rendering'; +import { ContextService } from './context'; interface Params { rootDomElement: HTMLElement; @@ -44,8 +45,12 @@ interface Params { } /** @internal */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface CoreContext {} +export type CoreId = symbol; + +/** @internal */ +export interface CoreContext { + coreId: CoreId; +} /** * The CoreSystem is the root of the new platform, and setups all parts @@ -69,6 +74,7 @@ export class CoreSystem { private readonly application: ApplicationService; private readonly docLinks: DocLinksService; private readonly rendering: RenderingService; + private readonly context: ContextService; private readonly rootDomElement: HTMLElement; private fatalErrorsSetup: FatalErrorsSetup | null = null; @@ -104,8 +110,9 @@ export class CoreSystem { this.docLinks = new DocLinksService(); this.rendering = new RenderingService(); - const core: CoreContext = {}; - this.plugins = new PluginsService(core); + const core: CoreContext = { coreId: Symbol('core') }; + this.context = new ContextService(core); + this.plugins = new PluginsService(core, injectedMetadata.uiPlugins); this.legacyPlatform = new LegacyPlatformService({ requireLegacyFiles, @@ -127,8 +134,12 @@ export class CoreSystem { const notifications = this.notifications.setup({ uiSettings }); const application = this.application.setup(); + const pluginDependencies = this.plugins.getOpaqueIds(); + const context = this.context.setup({ pluginDependencies }); + const core: InternalCoreSetup = { application, + context, fatalErrors: this.fatalErrorsSetup, http, injectedMetadata, diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index 7f33c23a8df23..0910635924ea2 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -46,7 +46,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiSelectable.loadingOptions": "Loading options", "euiSelectable.noAvailableOptions": "There aren't any options available", "euiSelectable.noMatchingOptions": [Function], - "euiStat.loadingText": [Function], + "euiStat.loadingText": "Statistic is loading", "euiStep.completeStep": "Step", "euiStep.incompleteStep": "Incomplete Step", "euiStepHorizontal.buttonTitle": [Function], diff --git a/src/core/public/i18n/i18n_service.tsx b/src/core/public/i18n/i18n_service.tsx index 0be47c14e441c..78411a7a418d7 100644 --- a/src/core/public/i18n/i18n_service.tsx +++ b/src/core/public/i18n/i18n_service.tsx @@ -244,9 +244,9 @@ export class I18nService { values={{ searchValue }} /> ), - 'euiStat.loadingText': () => ( - - ), + 'euiStat.loadingText': i18n.translate('core.euiStat.loadingText', { + defaultMessage: 'Statistic is loading', + }), 'euiStep.completeStep': i18n.translate('core.euiStep.completeStep', { defaultMessage: 'Step', description: diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 2a88ebf86ab0c..40a6fbf065164 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -50,7 +50,7 @@ import { ChromeRecentlyAccessedHistoryItem, } from './chrome'; import { FatalErrorsSetup, FatalErrorInfo } from './fatal_errors'; -import { HttpServiceBase, HttpSetup, HttpStart, HttpInterceptor } from './http'; +import { HttpSetup, HttpStart } from './http'; import { I18nStart } from './i18n'; import { InjectedMetadataSetup, InjectedMetadataStart, LegacyNavLink } from './injected_metadata'; import { @@ -66,9 +66,23 @@ import { Plugin, PluginInitializer, PluginInitializerContext } from './plugins'; import { UiSettingsClient, UiSettingsState, UiSettingsClientContract } from './ui_settings'; import { ApplicationSetup, Capabilities, ApplicationStart } from './application'; import { DocLinksStart } from './doc_links'; +import { IContextContainer, IContextProvider, ContextSetup, IContextHandler } from './context'; export { CoreContext, CoreSystem } from './core_system'; export { RecursiveReadonly } from '../utils'; +export { + HttpServiceBase, + HttpHeadersInit, + HttpRequestInit, + HttpFetchOptions, + HttpFetchQuery, + HttpErrorResponse, + HttpErrorRequest, + HttpInterceptor, + HttpResponse, + HttpHandler, + HttpBody, +} from './http'; /** * Core services exposed to the `Plugin` setup lifecycle @@ -80,6 +94,8 @@ export { RecursiveReadonly } from '../utils'; * https://github.com/Microsoft/web-build-tools/issues/1237 */ export interface CoreSetup { + /** {@link ContextSetup} */ + context: ContextSetup; /** {@link FatalErrorsSetup} */ fatalErrors: FatalErrorsSetup; /** {@link HttpSetup} */ @@ -146,12 +162,14 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, ChromeStart, + IContextContainer, + IContextHandler, + IContextProvider, + ContextSetup, DocLinksStart, ErrorToastOptions, FatalErrorInfo, FatalErrorsSetup, - HttpInterceptor, - HttpServiceBase, HttpSetup, HttpStart, I18nStart, diff --git a/src/core/public/legacy/legacy_service.test.ts b/src/core/public/legacy/legacy_service.test.ts index a514bf7154055..5f1c4e1cf6bf9 100644 --- a/src/core/public/legacy/legacy_service.test.ts +++ b/src/core/public/legacy/legacy_service.test.ts @@ -58,8 +58,10 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { LegacyPlatformService } from './legacy_service'; import { applicationServiceMock } from '../application/application_service.mock'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; const applicationSetup = applicationServiceMock.createSetupContract(); +const contextSetup = contextServiceMock.createSetupContract(); const fatalErrorsSetup = fatalErrorsServiceMock.createSetupContract(); const httpSetup = httpServiceMock.createSetupContract(); const injectedMetadataSetup = injectedMetadataServiceMock.createSetupContract(); @@ -75,6 +77,7 @@ const defaultParams = { const defaultSetupDeps = { core: { application: applicationSetup, + context: contextSetup, fatalErrors: fatalErrorsSetup, injectedMetadata: injectedMetadataSetup, notifications: notificationsSetup, diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index b1312eaa228d2..3682d86168dcd 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -26,6 +26,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock'; import { notificationServiceMock } from './notifications/notifications_service.mock'; import { overlayServiceMock } from './overlays/overlay_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; +import { contextServiceMock } from './context/context_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -40,6 +41,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; function createCoreSetupMock() { const mock: MockedKeys = { + context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), notifications: notificationServiceMock.createSetupContract(), diff --git a/src/core/public/overlays/overlay_service.ts b/src/core/public/overlays/overlay_service.ts index 0f9dec0b311c2..a9c44f63013c7 100644 --- a/src/core/public/overlays/overlay_service.ts +++ b/src/core/public/overlays/overlay_service.ts @@ -77,6 +77,7 @@ export interface OverlayStart { openModal: ( modalChildren: React.ReactNode, modalProps?: { + className?: string; closeButtonAriaLabel?: string; 'data-test-subj'?: string; } diff --git a/src/core/public/plugins/index.ts b/src/core/public/plugins/index.ts index fc16b6b004565..544d4cf49c632 100644 --- a/src/core/public/plugins/index.ts +++ b/src/core/public/plugins/index.ts @@ -18,5 +18,5 @@ */ export * from './plugins_service'; -export { Plugin, PluginInitializer } from './plugin'; +export { Plugin, PluginInitializer, PluginOpaqueId } from './plugin'; export { PluginInitializerContext } from './plugin_context'; diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts index bbe2baf006a85..6cbe0c7e0ed82 100644 --- a/src/core/public/plugins/plugin.test.ts +++ b/src/core/public/plugins/plugin.test.ts @@ -35,7 +35,8 @@ function createManifest( } let plugin: PluginWrapper>; -const initializerContext = {}; +const opaqueId = Symbol(); +const initializerContext = { opaqueId }; const addBasePath = (path: string) => path; beforeEach(() => { @@ -43,7 +44,7 @@ beforeEach(() => { mockPlugin.setup.mockClear(); mockPlugin.start.mockClear(); mockPlugin.stop.mockClear(); - plugin = new PluginWrapper(createManifest('plugin-a'), initializerContext); + plugin = new PluginWrapper(createManifest('plugin-a'), opaqueId, initializerContext); }); describe('PluginWrapper', () => { diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts index a24c19e3219f3..fe870bd23c7a0 100644 --- a/src/core/public/plugins/plugin.ts +++ b/src/core/public/plugins/plugin.ts @@ -22,6 +22,9 @@ import { PluginInitializerContext } from './plugin_context'; import { loadPluginBundle } from './plugin_loader'; import { CoreStart, CoreSetup } from '..'; +/** @public */ +export type PluginOpaqueId = symbol; + /** * The interface that should be returned by a `PluginInitializer`. * @@ -72,6 +75,7 @@ export class PluginWrapper< constructor( readonly discoveredPlugin: DiscoveredPlugin, + public readonly opaqueId: PluginOpaqueId, private readonly initializerContext: PluginInitializerContext ) { this.name = discoveredPlugin.id; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index bc77b139a86dc..3711ce08c9992 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -21,7 +21,7 @@ import { omit } from 'lodash'; import { DiscoveredPlugin } from '../../server'; import { CoreContext } from '../core_system'; -import { PluginWrapper } from './plugin'; +import { PluginWrapper, PluginOpaqueId } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; import { CoreSetup, CoreStart } from '../'; @@ -30,8 +30,12 @@ import { CoreSetup, CoreStart } from '../'; * * @public */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PluginInitializerContext {} +export interface PluginInitializerContext { + /** + * A symbol used to identify this plugin in the system. Needed when registering handlers or context providers. + */ + readonly opaqueId: PluginOpaqueId; +} /** * Provides a plugin-specific context passed to the plugin's construtor. This is currently @@ -43,9 +47,12 @@ export interface PluginInitializerContext {} */ export function createPluginInitializerContext( coreContext: CoreContext, + opaqueId: PluginOpaqueId, pluginManifest: DiscoveredPlugin ): PluginInitializerContext { - return {}; + return { + opaqueId, + }; } /** @@ -69,8 +76,9 @@ export function createPluginSetupContext< plugin: PluginWrapper ): CoreSetup { return { - http: deps.http, + context: omit(deps.context, 'setCurrentPlugin'), fatalErrors: deps.fatalErrors, + http: deps.http, notifications: deps.notifications, uiSettings: deps.uiSettings, }; diff --git a/src/core/public/plugins/plugins_service.mock.ts b/src/core/public/plugins/plugins_service.mock.ts index 4df57b05fda30..900f20422b826 100644 --- a/src/core/public/plugins/plugins_service.mock.ts +++ b/src/core/public/plugins/plugins_service.mock.ts @@ -38,6 +38,7 @@ const createStartContractMock = () => { type PluginsServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + getOpaqueIds: jest.fn(), setup: jest.fn(), start: jest.fn(), stop: jest.fn(), diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 55e91bde27cb0..bc3fe95cf4c9c 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -25,7 +25,7 @@ import { mockPluginInitializerProvider, } from './plugins_service.test.mocks'; -import { PluginName } from 'src/core/server'; +import { PluginName, DiscoveredPlugin } from 'src/core/server'; import { CoreContext } from '../core_system'; import { PluginsService, @@ -43,6 +43,7 @@ import { injectedMetadataServiceMock } from '../injected_metadata/injected_metad import { httpServiceMock } from '../http/http_service.mock'; import { CoreSetup, CoreStart } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; export let mockPluginInitializers: Map; @@ -50,35 +51,37 @@ mockPluginInitializerProvider.mockImplementation( pluginName => mockPluginInitializers.get(pluginName)! ); +let plugins: Array<{ id: string; plugin: DiscoveredPlugin }>; + type DeeplyMocked = { [P in keyof T]: jest.Mocked }; -const mockCoreContext: CoreContext = {}; +const mockCoreContext: CoreContext = { coreId: Symbol() }; let mockSetupDeps: DeeplyMocked; let mockSetupContext: DeeplyMocked; let mockStartDeps: DeeplyMocked; let mockStartContext: DeeplyMocked; beforeEach(() => { + plugins = [ + { id: 'pluginA', plugin: createManifest('pluginA') }, + { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, + { + id: 'pluginC', + plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), + }, + ]; mockSetupDeps = { application: applicationServiceMock.createSetupContract(), - injectedMetadata: (function() { - const metadata = injectedMetadataServiceMock.createSetupContract(); - metadata.getPlugins.mockReturnValue([ - { id: 'pluginA', plugin: createManifest('pluginA') }, - { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) }, - { - id: 'pluginC', - plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }), - }, - ]); - return metadata; - })(), + context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), }; - mockSetupContext = omit(mockSetupDeps, 'application', 'injectedMetadata'); + mockSetupContext = { + ...omit(mockSetupDeps, 'application', 'injectedMetadata'), + }; mockStartDeps = { application: applicationServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), @@ -148,10 +151,25 @@ function createManifest( }; } +test('`PluginsService.getOpaqueIds` returns dependency tree of symbols', () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); + expect(pluginsService.getOpaqueIds()).toMatchInlineSnapshot(` + Map { + Symbol(pluginA) => Array [], + Symbol(pluginB) => Array [ + Symbol(pluginA), + ], + Symbol(pluginC) => Array [ + Symbol(pluginA), + ], + } + `); +}); + test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => { mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle')); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Could not load bundle"` ); @@ -159,14 +177,14 @@ test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => { mockPluginInitializers.set('pluginA', (() => ({})) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await expect(pluginsService.setup(mockSetupDeps)).rejects.toThrowErrorMatchingInlineSnapshot( `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."` ); }); test('`PluginsService.setup` calls loadPluginBundles with http and plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3); @@ -175,17 +193,17 @@ test('`PluginsService.setup` calls loadPluginBundles with http and plugins', asy expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockSetupDeps.http.basePath.prepend, 'pluginC'); }); -test('`PluginsService.setup` initalizes plugins with CoreContext', async () => { - const pluginsService = new PluginsService(mockCoreContext); +test('`PluginsService.setup` initalizes plugins with PluginIntitializerContext', async () => { + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); - expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(mockCoreContext); - expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(mockCoreContext); - expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(mockCoreContext); + expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(expect.any(Object)); + expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(expect.any(Object)); }); test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; @@ -203,15 +221,13 @@ test('`PluginsService.setup` exposes dependent setup contracts to plugins', asyn }); test('`PluginsService.setup` does not set missing dependent setup contracts', async () => { - mockSetupDeps.injectedMetadata.getPlugins.mockReturnValue([ - { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) }, - ]); + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn(), start: jest.fn(), })) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); // If a dependency is missing it should not be in the deps at all, not even as undefined. @@ -222,7 +238,7 @@ test('`PluginsService.setup` does not set missing dependent setup contracts', as }); test('`PluginsService.setup` returns plugin setup contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); const { contracts } = await pluginsService.setup(mockSetupDeps); // Verify that plugin contracts were available @@ -231,7 +247,7 @@ test('`PluginsService.setup` returns plugin setup contracts', async () => { }); test('`PluginsService.start` exposes dependent start contracts to plugins', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); await pluginsService.start(mockStartDeps); @@ -250,15 +266,13 @@ test('`PluginsService.start` exposes dependent start contracts to plugins', asyn }); test('`PluginsService.start` does not set missing dependent start contracts', async () => { - mockSetupDeps.injectedMetadata.getPlugins.mockReturnValue([ - { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) }, - ]); + plugins = [{ id: 'pluginD', plugin: createManifest('pluginD', { optional: ['missing'] }) }]; mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn(), start: jest.fn(), })) as any); - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); await pluginsService.start(mockStartDeps); @@ -270,7 +284,7 @@ test('`PluginsService.start` does not set missing dependent start contracts', as }); test('`PluginsService.start` returns plugin start contracts', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const { contracts } = await pluginsService.start(mockStartDeps); @@ -280,7 +294,7 @@ test('`PluginsService.start` returns plugin start contracts', async () => { }); test('`PluginService.stop` calls the stop function on each plugin', async () => { - const pluginsService = new PluginsService(mockCoreContext); + const pluginsService = new PluginsService(mockCoreContext, plugins); await pluginsService.setup(mockSetupDeps); const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value; diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 03725a9d7f883..902c883c74bbc 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -17,10 +17,10 @@ * under the License. */ -import { PluginName } from '../../server'; +import { DiscoveredPlugin, PluginName } from '../../server'; import { CoreService } from '../../types'; import { CoreContext } from '../core_system'; -import { PluginWrapper } from './plugin'; +import { PluginWrapper, PluginOpaqueId } from './plugin'; import { createPluginInitializerContext, createPluginSetupContext, @@ -35,11 +35,11 @@ export type PluginsServiceStartDeps = InternalCoreStart; /** @internal */ export interface PluginsServiceSetup { - contracts: Map; + contracts: ReadonlyMap; } /** @internal */ export interface PluginsServiceStart { - contracts: Map; + contracts: ReadonlyMap; } /** @@ -50,37 +50,56 @@ export interface PluginsServiceStart { */ export class PluginsService implements CoreService { /** Plugin wrappers in topological order. */ - private readonly plugins: Map< - PluginName, - PluginWrapper> - > = new Map(); + private readonly plugins = new Map>>(); + private readonly pluginDependencies = new Map(); + private readonly satupPlugins: PluginName[] = []; - constructor(private readonly coreContext: CoreContext) {} + constructor( + private readonly coreContext: CoreContext, + plugins: Array<{ id: PluginName; plugin: DiscoveredPlugin }> + ) { + // Generate opaque ids + const opaqueIds = new Map(plugins.map(p => [p.id, Symbol(p.id)])); + + // Setup dependency map and plugin wrappers + plugins.forEach(({ id, plugin }) => { + // Setup map of dependencies + this.pluginDependencies.set(id, [ + ...plugin.requiredPlugins, + ...plugin.optionalPlugins.filter(optPlugin => opaqueIds.has(optPlugin)), + ]); - public async setup(deps: PluginsServiceSetupDeps) { - // Construct plugin wrappers, depending on the topological order set by the server. - deps.injectedMetadata - .getPlugins() - .forEach(({ id, plugin }) => - this.plugins.set( - id, - new PluginWrapper(plugin, createPluginInitializerContext(deps, plugin)) + // Construct plugin wrappers, depending on the topological order set by the server. + this.plugins.set( + id, + new PluginWrapper( + plugin, + opaqueIds.get(id)!, + createPluginInitializerContext(this.coreContext, opaqueIds.get(id)!, plugin) ) ); + }); + } + + public getOpaqueIds(): ReadonlyMap { + // Return dependency map of opaque ids + return new Map( + [...this.pluginDependencies].map(([id, deps]) => [ + this.plugins.get(id)!.opaqueId, + deps.map(depId => this.plugins.get(depId)!.opaqueId), + ]) + ); + } + public async setup(deps: PluginsServiceSetupDeps): Promise { // Load plugin bundles await this.loadPluginBundles(deps.http.basePath.prepend); // Setup each plugin with required and optional plugin contracts const contracts = new Map(); for (const [pluginName, plugin] of this.plugins.entries()) { - const pluginDeps = new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter(optPlugin => this.plugins.get(optPlugin)), - ]); - - const pluginDepContracts = [...pluginDeps.keys()].reduce( + const pluginDepContracts = [...this.pluginDependencies.get(pluginName)!].reduce( (depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have client-side code or is a // missing optional plugin. @@ -108,16 +127,11 @@ export class PluginsService implements CoreService { // Setup each plugin with required and optional plugin contracts const contracts = new Map(); for (const [pluginName, plugin] of this.plugins.entries()) { - const pluginDeps = new Set([ - ...plugin.requiredPlugins, - ...plugin.optionalPlugins.filter(optPlugin => this.plugins.get(optPlugin)), - ]); - - const pluginDepContracts = [...pluginDeps.keys()].reduce( + const pluginDepContracts = [...this.pluginDependencies.get(pluginName)!].reduce( (depContracts, dependencyName) => { // Only set if present. Could be absent if plugin does not have client-side code or is a // missing optional plugin. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 36c5ed84cd248..6058bc3dcb809 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -20,18 +20,12 @@ export interface ApplicationSetup { registerLegacyApp(app: LegacyApp): void; } -// Warning: (ae-missing-release-tag) "ApplicationStart" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export interface ApplicationStart { - // Warning: (ae-forgotten-export) The symbol "CapabilitiesStart" needs to be exported by the entry point index.d.ts - // - // (undocumented) - availableApps: CapabilitiesStart['availableApps']; - // (undocumented) - capabilities: CapabilitiesStart['capabilities']; - // (undocumented) - mount: (mountHandler: Function) => void; + availableApps: readonly App[]; + // @internal + availableLegacyApps: readonly LegacyApp[]; + capabilities: RecursiveReadonly; } // @public @@ -95,18 +89,25 @@ export interface ChromeNavControls { // @public (undocumented) export interface ChromeNavLink { + // @deprecated readonly active?: boolean; readonly baseUrl: string; + // @deprecated readonly disabled?: boolean; readonly euiIconType?: string; readonly hidden?: boolean; readonly icon?: string; readonly id: string; + // @internal + readonly legacy: boolean; + // @deprecated readonly linkToLastSubUrl?: boolean; readonly order: number; + // @deprecated readonly subUrlBase?: string; readonly title: string; readonly tooltip?: string; + // @deprecated readonly url?: string; } @@ -166,12 +167,23 @@ export interface ChromeStart { setIsVisible(isVisible: boolean): void; } +// @public +export interface ContextSetup { + createContextContainer(): IContextContainer; +} + // @internal (undocumented) export interface CoreContext { + // Warning: (ae-forgotten-export) The symbol "CoreId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + coreId: CoreId; } // @public export interface CoreSetup { + // (undocumented) + context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; // (undocumented) @@ -329,26 +341,104 @@ export interface FatalErrorsSetup { get$: () => Rx.Observable; } +// @public (undocumented) +export type HttpBody = BodyInit | null | any; + +// @public (undocumented) +export interface HttpErrorRequest { + // (undocumented) + error: Error; + // (undocumented) + request?: Request; +} + +// @public (undocumented) +export interface HttpErrorResponse extends HttpResponse { + // Warning: (ae-forgotten-export) The symbol "HttpFetchError" needs to be exported by the entry point index.d.ts + // + // (undocumented) + error: Error | HttpFetchError; +} + +// @public (undocumented) +export interface HttpFetchOptions extends HttpRequestInit { + // (undocumented) + headers?: HttpHeadersInit; + // (undocumented) + prependBasePath?: boolean; + // (undocumented) + query?: HttpFetchQuery; +} + +// @public (undocumented) +export interface HttpFetchQuery { + // (undocumented) + [key: string]: string | number | boolean | undefined; +} + +// @public (undocumented) +export type HttpHandler = (path: string, options?: HttpFetchOptions) => Promise; + +// @public (undocumented) +export interface HttpHeadersInit { + // (undocumented) + [name: string]: any; +} + // @public (undocumented) export interface HttpInterceptor { // Warning: (ae-forgotten-export) The symbol "HttpInterceptController" needs to be exported by the entry point index.d.ts // // (undocumented) request?(request: Request, controller: HttpInterceptController): Promise | Request | void; - // Warning: (ae-forgotten-export) The symbol "HttpErrorRequest" needs to be exported by the entry point index.d.ts - // // (undocumented) requestError?(httpErrorRequest: HttpErrorRequest, controller: HttpInterceptController): Promise | Request | void; - // Warning: (ae-forgotten-export) The symbol "HttpResponse" needs to be exported by the entry point index.d.ts - // // (undocumented) response?(httpResponse: HttpResponse, controller: HttpInterceptController): Promise | HttpResponse | void; - // Warning: (ae-forgotten-export) The symbol "HttpErrorResponse" needs to be exported by the entry point index.d.ts - // // (undocumented) responseError?(httpErrorResponse: HttpErrorResponse, controller: HttpInterceptController): Promise | HttpResponse | void; } +// @public (undocumented) +export interface HttpRequestInit { + // (undocumented) + body?: BodyInit | null; + // (undocumented) + cache?: RequestCache; + // (undocumented) + credentials?: RequestCredentials; + // (undocumented) + headers?: HttpHeadersInit; + // (undocumented) + integrity?: string; + // (undocumented) + keepalive?: boolean; + // (undocumented) + method?: string; + // (undocumented) + mode?: RequestMode; + // (undocumented) + redirect?: RequestRedirect; + // (undocumented) + referrer?: string; + // (undocumented) + referrerPolicy?: ReferrerPolicy; + // (undocumented) + signal?: AbortSignal | null; + // (undocumented) + window?: any; +} + +// @public (undocumented) +export interface HttpResponse { + // (undocumented) + body?: HttpBody; + // (undocumented) + request: Request; + // (undocumented) + response?: Response; +} + // @public (undocumented) export interface HttpServiceBase { // (undocumented) @@ -361,8 +451,6 @@ export interface HttpServiceBase { }; // (undocumented) delete: HttpHandler; - // Warning: (ae-forgotten-export) The symbol "HttpHandler" needs to be exported by the entry point index.d.ts - // // (undocumented) fetch: HttpHandler; // (undocumented) @@ -400,6 +488,20 @@ export interface I18nStart { }) => JSX.Element; } +// @public +export interface IContextContainer { + // Warning: (ae-forgotten-export) The symbol "Promisify" needs to be exported by the entry point index.d.ts + createHandler(pluginOpaqueId: PluginOpaqueId, handler: IContextHandler): (...rest: THandlerParameters) => Promisify; + // Warning: (ae-forgotten-export) The symbol "PluginOpaqueId" needs to be exported by the entry point index.d.ts + registerContext(pluginOpaqueId: PluginOpaqueId, contextName: TContextName, provider: IContextProvider): this; +} + +// @public +export type IContextHandler = (context: TContext, ...rest: THandlerParameters) => TReturn; + +// @public +export type IContextProvider, TContextName extends keyof TContext, TProviderParameters extends any[] = []> = (context: Partial, ...rest: TProviderParameters) => Promise | TContext[TContextName]; + // @internal (undocumented) export interface InternalCoreSetup extends CoreSetup { // (undocumented) @@ -469,6 +571,7 @@ export interface OverlayStart { }) => OverlayRef; // (undocumented) openModal: (modalChildren: React.ReactNode, modalProps?: { + className?: string; closeButtonAriaLabel?: string; 'data-test-subj'?: string; }) => OverlayRef; @@ -489,6 +592,7 @@ export type PluginInitializer undefined; /** * The set of options that defines how API call should be made and result be diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 1d439dfba49e9..f732f9e39b9e3 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -18,7 +18,7 @@ */ export { ElasticsearchServiceSetup, ElasticsearchService } from './elasticsearch_service'; -export { CallAPIOptions, ClusterClient, FakeRequest, LegacyRequest } from './cluster_client'; +export { CallAPIOptions, ClusterClient, FakeRequest } from './cluster_client'; export { ScopedClusterClient, Headers, APICaller } from './scoped_cluster_client'; export { ElasticsearchClientConfig } from './elasticsearch_client_config'; export { config } from './elasticsearch_config'; diff --git a/src/core/server/http/auth_headers_storage.ts b/src/core/server/http/auth_headers_storage.ts index bc3b55b3718c0..469e194a61fed 100644 --- a/src/core/server/http/auth_headers_storage.ts +++ b/src/core/server/http/auth_headers_storage.ts @@ -16,19 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -import { Request } from 'hapi'; -import { KibanaRequest, ensureRawRequest } from './router'; +import { KibanaRequest, ensureRawRequest, LegacyRequest } from './router'; import { AuthHeaders } from './lifecycle/auth'; /** * Get headers to authenticate a user against Elasticsearch. + * @param request {@link KibanaRequest} - an incoming request. + * @return authentication headers {@link AuthHeaders} for - an incoming request. * @public * */ -export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; +export type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; +/** @internal */ export class AuthHeadersStorage { - private authHeadersCache = new WeakMap(); - public set = (request: KibanaRequest | Request, headers: AuthHeaders) => { + private authHeadersCache = new WeakMap(); + public set = (request: KibanaRequest | LegacyRequest, headers: AuthHeaders) => { this.authHeadersCache.set(ensureRawRequest(request), headers); }; public get: GetAuthHeaders = request => { diff --git a/src/core/server/http/auth_state_storage.ts b/src/core/server/http/auth_state_storage.ts index 79fd9ed64f3b5..059dc7f380351 100644 --- a/src/core/server/http/auth_state_storage.ts +++ b/src/core/server/http/auth_state_storage.ts @@ -16,22 +16,51 @@ * specific language governing permissions and limitations * under the License. */ -import { Request } from 'hapi'; -import { KibanaRequest, ensureRawRequest } from './router'; +import { ensureRawRequest, KibanaRequest, LegacyRequest } from './router'; +/** + * Status indicating an outcome of the authentication. + * @public + */ export enum AuthStatus { + /** + * `auth` interceptor successfully authenticated a user + */ authenticated = 'authenticated', + /** + * `auth` interceptor failed user authentication + */ unauthenticated = 'unauthenticated', + /** + * `auth` interceptor has not been registered + */ unknown = 'unknown', } +/** + * Get authentication state for a request. Returned by `auth` interceptor. + * @param request {@link KibanaRequest} - an incoming request. + * @public + */ +export type GetAuthState = ( + request: KibanaRequest | LegacyRequest +) => { status: AuthStatus; state: unknown }; + +/** + * Return authentication status for a request. + * @param request {@link KibanaRequest} - an incoming request. + * @public + */ +export type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolean; + +/** @internal */ export class AuthStateStorage { - private readonly storage = new WeakMap(); + private readonly storage = new WeakMap(); constructor(private readonly canBeAuthenticated: () => boolean) {} - public set = (request: KibanaRequest | Request, state: unknown) => { + public set = (request: KibanaRequest | LegacyRequest, state: unknown) => { this.storage.set(ensureRawRequest(request), state); }; - public get = (request: KibanaRequest | Request) => { + public get: GetAuthState = request => { const key = ensureRawRequest(request); const state = this.storage.get(key); const status: AuthStatus = this.storage.has(key) @@ -42,7 +71,7 @@ export class AuthStateStorage { return { status, state }; }; - public isAuthenticated = (request: KibanaRequest | Request) => { + public isAuthenticated: IsAuthenticated = request => { return this.get(request).status === AuthStatus.authenticated; }; } diff --git a/src/core/server/http/base_path_proxy_server.ts b/src/core/server/http/base_path_proxy_server.ts index 2335f35ab63c1..ff7fee0198f68 100644 --- a/src/core/server/http/base_path_proxy_server.ts +++ b/src/core/server/http/base_path_proxy_server.ts @@ -18,7 +18,8 @@ */ import { ByteSizeValue } from '@kbn/config-schema'; -import { Server } from 'hapi'; +import { Server, Request } from 'hapi'; +import Url from 'url'; import { Agent as HttpsAgent, ServerOptions as TlsOptions } from 'https'; import { sample } from 'lodash'; import { DevConfig } from '../dev'; @@ -146,6 +147,38 @@ export class BasePathProxyServer { path: `${this.httpConfig.basePath}/{kbnPath*}`, }); + this.server.route({ + handler: { + proxy: { + agent: this.httpsAgent, + passThrough: true, + xforward: true, + mapUri: (request: Request) => ({ + uri: Url.format({ + hostname: request.server.info.host, + port: this.devConfig.basePathProxyTargetPort, + protocol: request.server.info.protocol, + pathname: `${this.httpConfig.basePath}/${request.params.kbnPath}`, + query: request.query, + }), + headers: request.headers, + }), + }, + }, + method: '*', + options: { + pre: [ + // Before we proxy request to a target port we may want to wait until some + // condition is met (e.g. until target listener is ready). + async (request, responseToolkit) => { + await blockUntil(); + return responseToolkit.continue; + }, + ], + }, + path: `/__UNSAFE_bypassBasePath/{kbnPath*}`, + }); + // It may happen that basepath has changed, but user still uses the old one, // so we can try to check if that's the case and just redirect user to the // same URL, but with valid basepath. diff --git a/src/core/server/http/base_path_service.ts b/src/core/server/http/base_path_service.ts index df189d29f2f59..951463a2c9919 100644 --- a/src/core/server/http/base_path_service.ts +++ b/src/core/server/http/base_path_service.ts @@ -16,24 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import { Request } from 'hapi'; -import { KibanaRequest, ensureRawRequest } from './router'; +import { ensureRawRequest, KibanaRequest, LegacyRequest } from './router'; import { modifyUrl } from '../../utils'; export class BasePath { - private readonly basePathCache = new WeakMap(); + private readonly basePathCache = new WeakMap(); constructor(private readonly serverBasePath?: string) {} - public get = (request: KibanaRequest | Request) => { + public get = (request: KibanaRequest | LegacyRequest) => { const requestScopePath = this.basePathCache.get(ensureRawRequest(request)) || ''; const serverBasePath = this.serverBasePath || ''; return `${serverBasePath}${requestScopePath}`; }; // should work only for KibanaRequest as soon as spaces migrate to NP - public set = (request: KibanaRequest | Request, requestSpecificBasePath: string) => { + public set = (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => { const rawRequest = ensureRawRequest(request); if (this.basePathCache.has(rawRequest)) { diff --git a/src/core/server/http/cookie_session_storage.ts b/src/core/server/http/cookie_session_storage.ts index 7b2569a1c6dd3..8a1b56d87fb4c 100644 --- a/src/core/server/http/cookie_session_storage.ts +++ b/src/core/server/http/cookie_session_storage.ts @@ -24,10 +24,26 @@ import { KibanaRequest, ensureRawRequest } from './router'; import { SessionStorageFactory, SessionStorage } from './session_storage'; import { Logger } from '..'; +/** + * Configuration used to create HTTP session storage based on top of cookie mechanism. + * @public + */ export interface SessionStorageCookieOptions { + /** + * Name of the session cookie. + */ name: string; + /** + * A key used to encrypt a cookie value. Should be at least 32 characters long. + */ encryptionKey: string; + /** + * Function called to validate a cookie content. + */ validate: (sessionValue: T) => boolean | Promise; + /** + * Flag indicating whether the cookie should be sent only via a secure connection. + */ isSecure: boolean; } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index aa341db20a6c9..83e569e8752c5 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -28,7 +28,7 @@ jest.mock('fs', () => ({ import Chance from 'chance'; import supertest from 'supertest'; -import { ByteSizeValue } from '@kbn/config-schema'; +import { ByteSizeValue, schema } from '@kbn/config-schema'; import { HttpConfig, Router } from '.'; import { loggingServiceMock } from '../logging/logging_service.mock'; import { HttpServer } from './http_server'; @@ -102,98 +102,17 @@ Array [ `); }); -test('200 OK with body', async () => { - const router = new Router('/foo'); - - router.get({ path: '/', validate: false }, (req, res) => { - return res.ok({ key: 'value' }); - }); - - const { registerRouter, server: innerServer } = await server.setup(config); - registerRouter(router); - await server.start(); - - await supertest(innerServer.listener) - .get('/foo/') - .expect(200) - .then(res => { - expect(res.body).toEqual({ key: 'value' }); - }); -}); - -test('202 Accepted with body', async () => { - const router = new Router('/foo'); - - router.get({ path: '/', validate: false }, (req, res) => { - return res.accepted({ location: 'somewhere' }); - }); - - const { registerRouter, server: innerServer } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await supertest(innerServer.listener) - .get('/foo/') - .expect(202) - .then(res => { - expect(res.body).toEqual({ location: 'somewhere' }); - }); -}); - -test('204 No content', async () => { - const router = new Router('/foo'); - - router.get({ path: '/', validate: false }, (req, res) => { - return res.noContent(); - }); - - const { registerRouter, server: innerServer } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await supertest(innerServer.listener) - .get('/foo/') - .expect(204) - .then(res => { - expect(res.body).toEqual({}); - // TODO Is ^ wrong or just a result of supertest, I expect `null` or `undefined` - }); -}); - -test('400 Bad request with error', async () => { - const router = new Router('/foo'); - - router.get({ path: '/', validate: false }, (req, res) => { - const err = new Error('some message'); - return res.badRequest(err); - }); - - const { registerRouter, server: innerServer } = await server.setup(config); - registerRouter(router); - - await server.start(); - - await supertest(innerServer.listener) - .get('/foo/') - .expect(400) - .then(res => { - expect(res.body).toEqual({ error: 'some message' }); - }); -}); - test('valid params', async () => { const router = new Router('/foo'); router.get( { path: '/{test}', - validate: schema => ({ + validate: { params: schema.object({ test: schema.string(), }), - }), + }, }, (req, res) => { return res.ok({ key: req.params.test }); @@ -219,11 +138,11 @@ test('invalid params', async () => { router.get( { path: '/{test}', - validate: schema => ({ + validate: { params: schema.object({ test: schema.number(), }), - }), + }, }, (req, res) => { return res.ok({ key: req.params.test }); @@ -240,7 +159,7 @@ test('invalid params', async () => { .expect(400) .then(res => { expect(res.body).toEqual({ - error: '[test]: expected value of type [number] but got [string]', + error: '[request params.test]: expected value of type [number] but got [string]', }); }); }); @@ -251,12 +170,12 @@ test('valid query', async () => { router.get( { path: '/', - validate: schema => ({ + validate: { query: schema.object({ bar: schema.string(), quux: schema.number(), }), - }), + }, }, (req, res) => { return res.ok(req.query); @@ -282,11 +201,11 @@ test('invalid query', async () => { router.get( { path: '/', - validate: schema => ({ + validate: { query: schema.object({ bar: schema.number(), }), - }), + }, }, (req, res) => { return res.ok(req.query); @@ -303,7 +222,7 @@ test('invalid query', async () => { .expect(400) .then(res => { expect(res.body).toEqual({ - error: '[bar]: expected value of type [number] but got [string]', + error: '[request query.bar]: expected value of type [number] but got [string]', }); }); }); @@ -314,12 +233,12 @@ test('valid body', async () => { router.post( { path: '/', - validate: schema => ({ + validate: { body: schema.object({ bar: schema.string(), baz: schema.number(), }), - }), + }, }, (req, res) => { return res.ok(req.body); @@ -349,11 +268,11 @@ test('invalid body', async () => { router.post( { path: '/', - validate: schema => ({ + validate: { body: schema.object({ bar: schema.number(), }), - }), + }, }, (req, res) => { return res.ok(req.body); @@ -371,7 +290,7 @@ test('invalid body', async () => { .expect(400) .then(res => { expect(res.body).toEqual({ - error: '[bar]: expected value of type [number] but got [string]', + error: '[request body.bar]: expected value of type [number] but got [string]', }); }); }); @@ -382,11 +301,11 @@ test('handles putting', async () => { router.put( { path: '/', - validate: schema => ({ + validate: { body: schema.object({ key: schema.string(), }), - }), + }, }, (req, res) => { return res.ok(req.body); @@ -413,11 +332,11 @@ test('handles deleting', async () => { router.delete( { path: '/{id}', - validate: schema => ({ + validate: { params: schema.object({ id: schema.number(), }), - }), + }, }, (req, res) => { return res.ok({ key: req.params.id }); @@ -917,6 +836,99 @@ describe('setup contract', () => { expect(fromRegisterOnPostAuth).toEqual({}); expect(fromRouteHandler).toEqual({}); }); + + it('attach security header to a successful response', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { registerAuth, registerRouter, server: innerServer } = await server.setup(config); + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, (req, res) => res.ok({ header: 'ok' })); + registerRouter(router); + + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']); + }); + + it('attach security header to an error response', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { registerAuth, registerRouter, server: innerServer } = await server.setup(config); + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, (req, res) => res.badRequest(new Error('reason'))); + registerRouter(router); + + await server.start(); + + const response = await supertest(innerServer.listener) + .get('/') + .expect(400); + + expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']); + }); + + // TODO un-skip when NP ResponseFactory supports configuring custom headers + it.skip('logs warning if Auth Security Header rewrites response header for success response', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { registerAuth, registerRouter, server: innerServer } = await server.setup(config); + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, (req, res) => res.ok({})); + registerRouter(router); + + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(); + }); + + it.skip('logs warning if Auth Security Header rewrites response header for error response', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { registerAuth, registerRouter, server: innerServer } = await server.setup(config); + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + const router = new Router('/'); + router.get({ path: '/', validate: false }, (req, res) => res.badRequest(new Error('reason'))); + registerRouter(router); + + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(400); + + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(); + }); }); describe('#auth.isAuthenticated()', () => { diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 4ca76d405a1fb..d90fb880a581c 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Request, Server } from 'hapi'; +import { Request, Server, ResponseToolkit } from 'hapi'; import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; @@ -25,21 +25,98 @@ import { createServer, getListenerOptions, getServerOptions } from './http_tools import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; -import { Router, KibanaRequest } from './router'; +import { KibanaRequest, LegacyRequest, ResponseHeaders, Router } from './router'; import { SessionStorageCookieOptions, createCookieSessionStorageFactory, } from './cookie_session_storage'; import { SessionStorageFactory } from './session_storage'; -import { AuthStateStorage } from './auth_state_storage'; -import { AuthHeadersStorage } from './auth_headers_storage'; +import { AuthStateStorage, GetAuthState, IsAuthenticated } from './auth_state_storage'; +import { AuthHeadersStorage, GetAuthHeaders } from './auth_headers_storage'; import { BasePath } from './base_path_service'; +/** + * Kibana HTTP Service provides own abstraction for work with HTTP stack. + * Plugins don't have direct access to `hapi` server and its primitives anymore. Moreover, + * plugins shouldn't rely on the fact that HTTP Service uses one or another library under the hood. + * This gives the platform flexibility to upgrade or changing our internal HTTP stack without breaking plugins. + * If the HTTP Service lacks functionality you need, we are happy to discuss and support your needs. + * + * @example + * To handle an incoming request in your plugin you should: + * - Create a `Router` instance. Use `plugin-id` as a prefix path segment for your routes. + * ```ts + * import { Router } from 'src/core/server'; + * const router = new Router('my-app'); + * ``` + * + * - Use `@kbn/config-schema` package to create a schema to validate the request `params`, `query`, and `body`. Every incoming request will be validated against the created schema. If validation failed, the request is rejected with `400` status and `Bad request` error without calling the route's handler. + * To opt out of validating the request, specify `false`. + * ```ts + * import { schema, TypeOf } from '@kbn/config-schema'; + * const validate = { + * params: schema.object({ + * id: schema.string(), + * }), + * }; + * ``` + * + * - Declare a function to respond to incoming request. + * The function will receive `request` object containing request details: url, headers, matched route, as well as validated `params`, `query`, `body`. + * And `response` object instructing HTTP server to create HTTP response with information sent back to the client as the response body, headers, and HTTP status. + * Unlike, `hapi` route handler in the Legacy platform, any exception raised during the handler call will generate `500 Server error` response and log error details for further investigation. See below for returning custom error responses. + * ```ts + * const handler = async (request: KibanaRequest, response: ResponseFactory) => { + * const data = await findObject(request.params.id); + * // creates a command to respond with 'not found' error + * if (!data) return response.notFound(); + * // creates a command to send found data to the client and set response headers + * return response.ok(data, { + * headers: { + * 'content-type': 'application/json' + * } + * }); + * } + * ``` + * + * - Register route handler for GET request to 'my-app/path/{id}' path + * ```ts + * import { schema, TypeOf } from '@kbn/config-schema'; + * import { Router } from 'src/core/server'; + * const router = new Router('my-app'); + * + * const validate = { + * params: schema.object({ + * id: schema.string(), + * }), + * }; + * + * router.get({ + * path: 'path/{id}', + * validate + * }, + * async (request, response) => { + * const data = await findObject(request.params.id); + * if (!data) return response.notFound(); + * return response.ok(data, { + * headers: { + * 'content-type': 'application/json' + * } + * }); + * }); + * ``` + * @public + */ export interface HttpServerSetup { server: Server; + /** + * Add all the routes registered with `router` to HTTP server request listeners. + * @param router {@link Router} - a router with registered route handlers. + */ registerRouter: (router: Router) => void; /** * Creates cookie based session storage factory {@link SessionStorageFactory} + * @param cookieOptions {@link SessionStorageCookieOptions} - options to configure created cookie session storage. */ createCookieSessionStorageFactory: ( cookieOptions: SessionStorageCookieOptions @@ -49,35 +126,53 @@ export interface HttpServerSetup { * A handler should return a state to associate with the incoming request. * The state can be retrieved later via http.auth.get(..) * Only one AuthenticationHandler can be registered. + * @param handler {@link AuthenticationHandler} - function to perform authentication. */ registerAuth: (handler: AuthenticationHandler) => void; /** * To define custom logic to perform for incoming requests. Runs the handler before Auth - * hook performs a check that user has access to requested resources, so it's the only + * interceptor performs a check that user has access to requested resources, so it's the only * place when you can forward a request to another URL right on the server. * Can register any number of registerOnPostAuth, which are called in sequence * (from the first registered to the last). + * @param handler {@link OnPreAuthHandler} - function to call. */ registerOnPreAuth: (handler: OnPreAuthHandler) => void; /** - * To define custom logic to perform for incoming requests. Runs the handler after Auth hook + * To define custom logic to perform for incoming requests. Runs the handler after Auth interceptor * did make sure a user has access to the requested resource. * The auth state is available at stage via http.auth.get(..) * Can register any number of registerOnPreAuth, which are called in sequence * (from the first registered to the last). + * @param handler {@link OnPostAuthHandler} - function to call. */ registerOnPostAuth: (handler: OnPostAuthHandler) => void; basePath: { - get: (request: KibanaRequest | Request) => string; - set: (request: KibanaRequest | Request, basePath: string) => void; + /** + * returns `basePath` value, specific for an incoming request. + */ + get: (request: KibanaRequest | LegacyRequest) => string; + /** + * sets `basePath` value, specific for an incoming request. + */ + set: (request: KibanaRequest | LegacyRequest, basePath: string) => void; + /** + * returns a new `basePath` value, prefixed with passed `url`. + */ prepend: (url: string) => string; + /** + * returns a new `basePath` value, cleaned up from passed `url`. + */ remove: (url: string) => string; }; auth: { - get: AuthStateStorage['get']; - isAuthenticated: AuthStateStorage['isAuthenticated']; - getAuthHeaders: AuthHeadersStorage['get']; + get: GetAuthState; + isAuthenticated: IsAuthenticated; + getAuthHeaders: GetAuthHeaders; }; + /** + * Flag showing whether a server was configured to use TLS connection. + */ isTlsEnabled: boolean; } @@ -90,11 +185,13 @@ export class HttpServer { private readonly log: Logger; private readonly authState: AuthStateStorage; - private readonly authHeaders: AuthHeadersStorage; + private readonly authRequestHeaders: AuthHeadersStorage; + private readonly authResponseHeaders: AuthHeadersStorage; constructor(private readonly logger: LoggerFactory, private readonly name: string) { this.authState = new AuthStateStorage(() => this.authRegistered); - this.authHeaders = new AuthHeadersStorage(); + this.authRequestHeaders = new AuthHeadersStorage(); + this.authResponseHeaders = new AuthHeadersStorage(); this.log = logger.get('http', 'server', name); } @@ -131,7 +228,7 @@ export class HttpServer { auth: { get: this.authState.get, isAuthenticated: this.authState.isAuthenticated, - getAuthHeaders: this.authHeaders.get, + getAuthHeaders: this.authRequestHeaders.get, }, isTlsEnabled: config.ssl.enabled, // Return server instance with the connection options so that we can properly @@ -151,7 +248,8 @@ export class HttpServer { for (const route of router.getRoutes()) { const { authRequired = true, tags } = route.options; this.server.route({ - handler: route.handler, + handler: (req: Request, responseToolkit: ResponseToolkit) => + route.handler(req, responseToolkit, this.log), method: route.method, path: this.getRouteFullPath(router.path, route.path), options: { @@ -247,12 +345,19 @@ export class HttpServer { this.authRegistered = true; this.server.auth.scheme('login', () => ({ - authenticate: adoptToHapiAuthFormat(fn, (req, { state, headers }) => { + authenticate: adoptToHapiAuthFormat(fn, (req, { state, requestHeaders, responseHeaders }) => { this.authState.set(req, state); - this.authHeaders.set(req, headers); - // we mutate headers only for the backward compatibility with the legacy platform. - // where some plugin read directly from headers to identify whether a user is authenticated. - Object.assign(req.headers, headers); + + if (responseHeaders) { + this.authResponseHeaders.set(req, responseHeaders); + } + + if (requestHeaders) { + this.authRequestHeaders.set(req, requestHeaders); + // we mutate headers only for the backward compatibility with the legacy platform. + // where some plugin read directly from headers to identify whether a user is authenticated. + Object.assign(req.headers, requestHeaders); + } }), })); this.server.auth.strategy('session', 'login'); @@ -262,5 +367,40 @@ export class HttpServer { // should be applied for all routes if they don't specify auth strategy in route declaration // https://github.com/hapijs/hapi/blob/master/API.md#-serverauthdefaultoptions this.server.auth.default('session'); + + this.server.ext('onPreResponse', (request, t) => { + const authResponseHeaders = this.authResponseHeaders.get(request); + this.extendResponseWithHeaders(request, authResponseHeaders); + return t.continue; + }); + } + + private extendResponseWithHeaders(request: Request, headers?: ResponseHeaders) { + const response = request.response; + if (!headers || !response) return; + + if (response instanceof Error) { + this.findHeadersIntersection(response.output.headers, headers); + // hapi wraps all error response in Boom object internally + response.output.headers = { + ...response.output.headers, + ...(headers as any), // hapi types don't specify string[] as valid value + }; + } else { + for (const [headerName, headerValue] of Object.entries(headers)) { + this.findHeadersIntersection(response.headers, headers); + response.header(headerName, headerValue as any); // hapi types don't specify string[] as valid value + } + } + } + + // NOTE: responseHeaders contains not a full list of response headers, but only explicitly set on a response object. + // any headers added by hapi internally, like `content-type`, `content-length`, etc. do not present here. + private findHeadersIntersection(responseHeaders: ResponseHeaders, headers: ResponseHeaders) { + Object.keys(headers).forEach(headerName => { + if (responseHeaders[headerName] !== undefined) { + this.log.warn(`Server rewrites a response header [${headerName}].`); + } + }); } } diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 02103fc4acc84..b4e509c8a05a5 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -19,7 +19,6 @@ import { Server } from 'hapi'; import { HttpService } from './http_service'; -import { HttpServerSetup } from './http_server'; import { HttpServiceSetup } from './http_service'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { AuthToolkit } from './lifecycle/auth'; @@ -52,10 +51,8 @@ const createSetupContractMock = () => { isAuthenticated: jest.fn(), getAuthHeaders: jest.fn(), }, - createNewServer: jest.fn(), isTlsEnabled: false, }; - setupContract.createNewServer.mockResolvedValue({} as HttpServerSetup); setupContract.createCookieSessionStorageFactory.mockResolvedValue( sessionStorageMock.createFactory() ); diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index f003ba1314434..cde06dc31802f 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -133,46 +133,6 @@ test('spins up notReady server until started if configured with `autoListen:true expect(notReadyHapiServer.stop).toBeCalledTimes(1); }); -// this is an integration test! -test('creates and sets up second http server', async () => { - const configService = createConfigService({ - host: 'localhost', - port: 1234, - }); - const { HttpServer } = jest.requireActual('./http_server'); - - mockHttpServer.mockImplementation((...args) => new HttpServer(...args)); - - const service = new HttpService({ configService, env, logger }); - const serverSetup = await service.setup(); - const cfg = { port: 2345 }; - await serverSetup.createNewServer(cfg); - const server = await service.start(); - expect(server.isListening()).toBeTruthy(); - expect(server.isListening(cfg.port)).toBeTruthy(); - - try { - await serverSetup.createNewServer(cfg); - } catch (err) { - expect(err.message).toBe('port 2345 is already in use'); - } - - try { - await serverSetup.createNewServer({ port: 1234 }); - } catch (err) { - expect(err.message).toBe('port 1234 is already in use'); - } - - try { - await serverSetup.createNewServer({ host: 'example.org' }); - } catch (err) { - expect(err.message).toBe('port must be defined'); - } - await service.stop(); - expect(server.isListening()).toBeFalsy(); - expect(server.isListening(cfg.port)).toBeFalsy(); -}); - test('logs error if already set up', async () => { const configService = createConfigService(); @@ -273,8 +233,7 @@ test('returns http server contract on setup', async () => { })); const service = new HttpService({ configService, env, logger }); - const { createNewServer, ...setupHttpServer } = await service.setup(); - expect(createNewServer).toBeDefined(); + const setupHttpServer = await service.setup(); expect(setupHttpServer).toEqual(httpServer); }); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index b06c690cf2621..e69906d512bac 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -25,14 +25,12 @@ import { LoggerFactory } from '../logging'; import { CoreService } from '../../types'; import { Logger } from '../logging'; import { CoreContext } from '../core_context'; -import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; +import { HttpConfig, HttpConfigType } from './http_config'; import { HttpServer, HttpServerSetup } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; /** @public */ -export interface HttpServiceSetup extends HttpServerSetup { - createNewServer: (cfg: Partial) => Promise; -} +export type HttpServiceSetup = HttpServerSetup; /** @public */ export interface HttpServiceStart { /** Indicates if http server is listening on a given port */ @@ -42,7 +40,6 @@ export interface HttpServiceStart { /** @internal */ export class HttpService implements CoreService { private readonly httpServer: HttpServer; - private readonly secondaryServers: Map = new Map(); private readonly httpsRedirectServer: HttpsRedirectServer; private readonly config$: Observable; private configSubscription?: Subscription; @@ -77,17 +74,11 @@ export class HttpService implements CoreService server.start())); } return { - isListening: (port: number = 0) => { - const server = this.secondaryServers.get(port); - if (server) return server.isListening(); - return this.httpServer.isListening(); - }, + isListening: () => this.httpServer.isListening(), }; } @@ -129,32 +115,6 @@ export class HttpService implements CoreService) { - const { port } = cfg; - const config = await this.config$.pipe(first()).toPromise(); - - if (!port) { - throw new Error('port must be defined'); - } - - // verify that main server and none of the secondary servers are already using this port - if (this.secondaryServers.has(port) || config.port === port) { - throw new Error(`port ${port} is already in use`); - } - - for (const [key, val] of Object.entries(cfg)) { - httpConfig.schema.validateKey(key, val); - } - - const baseConfig = await this.config$.pipe(first()).toPromise(); - const finalConfig = { ...baseConfig, ...cfg }; - - const httpServer = new HttpServer(this.logger, `secondary server:${port}`); - const httpSetup = await httpServer.setup(finalConfig); - this.secondaryServers.set(port, httpServer); - return httpSetup; - } - public async stop() { if (this.configSubscription === undefined) { return; @@ -168,8 +128,6 @@ export class HttpService implements CoreService s.stop())); - this.secondaryServers.clear(); } private async runNotReadyServer(config: HttpConfig) { @@ -177,7 +135,7 @@ export class HttpService implements CoreService { sessionStorage.set({ value: user, expires: Date.now() + sessionDurationMs }); return t.authenticated({ state: user, - headers: { + requestHeaders: { authorization: token, }, }); @@ -167,7 +167,7 @@ describe('http service', () => { const { registerAuth, registerRouter } = http; await registerAuth((req, t) => { - return t.authenticated({ headers: authHeaders }); + return t.authenticated({ requestHeaders: authHeaders }); }); const router = new Router('/new-platform'); @@ -187,7 +187,7 @@ describe('http service', () => { expect(headers).toEqual(authHeaders); }); - it('pass request authorization header to Elasticsearch if registerAuth was not set', async () => { + it('passes request authorization header to Elasticsearch if registerAuth was not set', async () => { const authorizationHeader = 'Basic: username:password'; const { http, elasticsearch } = await root.setup(); const { registerRouter } = http; @@ -214,6 +214,56 @@ describe('http service', () => { authorization: authorizationHeader, }); }); + + it('attach security header to a successful response handled by Legacy platform', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { http } = await root.setup(); + const { registerAuth } = http; + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + await root.start(); + + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: '/legacy', + handler: () => 'ok', + }); + + const response = await kbnTestServer.request.get(root, '/legacy').expect(200); + expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']); + }); + + it('attach security header to an error response handled by Legacy platform', async () => { + const authResponseHeader = { + 'www-authenticate': 'Negotiate ade0234568a4209af8bc0280289eca', + }; + const { http } = await root.setup(); + const { registerAuth } = http; + + await registerAuth((req, t) => { + return t.authenticated({ responseHeaders: authResponseHeader }); + }); + + await root.start(); + + const kbnServer = kbnTestServer.getKbnServer(root); + kbnServer.server.route({ + method: 'GET', + path: '/legacy', + handler: () => { + throw Boom.badRequest(); + }, + }); + + const response = await kbnTestServer.request.get(root, '/legacy').expect(400); + expect(response.header['www-authenticate']).toBe(authResponseHeader['www-authenticate']); + }); }); describe('#registerOnPostAuth()', () => { diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts new file mode 100644 index 0000000000000..1d3ee4643830d --- /dev/null +++ b/src/core/server/http/integration_tests/router.test.ts @@ -0,0 +1,1064 @@ +/* + * 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 { Stream } from 'stream'; +import Boom from 'boom'; + +import supertest from 'supertest'; +import { ByteSizeValue, schema } from '@kbn/config-schema'; + +import { HttpConfig, Router } from '..'; +import { HttpServer } from '../http_server'; + +import { LoggerFactory } from '../../logging'; +import { loggingServiceMock } from '../../logging/logging_service.mock'; + +let server: HttpServer; +let logger: LoggerFactory; + +const config = { + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + port: 10000, + ssl: { enabled: false }, +} as HttpConfig; + +beforeEach(() => { + logger = loggingServiceMock.create(); + server = new HttpServer(logger, 'tests'); +}); + +afterEach(async () => { + await server.stop(); +}); + +describe('Handler', () => { + it("Doesn't expose error details if handler throws", async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + throw new Error('unexpected error'); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: unexpected error], + ], + ] + `); + }); + + it('returns 500 Server error if handler throws Boom error', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + throw Boom.unauthorized(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unauthorized], + ], + ] + `); + }); + + it('returns 500 Server error if handler returns unexpected result', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => 'ok' as any); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unexpected result from Route Handler. Expected KibanaResponse, but given: string.], + ], + ] + `); + }); + + it('returns 400 Bad request if request validation failed', async () => { + const router = new Router('/'); + + router.get( + { + path: '/', + validate: { + query: schema.object({ + page: schema.number(), + }), + }, + }, + (req, res) => res.noContent() + ); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .query({ page: 'one' }) + .expect(400); + + expect(result.body).toEqual({ + error: '[request query.page]: expected value of type [number] but got [string]', + }); + }); +}); + +describe('Response factory', () => { + describe('Success', () => { + it('supports answering with json object', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok({ key: 'value' }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.body).toEqual({ key: 'value' }); + expect(result.header['content-type']).toBe('application/json; charset=utf-8'); + }); + + it('supports answering with string', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok('result'); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.text).toBe('result'); + expect(result.header['content-type']).toBe('text/html; charset=utf-8'); + }); + + it('supports answering with undefined', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok(undefined); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(200); + }); + + it('supports answering with Stream', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const stream = new Stream.Readable({ + read() { + this.push('a'); + this.push('b'); + this.push('c'); + this.push(null); + }, + }); + + return res.ok(stream); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.text).toBe('abc'); + expect(result.header['content-type']).toBe(undefined); + }); + + it('supports answering with chunked Stream', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const stream = new Stream.PassThrough(); + stream.write('a'); + stream.write('b'); + setTimeout(function() { + stream.write('c'); + stream.end(); + }, 100); + + return res.ok(stream); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.text).toBe('abc'); + expect(result.header['transfer-encoding']).toBe('chunked'); + }); + + it('supports answering with Buffer', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const buffer = Buffer.alloc(1028, '.'); + + return res.ok(buffer, { + headers: { + 'content-encoding': 'binary', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200) + .buffer(true); + + expect(result.header['content-encoding']).toBe('binary'); + expect(result.header['content-length']).toBe('1028'); + expect(result.header['content-type']).toBe('application/octet-stream'); + }); + + it('supports answering with Buffer text', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const buffer = new Buffer('abc'); + + return res.ok(buffer, { + headers: { + 'content-type': 'text/plain', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200) + .buffer(true); + + expect(result.text).toBe('abc'); + expect(result.header['content-length']).toBe('3'); + expect(result.header['content-type']).toBe('text/plain; charset=utf-8'); + }); + + it('supports configuring standard headers', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok('value', { + headers: { + etag: '1234', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.text).toEqual('value'); + expect(result.header.etag).toBe('1234'); + }); + + it('supports configuring non-standard headers', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok('value', { + headers: { + etag: '1234', + 'x-kibana': 'key', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.text).toEqual('value'); + expect(result.header.etag).toBe('1234'); + expect(result.header['x-kibana']).toBe('key'); + }); + + it('accepted headers are case-insensitive.', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok('value', { + headers: { + ETag: '1234', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.header.etag).toBe('1234'); + }); + + it('accept array of headers', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok('value', { + headers: { + 'set-cookie': ['foo', 'bar'], + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.header['set-cookie']).toEqual(['foo', 'bar']); + }); + + it('throws if given invalid json object as response payload', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const payload: any = { key: {} }; + payload.key.payload = payload; + return res.ok(payload); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(500); + + // error happens within hapi when route handler already finished execution. + expect(loggingServiceMock.collect(logger).error).toHaveLength(0); + }); + + it('200 OK with body', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.ok({ key: 'value' }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(200); + + expect(result.body).toEqual({ key: 'value' }); + expect(result.header['content-type']).toBe('application/json; charset=utf-8'); + }); + + it('202 Accepted with body', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.accepted({ location: 'somewhere' }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(202); + + expect(result.body).toEqual({ location: 'somewhere' }); + expect(result.header['content-type']).toBe('application/json; charset=utf-8'); + }); + + it('204 No content', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.noContent(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(204); + + expect(result.noContent).toBe(true); + }); + }); + + describe('Redirection', () => { + it('302 supports redirection to configured URL', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.redirected('The document has moved', { + headers: { + location: '/new-url', + 'x-kibana': 'tag', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(302); + + expect(result.text).toBe('The document has moved'); + expect(result.header.location).toBe('/new-url'); + expect(result.header['x-kibana']).toBe('tag'); + }); + + it('throws if redirection url not provided', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.redirected(undefined, { + headers: { + 'x-kibana': 'tag', + }, + } as any); // location headers is required + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: expected 'location' header to be set], + ], + ] + `); + }); + }); + + describe('Error', () => { + it('400 Bad request', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('some message'); + return res.badRequest(error); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(400); + + expect(result.body).toEqual({ error: 'some message' }); + }); + + it('400 Bad request with default message', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.badRequest(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(400); + + expect(result.body).toEqual({ error: 'Bad Request' }); + }); + + it('400 Bad request with additional data', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.badRequest({ message: 'some message', meta: { data: ['good', 'bad'] } }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(400); + + expect(result.body).toEqual({ + error: 'some message', + meta: { + data: ['good', 'bad'], + }, + }); + }); + + it('401 Unauthorized', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('no access'); + return res.unauthorized(error, { + headers: { + 'WWW-Authenticate': 'challenge', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body.error).toBe('no access'); + expect(result.header['www-authenticate']).toBe('challenge'); + }); + + it('401 Unauthorized with default message', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.unauthorized(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body.error).toBe('Unauthorized'); + }); + + it('403 Forbidden', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('reason'); + return res.forbidden(error); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(403); + + expect(result.body.error).toBe('reason'); + }); + + it('403 Forbidden with default message', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.forbidden(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(403); + + expect(result.body.error).toBe('Forbidden'); + }); + + it('404 Not Found', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('file is not found'); + return res.notFound(error); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(404); + + expect(result.body.error).toBe('file is not found'); + }); + + it('404 Not Found with default message', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.notFound(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(404); + + expect(result.body.error).toBe('Not Found'); + }); + + it('409 Conflict', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('stale version'); + return res.conflict(error); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(409); + + expect(result.body.error).toBe('stale version'); + }); + + it('409 Conflict with default message', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.conflict(); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(409); + + expect(result.body.error).toBe('Conflict'); + }); + }); + + describe('Custom', () => { + it('creates success response', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom(undefined, { + statusCode: 201, + headers: { + location: 'somewhere', + }, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(201); + + expect(result.header.location).toBe('somewhere'); + }); + + it('creates redirect response', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom('The document has moved', { + headers: { + location: '/new-url', + }, + statusCode: 301, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(301); + + expect(result.header.location).toBe('/new-url'); + }); + + it('throws if redirects without location header to be set', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom('The document has moved', { + headers: {}, + statusCode: 301, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: expected 'location' header to be set], + ], + ] + `); + }); + + it('creates error response', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('unauthorized'); + return res.custom(error, { + statusCode: 401, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body.error).toBe('unauthorized'); + }); + + it('creates error response with additional data', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom( + { + message: 'unauthorized', + meta: { errorCode: 'K401' }, + }, + { + statusCode: 401, + } + ); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body).toEqual({ + error: 'unauthorized', + meta: { errorCode: 'K401' }, + }); + }); + + it('creates error response with additional data and error object', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom( + { + message: new Error('unauthorized'), + meta: { errorCode: 'K401' }, + }, + { + statusCode: 401, + } + ); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body).toEqual({ + error: 'unauthorized', + meta: { errorCode: 'K401' }, + }); + }); + + it('creates error response with Boom error', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = Boom.unauthorized(); + return res.custom(error, { + statusCode: 401, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(401); + + expect(result.body.error).toBe('Unauthorized'); + }); + + it("Doesn't log details of created 500 Server error response", async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom('reason', { + statusCode: 500, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('reason'); + expect(loggingServiceMock.collect(logger).error).toHaveLength(0); + }); + + it('throws an error if not valid error is provided', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom( + { error: 'error-message' }, + { + statusCode: 401, + } + ); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: expected error message to be provided], + ], + ] + `); + }); + + it('throws if an error not provided', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + return res.custom(undefined, { + statusCode: 401, + }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: expected error message to be provided], + ], + ] + `); + }); + + it('throws an error if statusCode is not specified', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('error message'); + return res.custom(error, undefined as any); // options.statusCode is required + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: options.statusCode is expected to be set. given options: undefined], + ], + ] + `); + }); + + it('throws an error if statusCode is not valid', async () => { + const router = new Router('/'); + + router.get({ path: '/', validate: false }, (req, res) => { + const error = new Error('error message'); + return res.custom(error, { statusCode: 20 }); + }); + + const { registerRouter, server: innerServer } = await server.setup(config); + registerRouter(router); + await server.start(); + + const result = await supertest(innerServer.listener) + .get('/') + .expect(500); + + expect(result.body.error).toBe('An internal server error occurred.'); + expect(loggingServiceMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unexpected Http status code. Expected from 100 to 599, but given: 20.], + ], + ] + `); + }); + }); +}); diff --git a/src/core/server/http/lifecycle/auth.test.ts b/src/core/server/http/lifecycle/auth.test.ts index 668d2a4fd11dc..36aef7fc73bac 100644 --- a/src/core/server/http/lifecycle/auth.test.ts +++ b/src/core/server/http/lifecycle/auth.test.ts @@ -25,7 +25,7 @@ describe('adoptToHapiAuthFormat', () => { it('allows to associate arbitrary data with an incoming request', async () => { const authData = { state: { foo: 'bar' }, - headers: { authorization: 'baz' }, + requestHeaders: { authorization: 'baz' }, }; const authenticatedMock = jest.fn(); const onSuccessMock = jest.fn(); diff --git a/src/core/server/http/lifecycle/auth.ts b/src/core/server/http/lifecycle/auth.ts index 8319d52c2e884..e95b7af6e44f6 100644 --- a/src/core/server/http/lifecycle/auth.ts +++ b/src/core/server/http/lifecycle/auth.ts @@ -27,7 +27,7 @@ enum ResultType { rejected = 'rejected', } -interface Authenticated extends AuthResultData { +interface Authenticated extends AuthResultParams { type: ResultType.authenticated; } @@ -45,11 +45,12 @@ interface Rejected { type AuthResult = Authenticated | Rejected | Redirected; const authResult = { - authenticated(data: Partial = {}): AuthResult { + authenticated(data: Partial = {}): AuthResult { return { type: ResultType.authenticated, - state: data.state || {}, - headers: data.headers || {}, + state: data.state, + requestHeaders: data.requestHeaders, + responseHeaders: data.responseHeaders, }; }, redirected(url: string): AuthResult { @@ -82,21 +83,27 @@ const authResult = { * @public * */ -export type AuthHeaders = Record; +export type AuthHeaders = Record; /** * Result of an incoming request authentication. * @public * */ -export interface AuthResultData { +export interface AuthResultParams { /** * Data to associate with an incoming request. Any downstream plugin may get access to the data. */ - state: Record; + state?: Record; /** - * Auth specific headers to authenticate a user against Elasticsearch. + * Auth specific headers to attach to a request object. + * Used to perform a request to Elasticsearch on behalf of an authenticated user. */ - headers: AuthHeaders; + requestHeaders?: AuthHeaders; + /** + * Auth specific headers to attach to a response object. + * Used to send back authentication mechanism related headers to a client when needed. + */ + responseHeaders?: AuthHeaders; } /** @@ -105,7 +112,7 @@ export interface AuthResultData { */ export interface AuthToolkit { /** Authentication is successful with given credentials, allow request to pass through */ - authenticated: (data?: Partial) => AuthResult; + authenticated: (data?: AuthResultParams) => AuthResult; /** Authentication requires to interrupt request handling and redirect to a configured url */ redirected: (url: string) => AuthResult; /** Authentication is unsuccessful, fail the request with specified error. */ @@ -127,7 +134,7 @@ export type AuthenticationHandler = ( /** @public */ export function adoptToHapiAuthFormat( fn: AuthenticationHandler, - onSuccess: (req: Request, data: AuthResultData) => void = noop + onSuccess: (req: Request, data: AuthResultParams) => void = noop ) { return async function interceptAuth( req: Request, @@ -141,7 +148,11 @@ export function adoptToHapiAuthFormat( ); } if (authResult.isAuthenticated(result)) { - onSuccess(req, { state: result.state, headers: result.headers }); + onSuccess(req, { + state: result.state, + requestHeaders: result.requestHeaders, + responseHeaders: result.responseHeaders, + }); return h.authenticated({ credentials: result.state || {} }); } if (authResult.isRedirected(result)) { diff --git a/src/core/server/http/router/headers.ts b/src/core/server/http/router/headers.ts index 956ef8cd2ebb8..19eaee5081996 100644 --- a/src/core/server/http/router/headers.ts +++ b/src/core/server/http/router/headers.ts @@ -16,11 +16,49 @@ * specific language governing permissions and limitations * under the License. */ +import { IncomingHttpHeaders } from 'http'; import { pick } from '../../../utils'; -/** @public */ -export type Headers = Record; +/** + * Creates a Union type of all known keys of a given interface. + * @example + * ```ts + * interface Person { + * name: string; + * age: number; + * [attributes: string]: string | number; + * } + * type PersonKnownKeys = KnownKeys; // "age" | "name" + * ``` + */ +type KnownKeys = { + [K in keyof T]: string extends K ? never : number extends K ? never : K; +} extends { [_ in keyof T]: infer U } + ? U + : never; + +/** + * Set of well-known HTTP headers. + * @public + */ +export type KnownHeaders = KnownKeys; + +/** + * Http request headers to read. + * @public + */ +export type Headers = { [header in KnownHeaders]?: string | string[] | undefined } & { + [header: string]: string | string[] | undefined; +}; + +/** + * Http response headers to set. + * @public + */ +export type ResponseHeaders = { [header in KnownHeaders]?: string | string[] } & { + [header: string]: string | string[]; +}; const normalizeHeaderField = (field: string) => field.trim().toLowerCase(); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index 0a1c402917e45..f9009949825ba 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -17,7 +17,23 @@ * under the License. */ -export { Headers, filterHeaders } from './headers'; -export { Router } from './router'; -export { KibanaRequest, KibanaRequestRoute, ensureRawRequest, isRealRequest } from './request'; -export { RouteMethod, RouteConfigOptions } from './route'; +export { Headers, filterHeaders, ResponseHeaders, KnownHeaders } from './headers'; +export { Router, RequestHandler } from './router'; +export { + KibanaRequest, + KibanaRequestRoute, + isRealRequest, + LegacyRequest, + ensureRawRequest, +} from './request'; +export { RouteMethod, RouteConfig, RouteConfigOptions } from './route'; +export { + CustomHttpResponseOptions, + HttpResponseOptions, + HttpResponsePayload, + RedirectResponseOptions, + ResponseError, + ResponseErrorMeta, + kibanaResponseFactory, + KibanaResponseFactory, +} from './response'; diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 7a1f4903b1cfe..4eac2e98317fc 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -38,16 +38,22 @@ export interface KibanaRequestRoute { options: Required; } +/** + * @deprecated + * `hapi` request object, supported during migration process only for backward compatibility. + * @public + */ +export interface LegacyRequest extends Request {} // eslint-disable-line @typescript-eslint/no-empty-interface + /** * Kibana specific abstraction for an incoming request. * @public - * */ + */ export class KibanaRequest { /** * Factory for creating requests. Validates the request before creating an * instance of a KibanaRequest. * @internal - * */ public static from

( req: Request, @@ -68,6 +74,7 @@ export class KibanaRequest { * Validates the different parts of a request based on the schemas defined for * the route. Builds up the actual params, query and body object that will be * received in the route handler. + * @internal */ private static validate

( req: Request, @@ -86,16 +93,25 @@ export class KibanaRequest { } const params = - routeSchemas.params === undefined ? {} : routeSchemas.params.validate(req.params); + routeSchemas.params === undefined + ? {} + : routeSchemas.params.validate(req.params, {}, 'request params'); - const query = routeSchemas.query === undefined ? {} : routeSchemas.query.validate(req.query); + const query = + routeSchemas.query === undefined + ? {} + : routeSchemas.query.validate(req.query, {}, 'request query'); - const body = routeSchemas.body === undefined ? {} : routeSchemas.body.validate(req.payload); + const body = + routeSchemas.body === undefined + ? {} + : routeSchemas.body.validate(req.payload, {}, 'request body'); return { query, params, body }; } - + /** a WHATWG URL standard object. */ public readonly url: Url; + /** matched route details */ public readonly route: RecursiveReadonly; /** * Readonly copy of incoming request headers. @@ -145,14 +161,14 @@ export class KibanaRequest { * Returns underlying Hapi Request * @internal */ -export const ensureRawRequest = (request: KibanaRequest | Request) => +export const ensureRawRequest = (request: KibanaRequest | LegacyRequest) => isKibanaRequest(request) ? request[requestSymbol] : request; function isKibanaRequest(request: unknown): request is KibanaRequest { return request instanceof KibanaRequest; } -function isRequest(request: any): request is Request { +function isRequest(request: any): request is LegacyRequest { try { return request.raw.req && typeof request.raw.req === 'object'; } catch { @@ -164,6 +180,6 @@ function isRequest(request: any): request is Request { * Checks if an incoming request either KibanaRequest or Legacy.Request * @internal */ -export function isRealRequest(request: unknown): request is KibanaRequest | Request { +export function isRealRequest(request: unknown): request is KibanaRequest | LegacyRequest { return isKibanaRequest(request) || isRequest(request); } diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index 6e767aea0033d..100c4b0d660cd 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -16,19 +16,277 @@ * specific language governing permissions and limitations * under the License. */ +import { Stream } from 'stream'; +import { ResponseHeaders } from './headers'; -// TODO Needs _some_ work -export type StatusCode = 200 | 202 | 204 | 400; +/** + * Additional metadata to enhance error output or provide error details. + * @public + */ +export interface ResponseErrorMeta { + data?: Record; + errorCode?: string; + docLink?: string; +} +/** + * Error message and optional data send to the client in case of error. + * @public + */ +export type ResponseError = + | string + | Error + | { + message: string | Error; + meta?: ResponseErrorMeta; + }; -export class KibanaResponse { - constructor(readonly status: StatusCode, readonly payload?: T) {} +/** + * A response data object, expected to returned as a result of {@link RequestHandler} execution + * @internal + */ +export class KibanaResponse { + constructor( + readonly status: number, + readonly payload?: T, + readonly options: HttpResponseOptions = {} + ) {} } -export const responseFactory = { - accepted: (payload: T) => new KibanaResponse(202, payload), - badRequest: (err: T) => new KibanaResponse(400, err), - noContent: () => new KibanaResponse(204), - ok: (payload: T) => new KibanaResponse(200, payload), +/** + * HTTP response parameters + * @public + */ +export interface HttpResponseOptions { + /** HTTP Headers with additional information about response */ + headers?: ResponseHeaders; +} + +/** + * Data send to the client as a response payload. + * @public + */ +export type HttpResponsePayload = undefined | string | Record | Buffer | Stream; + +/** + * HTTP response parameters for a response with adjustable status code. + * @public + */ +export interface CustomHttpResponseOptions extends HttpResponseOptions { + statusCode: number; +} + +/** + * HTTP response parameters for redirection response + * @public + */ +export type RedirectResponseOptions = HttpResponseOptions & { + headers: { + location: string; + }; +}; + +/** + * Set of helpers used to create `KibanaResponse` to form HTTP response on an incoming request. + * Should be returned as a result of {@link RequestHandler} execution. + * + * @example + * 1. Successful response. Supported types of response body are: + * - `undefined`, no content to send. + * - `string`, send text + * - `JSON`, send JSON object, HTTP server will throw if given object is not valid (has circular references, for example) + * - `Stream` send data stream + * - `Buffer` send binary stream + * ```js + * return response.ok(undefined); + * return response.ok('ack'); + * return response.ok({ id: '1' }); + * return response.ok(Buffer.from(...);); + * + * const stream = new Stream.PassThrough(); + * fs.createReadStream('./file').pipe(stream); + * return res.ok(stream); + * ``` + * HTTP headers are configurable via response factory parameter `options` {@link HttpResponseOptions}. + * + * ```js + * return response.ok({ id: '1' }, { + * headers: { + * 'content-type': 'application/json' + * } + * }); + * ``` + * 2. Redirection response. Redirection URL is configures via 'Location' header. + * ```js + * return response.redirected('The document has moved', { + * headers: { + * location: '/new-url', + * }, + * }); + * ``` + * 3. Error response. You may pass an error message to the client, where error message can be: + * - `string` send message text + * - `Error` send the message text of given Error object. + * - `{ message: string | Error, meta: {data: Record, ...} }` - send message text and attach additional error metadata. + * ```js + * return response.unauthorized('User has no access to the requested resource.', { + * headers: { + * 'WWW-Authenticate': 'challenge', + * } + * }) + * return response.badRequest(); + * return response.badRequest('validation error'); + * + * try { + * // ... + * } catch(error){ + * return response.badRequest(error); + * } + * + * return response.badRequest({ + * message: 'validation error', + * meta: { + * data: { + * requestBody: request.body, + * failedFields: validationResult + * }, + * } + * }); + * + * try { + * // ... + * } catch(error) { + * return response.badRequest({ + * message: error, + * meta: { + * data: { + * requestBody: request.body, + * }, + * } + * }); + * } + * + * ``` + * 4. Custom response. `ResponseFactory` may not cover your use case, so you can use the `custom` function to customize the response. + * ```js + * return response.custom('ok', { + * statusCode: 201, + * headers: { + * location: '/created-url' + * } + * }) + * ``` + * @public + */ +export const kibanaResponseFactory = { + // Success + /** + * The request has succeeded. + * Status code: `200`. + * @param payload - {@link HttpResponsePayload} payload to send to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + ok: (payload: HttpResponsePayload, options: HttpResponseOptions = {}) => + new KibanaResponse(200, payload, options), + + /** + * The request has been accepted for processing. + * Status code: `202`. + * @param payload - {@link HttpResponsePayload} payload to send to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + accepted: (payload?: HttpResponsePayload, options: HttpResponseOptions = {}) => + new KibanaResponse(202, payload, options), + + /** + * The server has successfully fulfilled the request and that there is no additional content to send in the response payload body. + * Status code: `204`. + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + noContent: (options: HttpResponseOptions = {}) => new KibanaResponse(204, undefined, options), + + /** + * Creates a response with defined status code and payload. + * @param payload - {@link HttpResponsePayload} payload to send to the client + * @param options - {@link CustomHttpResponseOptions} configures HTTP response parameters. + */ + custom: (payload: HttpResponsePayload | ResponseError, options: CustomHttpResponseOptions) => { + if (!options || !options.statusCode) { + throw new Error(`options.statusCode is expected to be set. given options: ${options}`); + } + const { statusCode: code, ...rest } = options; + return new KibanaResponse(code, payload, rest); + }, + + // Redirection + /** + * Redirect to a different URI. + * Status code: `302`. + * @param payload - payload to send to the client + * @param options - {@link RedirectResponseOptions} configures HTTP response parameters. + * Expects `location` header to be set. + */ + redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => + new KibanaResponse(302, payload, options), + + // Client error + /** + * The server cannot process the request due to something that is perceived to be a client error. + * Status code: `400`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + badRequest: (error: ResponseError = 'Bad Request', options: HttpResponseOptions = {}) => + new KibanaResponse(400, error, options), + + /** + * The request cannot be applied because it lacks valid authentication credentials for the target resource. + * Status code: `401`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + unauthorized: (error: ResponseError = 'Unauthorized', options: HttpResponseOptions = {}) => + new KibanaResponse(401, error, options), + + /** + * Server cannot grant access to a resource. + * Status code: `403`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + forbidden: (error: ResponseError = 'Forbidden', options: HttpResponseOptions = {}) => + new KibanaResponse(403, error, options), + + /** + * Server cannot find a current representation for the target resource. + * Status code: `404`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + notFound: (error: ResponseError = 'Not Found', options: HttpResponseOptions = {}) => + new KibanaResponse(404, error, options), + + /** + * The request could not be completed due to a conflict with the current state of the target resource. + * Status code: `409`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + conflict: (error: ResponseError = 'Conflict', options: HttpResponseOptions = {}) => + new KibanaResponse(409, error, options), + + // Server error + /** + * The server encountered an unexpected condition that prevented it from fulfilling the request. + * Status code: `500`. + * @param error - {@link ResponseError} Error object containing message and other error details to pass to the client + * @param options - {@link HttpResponseOptions} configures HTTP response parameters. + */ + internal: (error: ResponseError = 'Internal Error', options: HttpResponseOptions = {}) => + new KibanaResponse(500, error, options), }; -export type ResponseFactory = typeof responseFactory; +/** + * Creates an object containing request response payload, HTTP headers, error details, and other data transmitted to the client. + * @public + */ +export type KibanaResponseFactory = typeof kibanaResponseFactory; diff --git a/src/core/server/http/router/response_adapter.ts b/src/core/server/http/router/response_adapter.ts new file mode 100644 index 0000000000000..d8208b4a34058 --- /dev/null +++ b/src/core/server/http/router/response_adapter.ts @@ -0,0 +1,116 @@ +/* + * 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 { ResponseObject as HapiResponseObject, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import typeDetect from 'type-detect'; + +import { HttpResponsePayload, KibanaResponse, ResponseError } from './response'; + +function setHeaders(response: HapiResponseObject, headers: Record = {}) { + Object.entries(headers).forEach(([header, value]) => { + if (value !== undefined) { + // Hapi typings for header accept only string, although string[] is a valid value + response.header(header, value as any); + } + }); + return response; +} + +const statusHelpers = { + isSuccess: (code: number) => code >= 100 && code < 300, + isRedirect: (code: number) => code >= 300 && code < 400, + isError: (code: number) => code >= 400 && code < 600, +}; + +export class HapiResponseAdapter { + constructor(private readonly responseToolkit: HapiResponseToolkit) {} + public toBadRequest(message: string) { + return this.responseToolkit.response({ error: message }).code(400); + } + + public toInternalError() { + return this.responseToolkit.response({ error: 'An internal server error occurred.' }).code(500); + } + + public handle(kibanaResponse: KibanaResponse) { + if (!(kibanaResponse instanceof KibanaResponse)) { + throw new Error( + `Unexpected result from Route Handler. Expected KibanaResponse, but given: ${typeDetect( + kibanaResponse + )}.` + ); + } + + const response = this.toHapiResponse(kibanaResponse); + setHeaders(response, kibanaResponse.options.headers); + return response; + } + + private toHapiResponse(kibanaResponse: KibanaResponse) { + if (statusHelpers.isSuccess(kibanaResponse.status)) { + return this.toSuccess(kibanaResponse); + } + if (statusHelpers.isRedirect(kibanaResponse.status)) { + return this.toRedirect(kibanaResponse); + } + if (statusHelpers.isError(kibanaResponse.status)) { + return this.toError(kibanaResponse); + } + throw new Error( + `Unexpected Http status code. Expected from 100 to 599, but given: ${kibanaResponse.status}.` + ); + } + + private toSuccess(kibanaResponse: KibanaResponse) { + return this.responseToolkit.response(kibanaResponse.payload).code(kibanaResponse.status); + } + + private toRedirect(kibanaResponse: KibanaResponse) { + const { headers } = kibanaResponse.options; + if (!headers || typeof headers.location !== 'string') { + throw new Error("expected 'location' header to be set"); + } + + return this.responseToolkit + .response(kibanaResponse.payload) + .redirect(headers.location) + .code(kibanaResponse.status); + } + + private toError(kibanaResponse: KibanaResponse) { + const { payload } = kibanaResponse; + return this.responseToolkit + .response({ + error: getErrorMessage(payload), + meta: getErrorMeta(payload), + }) + .code(kibanaResponse.status); + } +} + +function getErrorMessage(payload?: ResponseError): string { + if (!payload) { + throw new Error('expected error message to be provided'); + } + if (typeof payload === 'string') return payload; + return getErrorMessage(payload.message); +} + +function getErrorMeta(payload?: ResponseError) { + return typeof payload === 'object' && 'meta' in payload ? payload.meta : undefined; +} diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 42fb75db5f673..e805356014829 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -17,22 +17,22 @@ * under the License. */ -import { ObjectType, Schema } from '@kbn/config-schema'; +import { ObjectType } from '@kbn/config-schema'; /** * The set of common HTTP methods supported by Kibana routing. * @public - * */ + */ export type RouteMethod = 'get' | 'post' | 'put' | 'delete'; /** - * Route specific configuration. + * Additional route options. * @public - * */ + */ export interface RouteConfigOptions { /** * A flag shows that authentication for a route: - * enabled when true - * disabled when false + * `enabled` when true + * `disabled` when false * * Enabled by default. */ @@ -44,34 +44,58 @@ export interface RouteConfigOptions { tags?: readonly string[]; } +/** + * Route specific configuration. + * @public + */ export interface RouteConfig

{ /** * The endpoint _within_ the router path to register the route. E.g. if the * router is registered at `/elasticsearch` and the route path is `/search`, * the full path for the route is `/elasticsearch/search`. + * Supports: + * - named path segments `path/{name}`. + * - optional path segments `path/{position?}`. + * - multi-segments `path/{coordinates*2}`. + * Segments are accessible within a handler function as `params` property of {@link KibanaRequest} object. + * To have read access to `params` you *must* specify validation schema with {@link RouteConfig.validate}. */ path: string; /** - * A function that will be called when setting up the route and that returns - * a schema that every request will be validated against. - * + * A schema created with `@kbn/config-schema` that every request will be validated against. + * You *must* specify a validation schema to be able to read: + * - url path segments + * - request query + * - request body * To opt out of validating the request, specify `false`. + * @example + * ```ts + * import { schema } from '@kbn/config-schema'; + * router.get({ + * path: 'path/{id}' + * validate: { + * params: schema.object({ + * id: schema.string(), + * }), + * query: schema.object({...}), + * body: schema.object({...}), + * }, + * }) + * ``` */ - validate: RouteValidateFactory | false; + validate: RouteSchemas | false; + /** + * Additional route options {@link RouteConfigOptions}. + */ options?: RouteConfigOptions; } -export type RouteValidateFactory< - P extends ObjectType, - Q extends ObjectType, - B extends ObjectType -> = (schema: Schema) => RouteSchemas; - /** * RouteSchemas contains the schemas for validating the different parts of a * request. + * @public */ export interface RouteSchemas

{ params?: P; diff --git a/src/core/server/http/router/router.test.ts b/src/core/server/http/router/router.test.ts new file mode 100644 index 0000000000000..a0ab96257adc3 --- /dev/null +++ b/src/core/server/http/router/router.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { Router } from './router'; +describe('Router', () => { + describe('Options', () => { + it('throws if validation for a route is not defined explicitly', () => { + const router = new Router('/foo'); + expect( + // we use 'any' because validate is a required field + () => router.get({ path: '/' } as any, (req, res) => res.ok({})) + ).toThrowErrorMatchingInlineSnapshot( + `"The [get] at [/] does not have a 'validate' specified. Use 'false' as the value if you want to bypass validation."` + ); + }); + it('throws if validation for a route is declared wrong', () => { + const router = new Router('/foo'); + expect(() => + router.get( + // we use 'any' because validate requires @kbn/config-schema usage + { path: '/', validate: { params: { validate: () => 'error' } } } as any, + (req, res) => res.ok({}) + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Expected a valid schema declared with '@kbn/config-schema' package at key: [params]."` + ); + }); + }); +}); diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 9d3295a7f3bf6..a4d4b62e40203 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -17,28 +17,46 @@ * under the License. */ -import { ObjectType, schema, TypeOf } from '@kbn/config-schema'; +import { ObjectType, TypeOf, Type } from '@kbn/config-schema'; import { Request, ResponseObject, ResponseToolkit } from 'hapi'; +import { Logger } from '../../logging'; import { KibanaRequest } from './request'; -import { KibanaResponse, ResponseFactory, responseFactory } from './response'; +import { KibanaResponse, KibanaResponseFactory, kibanaResponseFactory } from './response'; import { RouteConfig, RouteConfigOptions, RouteMethod, RouteSchemas } from './route'; +import { HapiResponseAdapter } from './response_adapter'; -export interface RouterRoute { +interface RouterRoute { method: RouteMethod; path: string; options: RouteConfigOptions; - handler: (req: Request, responseToolkit: ResponseToolkit) => Promise; + handler: (req: Request, responseToolkit: ResponseToolkit, log: Logger) => Promise; } -/** @public */ +/** + * Provides ability to declare a handler function for a particular path and HTTP request method. + * Each route can have only one handler functions, which is executed when the route is matched. + * + * @example + * ```ts + * const router = new Router('my-app'); + * // handler is called when 'my-app/path' resource is requested with `GET` method + * router.get({ path: '/path', validate: false }, (req, res) => res.ok({ content: 'ok' })); + * ``` + * + * @public + * */ export class Router { public routes: Array> = []; - + /** + * @param path - a router path, set as the very first path segment for all registered routes. + */ constructor(readonly path: string) {} /** - * Register a `GET` request with the router + * Register a route handler for `GET` request. + * @param route {@link RouteConfig} - a route configuration. + * @param handler {@link RequestHandler} - a function to call to respond to an incoming request */ public get

( route: RouteConfig, @@ -47,8 +65,8 @@ export class Router { const { path, options = {} } = route; const routeSchemas = this.routeSchemasFromRouteConfig(route, 'get'); this.routes.push({ - handler: async (req, responseToolkit) => - await this.handle(routeSchemas, req, responseToolkit, handler), + handler: async (req, responseToolkit, log) => + await this.handle(routeSchemas, req, responseToolkit, handler, log), method: 'get', path, options, @@ -56,7 +74,9 @@ export class Router { } /** - * Register a `POST` request with the router + * Register a route handler for `POST` request. + * @param route {@link RouteConfig} - a route configuration. + * @param handler {@link RequestHandler} - a function to call to respond to an incoming request */ public post

( route: RouteConfig, @@ -65,8 +85,8 @@ export class Router { const { path, options = {} } = route; const routeSchemas = this.routeSchemasFromRouteConfig(route, 'post'); this.routes.push({ - handler: async (req, responseToolkit) => - await this.handle(routeSchemas, req, responseToolkit, handler), + handler: async (req, responseToolkit, log) => + await this.handle(routeSchemas, req, responseToolkit, handler, log), method: 'post', path, options, @@ -74,7 +94,9 @@ export class Router { } /** - * Register a `PUT` request with the router + * Register a route handler for `PUT` request. + * @param route {@link RouteConfig} - a route configuration. + * @param handler {@link RequestHandler} - a function to call to respond to an incoming request */ public put

( route: RouteConfig, @@ -83,8 +105,8 @@ export class Router { const { path, options = {} } = route; const routeSchemas = this.routeSchemasFromRouteConfig(route, 'put'); this.routes.push({ - handler: async (req, responseToolkit) => - await this.handle(routeSchemas, req, responseToolkit, handler), + handler: async (req, responseToolkit, log) => + await this.handle(routeSchemas, req, responseToolkit, handler, log), method: 'put', path, options, @@ -92,7 +114,9 @@ export class Router { } /** - * Register a `DELETE` request with the router + * Register a route handler for `DELETE` request. + * @param route {@link RouteConfig} - a route configuration. + * @param handler {@link RequestHandler} - a function to call to respond to an incoming request */ public delete

( route: RouteConfig, @@ -101,8 +125,8 @@ export class Router { const { path, options = {} } = route; const routeSchemas = this.routeSchemasFromRouteConfig(route, 'delete'); this.routes.push({ - handler: async (req, responseToolkit) => - await this.handle(routeSchemas, req, responseToolkit, handler), + handler: async (req, responseToolkit, log) => + await this.handle(routeSchemas, req, responseToolkit, handler, log), method: 'delete', path, options, @@ -112,13 +136,14 @@ export class Router { /** * Returns all routes registered with the this router. * @returns List of registered routes. + * @internal */ public getRoutes() { return [...this.routes]; } /** - * Create the schemas for a route + * Create the validation schemas for a route * * @returns Route schemas if `validate` is specified on the route, otherwise * undefined. @@ -136,46 +161,78 @@ export class Router { ); } - return route.validate ? route.validate(schema) : undefined; + if (route.validate !== false) { + Object.entries(route.validate).forEach(([key, schema]) => { + if (!(schema instanceof Type)) { + throw new Error( + `Expected a valid schema declared with '@kbn/config-schema' package at key: [${key}].` + ); + } + }); + } + + return route.validate ? route.validate : undefined; } private async handle

( routeSchemas: RouteSchemas | undefined, request: Request, responseToolkit: ResponseToolkit, - handler: RequestHandler + handler: RequestHandler, + log: Logger ) { let kibanaRequest: KibanaRequest, TypeOf, TypeOf>; - + const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); try { kibanaRequest = KibanaRequest.from(request, routeSchemas); } catch (e) { - // TODO Handle failed validation - return responseToolkit.response({ error: e.message }).code(400); + return hapiResponseAdapter.toBadRequest(e.message); } try { - const kibanaResponse = await handler(kibanaRequest, responseFactory); - - let payload = null; - if (kibanaResponse.payload instanceof Error) { - // TODO Design an error format - payload = { error: kibanaResponse.payload.message }; - } else if (kibanaResponse.payload !== undefined) { - payload = kibanaResponse.payload; - } - - return responseToolkit.response(payload).code(kibanaResponse.status); + const kibanaResponse = await handler(kibanaRequest, kibanaResponseFactory); + return hapiResponseAdapter.handle(kibanaResponse); } catch (e) { - // TODO Handle `KibanaResponseError` - - // Otherwise we default to something along the lines of - return responseToolkit.response({ error: e.message }).code(500); + log.error(e); + return hapiResponseAdapter.toInternalError(); } } } +/** + * A function executed when route path matched requested resource path. + * Request handler is expected to return a result of one of {@link KibanaResponseFactory} functions. + * @param request {@link KibanaRequest} - object containing information about requested resource, + * such as path, method, headers, parameters, query, body, etc. + * @param response {@link KibanaResponseFactory} - a set of helper functions used to respond to a request. + * + * @example + * ```ts + * const router = new Router('my-app'); + * // creates a route handler for GET request on 'my-app/path/{id}' path + * router.get( + * { + * path: 'path/{id}', + * // defines a validation schema for a named segment of the route path + * validate: { + * params: schema.object({ + * id: schema.string(), + * }), + * }, + * }, + * // function to execute to create a responses + * async (request, response) => { + * const data = await findObject(request.params.id); + * // creates a command to respond with 'not found' error + * if (!data) return response.notFound(); + * // creates a command to send found data to the client + * return response.ok(data); + * } + * ); + * ``` + * @public + */ export type RequestHandler

= ( - req: KibanaRequest, TypeOf, TypeOf>, - createResponse: ResponseFactory + request: KibanaRequest, TypeOf, TypeOf>, + response: KibanaResponseFactory ) => KibanaResponse | Promise>; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 4582f1362922f..72a183f2dd1db 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -56,25 +56,41 @@ export { ElasticsearchErrorHelpers, APICaller, FakeRequest, - LegacyRequest, } from './elasticsearch'; export { AuthenticationHandler, AuthHeaders, - AuthResultData, + AuthResultParams, + AuthStatus, AuthToolkit, + CustomHttpResponseOptions, GetAuthHeaders, + GetAuthState, + HttpResponseOptions, + HttpResponsePayload, + HttpServerSetup, + IsAuthenticated, KibanaRequest, KibanaRequestRoute, + KnownHeaders, + LegacyRequest, OnPreAuthHandler, OnPreAuthToolkit, OnPostAuthHandler, OnPostAuthToolkit, + RedirectResponseOptions, + RequestHandler, + ResponseError, + ResponseErrorMeta, + kibanaResponseFactory, + KibanaResponseFactory, + RouteConfig, Router, RouteMethod, RouteConfigOptions, - SessionStorageFactory, SessionStorage, + SessionStorageCookieOptions, + SessionStorageFactory, } from './http'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; @@ -97,15 +113,29 @@ export { SavedObjectsClient, SavedObjectsClientContract, SavedObjectsCreateOptions, + SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, SavedObjectsErrorHelpers, SavedObjectsFindOptions, SavedObjectsFindResponse, SavedObjectsMigrationVersion, + SavedObjectsRawDoc, + SavedObjectsSchema, + SavedObjectsSerializer, SavedObjectsService, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, + SavedObjectsExportOptions, + SavedObjectsImportError, + SavedObjectsImportConflictError, + SavedObjectsImportMissingReferencesError, + SavedObjectsImportUnknownError, + SavedObjectsImportUnsupportedTypeError, + SavedObjectsImportOptions, + SavedObjectsImportResponse, + SavedObjectsImportRetry, + SavedObjectsResolveImportErrorsOptions, } from './saved_objects'; export { RecursiveReadonly } from '../utils'; @@ -130,7 +160,6 @@ export interface CoreSetup { registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; - createNewServer: HttpServiceSetup['createNewServer']; isTlsEnabled: HttpServiceSetup['isTlsEnabled']; }; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index fcc8a26f51b4b..8ebd3e6b3c8b2 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -123,7 +123,6 @@ export function createPluginSetupContext( registerAuth: deps.http.registerAuth, registerOnPostAuth: deps.http.registerOnPostAuth, basePath: deps.http.basePath, - createNewServer: deps.http.createNewServer, isTlsEnabled: deps.http.isTlsEnabled, }, }; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 385de5b14565d..618a83b0fb68b 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -74,50 +74,134 @@ describe('getSortedObjectsForExport()', () => { const response = await readStreamToCompletion(exportStream); expect(response).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "name", - "type": "index-pattern", - }, - ], - "type": "search", - }, -] -`); + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + ] + `); expect(savedObjectsClient.find).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Object { - "perPage": 500, - "sortField": "_id", - "sortOrder": "asc", - "type": Array [ - "index-pattern", - "search", + [MockFunction] { + "calls": Array [ + Array [ + Object { + "namespace": undefined, + "perPage": 500, + "sortField": "_id", + "sortOrder": "asc", + "type": Array [ + "index-pattern", + "search", + ], + }, + ], ], - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + test('exports from the provided namespace when present', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + references: [ + { + name: 'name', + type: 'index-pattern', + id: '1', + }, + ], + }, + { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await getSortedObjectsForExport({ + savedObjectsClient, + exportSizeLimit: 500, + types: ['index-pattern', 'search'], + namespace: 'foo', + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + ] + `); + expect(savedObjectsClient.find).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Object { + "namespace": "foo", + "perPage": 500, + "sortField": "_id", + "sortOrder": "asc", + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('export selected types throws error when exceeding exportSizeLimit', async () => { @@ -195,51 +279,54 @@ Array [ }); const response = await readStreamToCompletion(exportStream); expect(response).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "name", - "type": "index-pattern", - }, - ], - "type": "search", - }, -] -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ Array [ Object { + "attributes": Object {}, "id": "1", + "references": Array [], "type": "index-pattern", }, Object { + "attributes": Object {}, "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], "type": "search", }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ] + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + Object { + "id": "2", + "type": "search", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('includes nested dependencies when passed in', async () => { @@ -283,59 +370,65 @@ Array [ }); const response = await readStreamToCompletion(exportStream); expect(response).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "name", - "type": "index-pattern", - }, - ], - "type": "search", - }, -] -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "id": "2", - "type": "search", - }, - ], - ], - Array [ Array [ Object { + "attributes": Object {}, "id": "1", + "references": Array [], "type": "index-pattern", }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + ] + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "id": "2", + "type": "search", + }, + ], + Object { + "namespace": undefined, + }, + ], + Array [ + Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('export selected objects throws error when exceeding exportSizeLimit', async () => { diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index e09780574a25c..d8513bcfe72d3 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -23,17 +23,20 @@ import { SavedObjectsClientContract } from '../'; import { injectNestedDependencies } from './inject_nested_depdendencies'; import { sortObjects } from './sort_objects'; -interface ObjectToExport { - id: string; - type: string; -} - -interface ExportObjectsOptions { +/** + * Options controlling the export operation. + * @public + */ +export interface SavedObjectsExportOptions { types?: string[]; - objects?: ObjectToExport[]; + objects?: Array<{ + id: string; + type: string; + }>; savedObjectsClient: SavedObjectsClientContract; exportSizeLimit: number; includeReferencesDeep?: boolean; + namespace?: string; } async function fetchObjectsToExport({ @@ -41,17 +44,19 @@ async function fetchObjectsToExport({ types, exportSizeLimit, savedObjectsClient, + namespace, }: { - objects?: ObjectToExport[]; + objects?: SavedObjectsExportOptions['objects']; types?: string[]; exportSizeLimit: number; savedObjectsClient: SavedObjectsClientContract; + namespace?: string; }) { if (objects) { if (objects.length > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); } - const bulkGetResult = await savedObjectsClient.bulkGet(objects); + const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace }); const erroredObjects = bulkGetResult.saved_objects.filter(obj => !!obj.error); if (erroredObjects.length) { const err = Boom.badRequest(); @@ -67,6 +72,7 @@ async function fetchObjectsToExport({ sortField: '_id', sortOrder: 'asc', perPage: exportSizeLimit, + namespace, }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -80,17 +86,19 @@ export async function getSortedObjectsForExport({ savedObjectsClient, exportSizeLimit, includeReferencesDeep = false, -}: ExportObjectsOptions) { + namespace, +}: SavedObjectsExportOptions) { const objectsToExport = await fetchObjectsToExport({ types, objects, savedObjectsClient, exportSizeLimit, + namespace, }); const exportedObjects = sortObjects( includeReferencesDeep - ? await injectNestedDependencies(objectsToExport, savedObjectsClient) + ? await injectNestedDependencies(objectsToExport, savedObjectsClient, namespace) : objectsToExport ); diff --git a/src/core/server/saved_objects/export/index.ts b/src/core/server/saved_objects/export/index.ts index eb52fcbecfda8..d994df2af627c 100644 --- a/src/core/server/saved_objects/export/index.ts +++ b/src/core/server/saved_objects/export/index.ts @@ -17,4 +17,7 @@ * under the License. */ -export { getSortedObjectsForExport } from './get_sorted_objects_for_export'; +export { + getSortedObjectsForExport, + SavedObjectsExportOptions, +} from './get_sorted_objects_for_export'; diff --git a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts index 9a2548952de3d..2fa06550727f0 100644 --- a/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts +++ b/src/core/server/saved_objects/export/inject_nested_depdendencies.test.ts @@ -70,13 +70,13 @@ describe('getObjectReferencesToFetch()', () => { }); const result = getObjectReferencesToFetch(map); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "id": "1", - "type": "index-pattern", - }, -] -`); + Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + ] + `); }); test(`doesn't deal with circular dependencies`, () => { @@ -137,15 +137,15 @@ describe('injectNestedDependencies', () => { ]; const result = await injectNestedDependencies(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, -] -`); + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + ] + `); }); test(`doesn't fetch references that are already fetched`, async () => { @@ -171,27 +171,27 @@ Array [ ]; const result = await injectNestedDependencies(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, -] -`); + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "type": "search", + }, + ] + `); }); test('fetches dependencies at least one level deep', async () => { @@ -221,47 +221,50 @@ Array [ }); const result = await injectNestedDependencies(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, -] -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ Array [ Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "attributes": Object {}, "id": "1", + "references": Array [], "type": "index-pattern", }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ] + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('fetches dependencies multiple levels deep', async () => { @@ -336,108 +339,114 @@ Array [ }); const result = await injectNestedDependencies(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "5", - "references": Array [ - Object { - "id": "4", - "name": "panel_0", - "type": "visualization", - }, - Object { - "id": "3", - "name": "panel_1", - "type": "visualization", - }, - ], - "type": "dashboard", - }, - Object { - "attributes": Object {}, - "id": "4", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "3", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "visualization", - }, - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [], - "type": "index-pattern", - }, -] -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ Array [ Object { + "attributes": Object {}, + "id": "5", + "references": Array [ + Object { + "id": "4", + "name": "panel_0", + "type": "visualization", + }, + Object { + "id": "3", + "name": "panel_1", + "type": "visualization", + }, + ], + "type": "dashboard", + }, + Object { + "attributes": Object {}, "id": "4", + "references": Array [ + Object { + "id": "2", + "name": "ref_0", + "type": "search", + }, + ], "type": "visualization", }, Object { + "attributes": Object {}, "id": "3", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], "type": "visualization", }, - ], - ], - Array [ - Array [ Object { + "attributes": Object {}, "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], "type": "search", }, Object { + "attributes": Object {}, "id": "1", + "references": Array [], "type": "index-pattern", }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ] + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "id": "4", + "type": "visualization", + }, + Object { + "id": "3", + "type": "visualization", + }, + ], + Object { + "namespace": undefined, + }, + ], + Array [ + Array [ + Object { + "id": "2", + "type": "search", + }, + Object { + "id": "1", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('throws error when bulkGet returns an error', async () => { @@ -505,52 +514,55 @@ Array [ }); const result = await injectNestedDependencies(savedObjects, savedObjectsClient); expect(result).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object {}, - "id": "2", - "references": Array [ - Object { - "id": "1", - "name": "ref_0", - "type": "index-pattern", - }, - ], - "type": "search", - }, - Object { - "attributes": Object {}, - "id": "1", - "references": Array [ - Object { - "id": "2", - "name": "ref_0", - "type": "search", - }, - ], - "type": "index-pattern", - }, -] -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ Array [ Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "ref_0", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "attributes": Object {}, "id": "1", + "references": Array [ + Object { + "id": "2", + "name": "ref_0", + "type": "search", + }, + ], "type": "index-pattern", }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ] + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "id": "1", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); }); diff --git a/src/core/server/saved_objects/export/inject_nested_depdendencies.ts b/src/core/server/saved_objects/export/inject_nested_depdendencies.ts index ee9ce781ef9a5..82cb3e3cfe115 100644 --- a/src/core/server/saved_objects/export/inject_nested_depdendencies.ts +++ b/src/core/server/saved_objects/export/inject_nested_depdendencies.ts @@ -34,7 +34,8 @@ export function getObjectReferencesToFetch(savedObjectsMap: Map(); for (const savedObject of savedObjects) { @@ -42,7 +43,7 @@ export async function injectNestedDependencies( } let objectsToFetch = getObjectReferencesToFetch(savedObjectsMap); while (objectsToFetch.length > 0) { - const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch); + const bulkGetResponse = await savedObjectsClient.bulkGet(objectsToFetch, { namespace }); // Check for errors const erroredObjects = bulkGetResponse.saved_objects.filter(obj => !!obj.error); if (erroredObjects.length) { diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 11add36e54fb6..fa2938109f6e7 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -26,7 +26,7 @@ import { } from '../../../../legacy/utils/streams'; import { SavedObject } from '../service'; import { createLimitStream } from './create_limit_stream'; -import { ImportError } from './types'; +import { SavedObjectsImportError } from './types'; interface CollectSavedObjectsOptions { readStream: Readable; @@ -41,7 +41,7 @@ export async function collectSavedObjects({ filter, supportedTypes, }: CollectSavedObjectsOptions) { - const errors: ImportError[] = []; + const errors: SavedObjectsImportError[] = []; const collectedObjects: SavedObject[] = await createPromiseFromStreams([ readStream, createLimitStream(objectLimit), diff --git a/src/core/server/saved_objects/import/create_objects_filter.ts b/src/core/server/saved_objects/import/create_objects_filter.ts index aacf8112f255a..684e44944002b 100644 --- a/src/core/server/saved_objects/import/create_objects_filter.ts +++ b/src/core/server/saved_objects/import/create_objects_filter.ts @@ -18,9 +18,9 @@ */ import { SavedObject } from '../service'; -import { Retry } from './types'; +import { SavedObjectsImportRetry } from './types'; -export function createObjectsFilter(retries: Retry[]) { +export function createObjectsFilter(retries: SavedObjectsImportRetry[]) { const retryKeys = new Set(retries.map(retry => `${retry.type}:${retry.id}`)); return (obj: SavedObject) => { return retryKeys.has(`${obj.type}:${obj.id}`); diff --git a/src/core/server/saved_objects/import/extract_errors.ts b/src/core/server/saved_objects/import/extract_errors.ts index 6ae9562a1f3aa..4c001cf8acf64 100644 --- a/src/core/server/saved_objects/import/extract_errors.ts +++ b/src/core/server/saved_objects/import/extract_errors.ts @@ -18,13 +18,13 @@ */ import { SavedObject } from '../service'; -import { ImportError } from './types'; +import { SavedObjectsImportError } from './types'; export function extractErrors( savedObjectResults: SavedObject[], savedObjectsToImport: SavedObject[] ) { - const errors: ImportError[] = []; + const errors: SavedObjectsImportError[] = []; const originalSavedObjectsMap = new Map(); for (const savedObject of savedObjectsToImport) { originalSavedObjectsMap.set(`${savedObject.type}:${savedObject.id}`, savedObject); diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 80e5cc9a306f0..6ba4304574f49 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -86,11 +86,11 @@ describe('importSavedObjects()', () => { supportedTypes: [], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 0, -} -`); + Object { + "success": true, + "successCount": 0, + } + `); }); test('calls bulkCreate without overwrite', async () => { @@ -113,66 +113,151 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 4, -} -`); + Object { + "success": true, + "successCount": 4, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Index Pattern", + }, + "id": "1", + "migrationVersion": Object {}, + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object { + "title": "My Search", + }, + "id": "2", + "migrationVersion": Object {}, + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object { + "title": "My Visualization", + }, + "id": "3", + "migrationVersion": Object {}, + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object { + "title": "My Dashboard", + }, + "id": "4", + "migrationVersion": Object {}, + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "namespace": undefined, + "overwrite": false, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "overwrite": false, + ], + } + `); + }); + + test('uses the provided namespace when present', async () => { + const readStream = new Readable({ + objectMode: true, + read() { + savedObjects.forEach(obj => this.push(obj)); + this.push(null); }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + }); + savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects, + }); + const result = await importSavedObjects({ + readStream, + objectLimit: 4, + overwrite: false, + savedObjectsClient, + supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + namespace: 'foo', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "success": true, + "successCount": 4, + } + `); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Index Pattern", + }, + "id": "1", + "migrationVersion": Object {}, + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object { + "title": "My Search", + }, + "id": "2", + "migrationVersion": Object {}, + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object { + "title": "My Visualization", + }, + "id": "3", + "migrationVersion": Object {}, + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object { + "title": "My Dashboard", + }, + "id": "4", + "migrationVersion": Object {}, + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "namespace": "foo", + "overwrite": false, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('calls bulkCreate with overwrite', async () => { @@ -195,66 +280,67 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 4, -} -`); + Object { + "success": true, + "successCount": 4, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", - }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Index Pattern", + }, + "id": "1", + "migrationVersion": Object {}, + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object { + "title": "My Search", + }, + "id": "2", + "migrationVersion": Object {}, + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object { + "title": "My Visualization", + }, + "id": "3", + "migrationVersion": Object {}, + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object { + "title": "My Dashboard", + }, + "id": "4", + "migrationVersion": Object {}, + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "namespace": undefined, + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ], + } + `); }); test('extracts errors for conflicts', async () => { @@ -284,45 +370,45 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, -} -`); + Object { + "errors": Array [ + Object { + "error": Object { + "type": "conflict", + }, + "id": "1", + "title": "My Index Pattern", + "type": "index-pattern", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "2", + "title": "My Search", + "type": "search", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "3", + "title": "My Visualization", + "type": "visualization", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "4", + "title": "My Dashboard", + "type": "dashboard", + }, + ], + "success": false, + "successCount": 0, + } + `); }); test('validates references', async () => { @@ -380,56 +466,59 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ + Object { + "errors": Array [ Object { - "id": "3", - "type": "visualization", + "error": Object { + "blocking": Array [ + Object { + "id": "3", + "type": "visualization", + }, + ], + "references": Array [ + Object { + "id": "2", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "id": "1", + "title": "My Search", + "type": "search", }, ], - "references": Array [ + "success": false, + "successCount": 0, + } + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "2", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ Object { - "id": "2", - "type": "index-pattern", + "type": "return", + "value": Promise {}, }, ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, -} -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + } + `); }); test('validates supported types', async () => { @@ -453,75 +542,76 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 4, -} -`); - expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", - }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - Object { - "attributes": Object { - "title": "My Search", - }, - "id": "2", - "migrationVersion": Object {}, - "references": Array [], - "type": "search", - }, - Object { - "attributes": Object { - "title": "My Visualization", + Object { + "errors": Array [ + Object { + "error": Object { + "type": "unsupported_type", + }, + "id": "1", + "title": "my title", + "type": "wigwags", }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - Object { - "attributes": Object { - "title": "My Dashboard", + ], + "success": false, + "successCount": 4, + } + `); + expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Index Pattern", + }, + "id": "1", + "migrationVersion": Object {}, + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object { + "title": "My Search", + }, + "id": "2", + "migrationVersion": Object {}, + "references": Array [], + "type": "search", + }, + Object { + "attributes": Object { + "title": "My Visualization", + }, + "id": "3", + "migrationVersion": Object {}, + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object { + "title": "My Dashboard", + }, + "id": "4", + "migrationVersion": Object {}, + "references": Array [], + "type": "dashboard", + }, + ], + Object { + "namespace": undefined, + "overwrite": false, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [], - "type": "dashboard", - }, - ], - Object { - "overwrite": false, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ], + } + `); }); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 10c1350c4c579..ef3b4a214c2c2 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -17,26 +17,14 @@ * under the License. */ -import { Readable } from 'stream'; import { collectSavedObjects } from './collect_saved_objects'; import { extractErrors } from './extract_errors'; -import { ImportError } from './types'; +import { + SavedObjectsImportError, + SavedObjectsImportResponse, + SavedObjectsImportOptions, +} from './types'; import { validateReferences } from './validate_references'; -import { SavedObjectsClientContract } from '../'; - -interface ImportSavedObjectsOptions { - readStream: Readable; - objectLimit: number; - overwrite: boolean; - savedObjectsClient: SavedObjectsClientContract; - supportedTypes: string[]; -} - -interface ImportResponse { - success: boolean; - successCount: number; - errors?: ImportError[]; -} export async function importSavedObjects({ readStream, @@ -44,8 +32,9 @@ export async function importSavedObjects({ overwrite, savedObjectsClient, supportedTypes, -}: ImportSavedObjectsOptions): Promise { - let errorAccumulator: ImportError[] = []; + namespace, +}: SavedObjectsImportOptions): Promise { + let errorAccumulator: SavedObjectsImportError[] = []; // Get the objects to import const { @@ -57,7 +46,8 @@ export async function importSavedObjects({ // Validate references const { filteredObjects, errors: validationErrors } = await validateReferences( objectsFromStream, - savedObjectsClient + savedObjectsClient, + namespace ); errorAccumulator = [...errorAccumulator, ...validationErrors]; @@ -73,6 +63,7 @@ export async function importSavedObjects({ // Create objects in bulk const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, { overwrite, + namespace, }); errorAccumulator = [ ...errorAccumulator, diff --git a/src/core/server/saved_objects/import/index.ts b/src/core/server/saved_objects/import/index.ts index aad06931c330e..95fa8aa192f3e 100644 --- a/src/core/server/saved_objects/import/index.ts +++ b/src/core/server/saved_objects/import/index.ts @@ -19,3 +19,14 @@ export { importSavedObjects } from './import_saved_objects'; export { resolveImportErrors } from './resolve_import_errors'; +export { + SavedObjectsImportResponse, + SavedObjectsImportError, + SavedObjectsImportOptions, + SavedObjectsImportConflictError, + SavedObjectsImportMissingReferencesError, + SavedObjectsImportUnknownError, + SavedObjectsImportUnsupportedTypeError, + SavedObjectsResolveImportErrorsOptions, + SavedObjectsImportRetry, +} from './types'; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index d3f36852fd796..783b0d6a61be6 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -96,11 +96,11 @@ describe('resolveImportErrors()', () => { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 0, -} -`); + Object { + "success": true, + "successCount": 0, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); }); @@ -130,36 +130,39 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 1, -} -`); + Object { + "success": true, + "successCount": 1, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Visualization", + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Visualization", + }, + "id": "3", + "migrationVersion": Object {}, + "references": Array [], + "type": "visualization", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, }, - "id": "3", - "migrationVersion": Object {}, - "references": Array [], - "type": "visualization", - }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ], + } + `); }); test('works with overwrites', async () => { @@ -188,39 +191,40 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 1, -} -`); + Object { + "success": true, + "successCount": 1, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Index Pattern", + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Index Pattern", + }, + "id": "1", + "migrationVersion": Object {}, + "references": Array [], + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + "overwrite": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, }, - "id": "1", - "migrationVersion": Object {}, - "references": Array [], - "type": "index-pattern", - }, - ], - Object { - "overwrite": true, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ], + } + `); }); test('works wtih replaceReferences', async () => { @@ -255,42 +259,45 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "success": true, - "successCount": 1, -} -`); + Object { + "success": true, + "successCount": 1, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "attributes": Object { - "title": "My Dashboard", - }, - "id": "4", - "migrationVersion": Object {}, - "references": Array [ + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "attributes": Object { + "title": "My Dashboard", + }, + "id": "4", + "migrationVersion": Object {}, + "references": Array [ + Object { + "id": "13", + "name": "panel_0", + "type": "visualization", + }, + ], + "type": "dashboard", + }, + ], Object { - "id": "13", - "name": "panel_0", - "type": "visualization", + "namespace": undefined, }, ], - "type": "dashboard", - }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); }); test('extracts errors for conflicts', async () => { @@ -324,45 +331,45 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "type": "conflict", - }, - "id": "1", - "title": "My Index Pattern", - "type": "index-pattern", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "2", - "title": "My Search", - "type": "search", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "3", - "title": "My Visualization", - "type": "visualization", - }, - Object { - "error": Object { - "type": "conflict", - }, - "id": "4", - "title": "My Dashboard", - "type": "dashboard", - }, - ], - "success": false, - "successCount": 0, -} -`); + Object { + "errors": Array [ + Object { + "error": Object { + "type": "conflict", + }, + "id": "1", + "title": "My Index Pattern", + "type": "index-pattern", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "2", + "title": "My Search", + "type": "search", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "3", + "title": "My Visualization", + "type": "visualization", + }, + Object { + "error": Object { + "type": "conflict", + }, + "id": "4", + "title": "My Dashboard", + "type": "dashboard", + }, + ], + "success": false, + "successCount": 0, + } + `); }); test('validates references', async () => { @@ -433,56 +440,59 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "blocking": Array [ + Object { + "errors": Array [ Object { - "id": "3", - "type": "visualization", + "error": Object { + "blocking": Array [ + Object { + "id": "3", + "type": "visualization", + }, + ], + "references": Array [ + Object { + "id": "2", + "type": "index-pattern", + }, + ], + "type": "missing_references", + }, + "id": "1", + "title": "My Search", + "type": "search", }, ], - "references": Array [ + "success": false, + "successCount": 0, + } + `); + expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Array [ + Object { + "fields": Array [ + "id", + ], + "id": "2", + "type": "index-pattern", + }, + ], + Object { + "namespace": undefined, + }, + ], + ], + "results": Array [ Object { - "id": "2", - "type": "index-pattern", + "type": "return", + "value": Promise {}, }, ], - "type": "missing_references", - }, - "id": "1", - "title": "My Search", - "type": "search", - }, - ], - "success": false, - "successCount": 0, -} -`); - expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - Object { - "fields": Array [ - "id", - ], - "id": "2", - "type": "index-pattern", - }, - ], - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`); + } + `); }); test('validates object types', async () => { @@ -512,21 +522,67 @@ Object { supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], }); expect(result).toMatchInlineSnapshot(` -Object { - "errors": Array [ - Object { - "error": Object { - "type": "unsupported_type", - }, - "id": "1", - "title": "my title", - "type": "wigwags", - }, - ], - "success": false, - "successCount": 0, -} -`); + Object { + "errors": Array [ + Object { + "error": Object { + "type": "unsupported_type", + }, + "id": "1", + "title": "my title", + "type": "wigwags", + }, + ], + "success": false, + "successCount": 0, + } + `); expect(savedObjectsClient.bulkCreate).toMatchInlineSnapshot(`[MockFunction]`); }); + + test('uses namespace when provided', async () => { + const readStream = new Readable({ + objectMode: true, + read() { + savedObjects.forEach(obj => this.push(obj)); + this.push(null); + }, + }); + savedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: savedObjects.filter(obj => obj.type === 'index-pattern' && obj.id === '1'), + }); + const result = await resolveImportErrors({ + readStream, + objectLimit: 4, + retries: [ + { + type: 'index-pattern', + id: '1', + overwrite: true, + replaceReferences: [], + }, + ], + savedObjectsClient, + supportedTypes: ['index-pattern', 'search', 'visualization', 'dashboard'], + namespace: 'foo', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "success": true, + "successCount": 1, + } + `); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + { + attributes: { title: 'My Index Pattern' }, + id: '1', + migrationVersion: {}, + references: [], + type: 'index-pattern', + }, + ], + { namespace: 'foo', overwrite: true } + ); + }); }); diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 5cd4d2fca740c..c04827dc98ab5 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -17,38 +17,27 @@ * under the License. */ -import { Readable } from 'stream'; -import { SavedObjectsClientContract } from '../'; import { collectSavedObjects } from './collect_saved_objects'; import { createObjectsFilter } from './create_objects_filter'; import { extractErrors } from './extract_errors'; import { splitOverwrites } from './split_overwrites'; -import { ImportError, Retry } from './types'; +import { + SavedObjectsImportError, + SavedObjectsImportResponse, + SavedObjectsResolveImportErrorsOptions, +} from './types'; import { validateReferences } from './validate_references'; -interface ResolveImportErrorsOptions { - readStream: Readable; - objectLimit: number; - savedObjectsClient: SavedObjectsClientContract; - retries: Retry[]; - supportedTypes: string[]; -} - -interface ImportResponse { - success: boolean; - successCount: number; - errors?: ImportError[]; -} - export async function resolveImportErrors({ readStream, objectLimit, retries, savedObjectsClient, supportedTypes, -}: ResolveImportErrorsOptions): Promise { + namespace, +}: SavedObjectsResolveImportErrorsOptions): Promise { let successCount = 0; - let errorAccumulator: ImportError[] = []; + let errorAccumulator: SavedObjectsImportError[] = []; const filter = createObjectsFilter(retries); // Get the objects to resolve errors @@ -89,7 +78,8 @@ export async function resolveImportErrors({ // Validate references const { filteredObjects, errors: validationErrors } = await validateReferences( objectsToResolve, - savedObjectsClient + savedObjectsClient, + namespace ); errorAccumulator = [...errorAccumulator, ...validationErrors]; @@ -98,6 +88,7 @@ export async function resolveImportErrors({ if (objectsToOverwrite.length) { const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToOverwrite, { overwrite: true, + namespace, }); errorAccumulator = [ ...errorAccumulator, @@ -106,7 +97,9 @@ export async function resolveImportErrors({ successCount += bulkCreateResult.saved_objects.filter(obj => !obj.error).length; } if (objectsToNotOverwrite.length) { - const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToNotOverwrite); + const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToNotOverwrite, { + namespace, + }); errorAccumulator = [ ...errorAccumulator, ...extractErrors(bulkCreateResult.saved_objects, objectsToNotOverwrite), diff --git a/src/core/server/saved_objects/import/split_overwrites.ts b/src/core/server/saved_objects/import/split_overwrites.ts index 5609308f755f3..f49b634c8d9f2 100644 --- a/src/core/server/saved_objects/import/split_overwrites.ts +++ b/src/core/server/saved_objects/import/split_overwrites.ts @@ -18,9 +18,9 @@ */ import { SavedObject } from '../service'; -import { Retry } from './types'; +import { SavedObjectsImportRetry } from './types'; -export function splitOverwrites(savedObjects: SavedObject[], retries: Retry[]) { +export function splitOverwrites(savedObjects: SavedObject[], retries: SavedObjectsImportRetry[]) { const objectsToOverwrite: SavedObject[] = []; const objectsToNotOverwrite: SavedObject[] = []; const overwrites = retries diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index ccefe178f38d5..cc16a1697d9a0 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -17,7 +17,14 @@ * under the License. */ -export interface Retry { +import { Readable } from 'stream'; +import { SavedObjectsClientContract } from '../service'; + +/** + * Describes a retry operation for importing a saved object. + * @public + */ +export interface SavedObjectsImportRetry { type: string; id: string; overwrite: boolean; @@ -28,21 +35,37 @@ export interface Retry { }>; } -export interface ConflictError { +/** + * Represents a failure to import due to a conflict. + * @public + */ +export interface SavedObjectsImportConflictError { type: 'conflict'; } -export interface UnsupportedTypeError { +/** + * Represents a failure to import due to having an unsupported saved object type. + * @public + */ +export interface SavedObjectsImportUnsupportedTypeError { type: 'unsupported_type'; } -export interface UnknownError { +/** + * Represents a failure to import due to an unknown reason. + * @public + */ +export interface SavedObjectsImportUnknownError { type: 'unknown'; message: string; statusCode: number; } -export interface MissingReferencesError { +/** + * Represents a failure to import due to missing references. + * @public + */ +export interface SavedObjectsImportMissingReferencesError { type: 'missing_references'; references: Array<{ type: string; @@ -54,9 +77,53 @@ export interface MissingReferencesError { }>; } -export interface ImportError { +/** + * Represents a failure to import. + * @public + */ +export interface SavedObjectsImportError { id: string; type: string; title?: string; - error: ConflictError | UnsupportedTypeError | MissingReferencesError | UnknownError; + error: + | SavedObjectsImportConflictError + | SavedObjectsImportUnsupportedTypeError + | SavedObjectsImportMissingReferencesError + | SavedObjectsImportUnknownError; +} + +/** + * The response describing the result of an import. + * @public + */ +export interface SavedObjectsImportResponse { + success: boolean; + successCount: number; + errors?: SavedObjectsImportError[]; +} + +/** + * Options to control the import operation. + * @public + */ +export interface SavedObjectsImportOptions { + readStream: Readable; + objectLimit: number; + overwrite: boolean; + savedObjectsClient: SavedObjectsClientContract; + supportedTypes: string[]; + namespace?: string; +} + +/** + * Options to control the "resolve import" operation. + * @public + */ +export interface SavedObjectsResolveImportErrorsOptions { + readStream: Readable; + objectLimit: number; + savedObjectsClient: SavedObjectsClientContract; + retries: SavedObjectsImportRetry[]; + supportedTypes: string[]; + namespace?: string; } diff --git a/src/core/server/saved_objects/import/validate_references.test.ts b/src/core/server/saved_objects/import/validate_references.test.ts index e69ca99fb10f2..1a558b3d82b32 100644 --- a/src/core/server/saved_objects/import/validate_references.test.ts +++ b/src/core/server/saved_objects/import/validate_references.test.ts @@ -107,6 +107,9 @@ describe('getNonExistingReferenceAsKeys()', () => { "type": "index-pattern", }, ], + Object { + "namespace": undefined, + }, ], ], "results": Array [ @@ -206,6 +209,9 @@ describe('getNonExistingReferenceAsKeys()', () => { "type": "search", }, ], + Object { + "namespace": undefined, + }, ], ], "results": Array [ @@ -434,6 +440,9 @@ Object { "type": "search", }, ], + Object { + "namespace": undefined, + }, ], ], "results": Array [ diff --git a/src/core/server/saved_objects/import/validate_references.ts b/src/core/server/saved_objects/import/validate_references.ts index 2e3c1ef5293b3..ad3f73b68f6e0 100644 --- a/src/core/server/saved_objects/import/validate_references.ts +++ b/src/core/server/saved_objects/import/validate_references.ts @@ -19,7 +19,7 @@ import Boom from 'boom'; import { SavedObject, SavedObjectsClientContract } from '../'; -import { ImportError } from './types'; +import { SavedObjectsImportError } from './types'; const REF_TYPES_TO_VLIDATE = ['index-pattern', 'search']; @@ -29,7 +29,8 @@ function filterReferencesToValidate({ type }: { type: string }) { export async function getNonExistingReferenceAsKeys( savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract + savedObjectsClient: SavedObjectsClientContract, + namespace?: string ) { const collector = new Map(); // Collect all references within objects @@ -50,7 +51,7 @@ export async function getNonExistingReferenceAsKeys( // Fetch references to see if they exist const bulkGetOpts = Array.from(collector.values()).map(obj => ({ ...obj, fields: ['id'] })); - const bulkGetResponse = await savedObjectsClient.bulkGet(bulkGetOpts); + const bulkGetResponse = await savedObjectsClient.bulkGet(bulkGetOpts, { namespace }); // Error handling const erroredObjects = bulkGetResponse.saved_objects.filter( @@ -77,12 +78,14 @@ export async function getNonExistingReferenceAsKeys( export async function validateReferences( savedObjects: SavedObject[], - savedObjectsClient: SavedObjectsClientContract + savedObjectsClient: SavedObjectsClientContract, + namespace?: string ) { - const errorMap: { [key: string]: ImportError } = {}; + const errorMap: { [key: string]: SavedObjectsImportError } = {}; const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys( savedObjects, - savedObjectsClient + savedObjectsClient, + namespace ); // Filter out objects with missing references, add to error object diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index e6e9e2d266000..ef0362e0eb915 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -22,3 +22,9 @@ export * from './service'; export { SavedObjectsSchema } from './schema'; export { SavedObjectsManagement } from './management'; + +export * from './import'; + +export { getSortedObjectsForExport, SavedObjectsExportOptions } from './export'; + +export { SavedObjectsSerializer, RawDoc as SavedObjectsRawDoc } from './serialization'; diff --git a/src/core/server/saved_objects/migrations/core/build_index_map.test.ts b/src/core/server/saved_objects/migrations/core/build_index_map.test.ts new file mode 100644 index 0000000000000..c4a3319fa22c9 --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/build_index_map.test.ts @@ -0,0 +1,158 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createIndexMap } from './build_index_map'; + +test('mappings without index pattern goes to default index', () => { + const result = createIndexMap( + '.kibana', + { + type1: { + isNamespaceAgnostic: false, + }, + }, + { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + } + ); + expect(result).toEqual({ + '.kibana': { + typeMappings: { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + }, + }, + }); +}); + +test(`mappings with custom index pattern doesn't go to default index`, () => { + const result = createIndexMap( + '.kibana', + { + type1: { + isNamespaceAgnostic: false, + indexPattern: '.other_kibana', + }, + }, + { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + } + ); + expect(result).toEqual({ + '.other_kibana': { + typeMappings: { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + }, + }, + }); +}); + +test('creating a script gets added to the index pattern', () => { + const result = createIndexMap( + '.kibana', + { + type1: { + isNamespaceAgnostic: false, + indexPattern: '.other_kibana', + convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, + }, + }, + { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + } + ); + expect(result).toEqual({ + '.other_kibana': { + script: `ctx._id = ctx._source.type + ':' + ctx._id`, + typeMappings: { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + }, + }, + }); +}); + +test('throws when two scripts are defined for an index pattern', () => { + const defaultIndex = '.kibana'; + const savedObjectSchemas = { + type1: { + isNamespaceAgnostic: false, + convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, + }, + type2: { + isNamespaceAgnostic: false, + convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, + }, + }; + const indexMap = { + type1: { + properties: { + field1: { + type: 'string', + }, + }, + }, + type2: { + properties: { + field1: { + type: 'string', + }, + }, + }, + }; + expect(() => + createIndexMap(defaultIndex, savedObjectSchemas, indexMap) + ).toThrowErrorMatchingInlineSnapshot( + `"convertToAliasScript has been defined more than once for index pattern \\".kibana\\""` + ); +}); diff --git a/src/core/server/saved_objects/migrations/core/build_index_map.ts b/src/core/server/saved_objects/migrations/core/build_index_map.ts index 365c79692ba0d..e08bc9e972767 100644 --- a/src/core/server/saved_objects/migrations/core/build_index_map.ts +++ b/src/core/server/saved_objects/migrations/core/build_index_map.ts @@ -20,6 +20,13 @@ import { MappingProperties } from '../../mappings'; import { SavedObjectsSchemaDefinition } from '../../schema'; +export interface IndexMap { + [index: string]: { + typeMappings: MappingProperties; + script?: string; + }; +} + /* * This file contains logic to convert savedObjectSchemas into a dictonary of indexes and documents */ @@ -28,13 +35,22 @@ export function createIndexMap( savedObjectSchemas: SavedObjectsSchemaDefinition, indexMap: MappingProperties ) { - const map: { [index: string]: MappingProperties } = {}; + const map: IndexMap = {}; Object.keys(indexMap).forEach(type => { - const indexPattern = (savedObjectSchemas[type] || {}).indexPattern || defaultIndex; + const schema = savedObjectSchemas[type] || {}; + const script = schema.convertToAliasScript; + const indexPattern = schema.indexPattern || defaultIndex; if (!map.hasOwnProperty(indexPattern as string)) { - map[indexPattern] = {}; + map[indexPattern] = { typeMappings: {} }; + } + map[indexPattern].typeMappings[type] = indexMap[type]; + if (script && map[indexPattern].script) { + throw Error( + `convertToAliasScript has been defined more than once for index pattern "${indexPattern}"` + ); + } else if (script) { + map[indexPattern].script = script; } - map[indexPattern][type] = indexMap[type]; }); return map; } diff --git a/src/core/server/saved_objects/migrations/core/call_cluster.ts b/src/core/server/saved_objects/migrations/core/call_cluster.ts index f5b4f787a61d4..628f2785e6c64 100644 --- a/src/core/server/saved_objects/migrations/core/call_cluster.ts +++ b/src/core/server/saved_objects/migrations/core/call_cluster.ts @@ -90,6 +90,10 @@ export interface ReindexOpts { body: { dest: IndexOpts; source: IndexOpts & { size: number }; + script?: { + source: string; + lang: 'painless'; + }; }; refresh: boolean; waitForCompletion: boolean; diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 65df2fd580d84..393cbb7fbb2ae 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -231,6 +231,10 @@ describe('ElasticIndex', () => { body: { dest: { index: '.ze-index' }, source: { index: '.muchacha' }, + script: { + source: `ctx._id = ctx._source.type + ':' + ctx._id`, + lang: 'painless', + }, }, refresh: true, waitForCompletion: false, @@ -267,7 +271,13 @@ describe('ElasticIndex', () => { properties: { foo: { type: 'keyword' } }, }, }; - await Index.convertToAlias(callCluster as any, info, '.muchacha', 10); + await Index.convertToAlias( + callCluster as any, + info, + '.muchacha', + 10, + `ctx._id = ctx._source.type + ':' + ctx._id` + ); expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ 'indices.create', diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 9606a46edef95..da76905d1c65c 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -228,14 +228,15 @@ export async function convertToAlias( callCluster: CallCluster, info: FullIndexInfo, alias: string, - batchSize: number + batchSize: number, + script?: string ) { await callCluster('indices.create', { body: { mappings: info.mappings, settings }, index: info.indexName, }); - await reindex(callCluster, alias, info.indexName, batchSize); + await reindex(callCluster, alias, info.indexName, batchSize, script); await claimAlias(callCluster, info.indexName, alias, [{ remove_index: { index: alias } }]); } @@ -316,7 +317,13 @@ function assertResponseIncludeAllShards({ _shards }: { _shards: ShardsInfo }) { /** * Reindexes from source to dest, polling for the reindex completion. */ -async function reindex(callCluster: CallCluster, source: string, dest: string, batchSize: number) { +async function reindex( + callCluster: CallCluster, + source: string, + dest: string, + batchSize: number, + script?: string +) { // We poll instead of having the request wait for completion, as for large indices, // the request times out on the Elasticsearch side of things. We have a relatively tight // polling interval, as the request is fairly efficent, and we don't @@ -326,6 +333,12 @@ async function reindex(callCluster: CallCluster, source: string, dest: string, b body: { dest: { index: dest }, source: { index: source, size: batchSize }, + script: script + ? { + source: script, + lang: 'painless', + } + : undefined, }, refresh: true, waitForCompletion: false, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 7fc2bcfb72602..c75fa68572c71 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -176,7 +176,7 @@ async function migrateSourceToDest(context: Context) { if (!source.aliases[alias]) { log.info(`Reindexing ${alias} to ${source.indexName}`); - await Index.convertToAlias(callCluster, source, alias, batchSize); + await Index.convertToAlias(callCluster, source, alias, batchSize, context.convertToAliasScript); } const read = Index.reader(callCluster, source.indexName, { batchSize, scrollDuration }); diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index f3c4b271c3a72..a151a8d37a524 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -42,6 +42,7 @@ export interface MigrationOpts { mappingProperties: MappingProperties; documentMigrator: VersionedTransformer; serializer: SavedObjectsSerializer; + convertToAliasScript?: string; /** * If specified, templates matching the specified pattern will be removed @@ -62,6 +63,7 @@ export interface Context { scrollDuration: string; serializer: SavedObjectsSerializer; obsoleteIndexTemplatePattern?: string; + convertToAliasScript?: string; } /** @@ -87,6 +89,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { scrollDuration: opts.scrollDuration, serializer: opts.serializer, obsoleteIndexTemplatePattern: opts.obsoleteIndexTemplatePattern, + convertToAliasScript: opts.convertToAliasScript, }; } diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 7237d62dca6e2..9fc8afd356043 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -84,6 +84,30 @@ describe('KibanaMigrator', () => { const migrationResults = await new KibanaMigrator({ kbnServer }).awaitMigration(); expect(migrationResults.length).toEqual(2); }); + + it('only handles and deletes index templates once', async () => { + const { kbnServer } = mockKbnServer(); + const clusterStub = jest.fn(() => ({ status: 404 })); + const waitUntilReady = jest.fn(async () => undefined); + + kbnServer.server.plugins.elasticsearch = { + waitUntilReady, + getCluster() { + return { + callWithInternalUser: clusterStub, + }; + }, + }; + + await new KibanaMigrator({ kbnServer }).awaitMigration(); + + // callCluster with "cat.templates" is called by "deleteIndexTemplates" function + // and should only be done once + const callClusterCommands = clusterStub.mock.calls + .map(([callClusterPath]) => callClusterPath) + .filter(callClusterPath => callClusterPath === 'cat.templates'); + expect(callClusterCommands.length).toBe(1); + }); }); }); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index b2a03a7623bfe..0c2cb768fc011 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -93,8 +93,9 @@ export class KibanaMigrator { await server.plugins.elasticsearch.waitUntilReady(); const config = server.config(); + const kibanaIndexName = config.get('kibana.index'); const indexMap = createIndexMap( - config.get('kibana.index'), + kibanaIndexName, this.kbnServer.uiExports.savedObjectSchemas, this.mappingProperties ); @@ -106,11 +107,14 @@ export class KibanaMigrator { documentMigrator: this.documentMigrator, index, log: this.log, - mappingProperties: indexMap[index], + mappingProperties: indexMap[index].typeMappings, pollInterval: config.get('migrations.pollInterval'), scrollDuration: config.get('migrations.scrollDuration'), serializer: this.serializer, - obsoleteIndexTemplatePattern: 'kibana_index_template*', + // Only necessary for the migrator of the kibana index. + obsoleteIndexTemplatePattern: + index === kibanaIndexName ? 'kibana_index_template*' : undefined, + convertToAliasScript: indexMap[index].script, }); }); diff --git a/src/core/server/saved_objects/schema/schema.ts b/src/core/server/saved_objects/schema/schema.ts index 6756feeb15a0f..1f098d0b6e21d 100644 --- a/src/core/server/saved_objects/schema/schema.ts +++ b/src/core/server/saved_objects/schema/schema.ts @@ -20,6 +20,7 @@ interface SavedObjectsSchemaTypeDefinition { isNamespaceAgnostic: boolean; hidden?: boolean; indexPattern?: string; + convertToAliasScript?: string; } export interface SavedObjectsSchemaDefinition { diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 697e1d2d41471..386539e755d9a 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -17,8 +17,13 @@ * under the License. */ +import { Readable } from 'stream'; import { ScopedSavedObjectsClientProvider } from './lib'; import { SavedObjectsClient } from './saved_objects_client'; +import { SavedObjectsExportOptions } from '../export'; +import { SavedObjectsImportOptions, SavedObjectsImportResponse } from '../import'; +import { SavedObjectsSchema } from '../schema'; +import { SavedObjectsResolveImportErrorsOptions } from '../import/types'; /** * @public @@ -31,12 +36,22 @@ export interface SavedObjectsService { getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider['getClient']; SavedObjectsClient: typeof SavedObjectsClient; types: string[]; + schema: SavedObjectsSchema; getSavedObjectsRepository(...rest: any[]): any; + importExport: { + objectLimit: number; + importSavedObjects(options: SavedObjectsImportOptions): Promise; + resolveImportErrors( + options: SavedObjectsResolveImportErrorsOptions + ): Promise; + getSortedObjectsForExport(options: SavedObjectsExportOptions): Promise; + }; } export { SavedObjectsRepository, ScopedSavedObjectsClientProvider, + SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, SavedObjectsErrorHelpers, diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index 19fdc3d75f603..d987737c2ffa0 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -22,6 +22,7 @@ export { SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, ScopedSavedObjectsClientProvider, + SavedObjectsClientProviderOptions, } from './scoped_client_provider'; export { SavedObjectsErrorHelpers } from './errors'; diff --git a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts index fc0a3ea64c7a4..e0ca16e254e18 100644 --- a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts +++ b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts @@ -19,21 +19,37 @@ import { PriorityCollection } from './priority_collection'; import { SavedObjectsClientContract } from '..'; +/** + * Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. + * @public + */ export interface SavedObjectsClientWrapperOptions { client: SavedObjectsClientContract; request: Request; } +/** + * Describes the factory used to create instances of Saved Objects Client Wrappers. + * @public + */ export type SavedObjectsClientWrapperFactory = ( options: SavedObjectsClientWrapperOptions ) => SavedObjectsClientContract; +/** + * Describes the factory used to create instances of the Saved Objects Client. + * @public + */ export type SavedObjectsClientFactory = ({ request, }: { request: Request; }) => SavedObjectsClientContract; +/** + * Options to control the creation of the Saved Objects Client. + * @public + */ export interface SavedObjectsClientProviderOptions { excludedWrappers?: string[]; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 0fefb2d80892e..94bed2ad01e64 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -5,17 +5,18 @@ ```ts import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; import { ConfigOptions } from 'elasticsearch'; import { Duration } from 'moment'; +import { IncomingHttpHeaders } from 'http'; import { ObjectType } from '@kbn/config-schema'; import { Observable } from 'rxjs'; +import { Readable } from 'stream'; import { Request } from 'hapi'; import { ResponseObject } from 'hapi'; import { ResponseToolkit } from 'hapi'; -import { Schema } from '@kbn/config-schema'; import { Server } from 'hapi'; +import { Stream } from 'stream'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { Url } from 'url'; @@ -29,17 +30,25 @@ export type APICaller = (endpoint: string, clientParams: Record, op export type AuthenticationHandler = (request: KibanaRequest, t: AuthToolkit) => AuthResult | Promise; // @public -export type AuthHeaders = Record; +export type AuthHeaders = Record; // @public -export interface AuthResultData { - headers: AuthHeaders; - state: Record; +export interface AuthResultParams { + requestHeaders?: AuthHeaders; + responseHeaders?: AuthHeaders; + state?: Record; +} + +// @public +export enum AuthStatus { + authenticated = "authenticated", + unauthenticated = "unauthenticated", + unknown = "unknown" } // @public export interface AuthToolkit { - authenticated: (data?: Partial) => AuthResult; + authenticated: (data?: AuthResultParams) => AuthResult; redirected: (url: string) => AuthResult; rejected: (error: Error, options?: { statusCode?: number; @@ -98,7 +107,6 @@ export interface CoreSetup { registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; basePath: HttpServiceSetup['basePath']; - createNewServer: HttpServiceSetup['createNewServer']; isTlsEnabled: HttpServiceSetup['isTlsEnabled']; }; } @@ -107,6 +115,12 @@ export interface CoreSetup { export interface CoreStart { } +// @public +export interface CustomHttpResponseOptions extends HttpResponseOptions { + // (undocumented) + statusCode: number; +} + // @public export interface DiscoveredPlugin { readonly configPath: ConfigPath; @@ -160,21 +174,58 @@ export interface FakeRequest { } // @public -export type GetAuthHeaders = (request: KibanaRequest | Request) => AuthHeaders | undefined; +export type GetAuthHeaders = (request: KibanaRequest | LegacyRequest) => AuthHeaders | undefined; -// @public (undocumented) -export type Headers = Record; +// @public +export type GetAuthState = (request: KibanaRequest | LegacyRequest) => { + status: AuthStatus; + state: unknown; +}; -// Warning: (ae-forgotten-export) The symbol "HttpServerSetup" needs to be exported by the entry point index.d.ts -// -// @public (undocumented) -export interface HttpServiceSetup extends HttpServerSetup { - // Warning: (ae-forgotten-export) The symbol "HttpConfig" needs to be exported by the entry point index.d.ts - // +// @public +export type Headers = { + [header in KnownHeaders]?: string | string[] | undefined; +} & { + [header: string]: string | string[] | undefined; +}; + +// @public +export interface HttpResponseOptions { + // Warning: (ae-forgotten-export) The symbol "ResponseHeaders" needs to be exported by the entry point index.d.ts + headers?: ResponseHeaders; +} + +// @public +export type HttpResponsePayload = undefined | string | Record | Buffer | Stream; + +// @public +export interface HttpServerSetup { + // (undocumented) + auth: { + get: GetAuthState; + isAuthenticated: IsAuthenticated; + getAuthHeaders: GetAuthHeaders; + }; + // (undocumented) + basePath: { + get: (request: KibanaRequest | LegacyRequest) => string; + set: (request: KibanaRequest | LegacyRequest, basePath: string) => void; + prepend: (url: string) => string; + remove: (url: string) => string; + }; + createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => Promise>; + isTlsEnabled: boolean; + registerAuth: (handler: AuthenticationHandler) => void; + registerOnPostAuth: (handler: OnPostAuthHandler) => void; + registerOnPreAuth: (handler: OnPreAuthHandler) => void; + registerRouter: (router: Router) => void; // (undocumented) - createNewServer: (cfg: Partial) => Promise; + server: Server; } +// @public (undocumented) +export type HttpServiceSetup = HttpServerSetup; + // @public (undocumented) export interface HttpServiceStart { isListening: (port: number) => boolean; @@ -196,6 +247,9 @@ export interface InternalCoreStart { plugins: PluginsServiceStart; } +// @public +export type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolean; + // @public export class KibanaRequest { // @internal (undocumented) @@ -212,9 +266,7 @@ export class KibanaRequest { readonly params: Params; // (undocumented) readonly query: Query; - // (undocumented) readonly route: RecursiveReadonly; - // (undocumented) readonly url: Url; } @@ -229,7 +281,37 @@ export interface KibanaRequestRoute { } // @public -export type LegacyRequest = Request; +export type KibanaResponseFactory = typeof kibanaResponseFactory; + +// @public +export const kibanaResponseFactory: { + ok: (payload: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + accepted: (payload?: HttpResponsePayload, options?: HttpResponseOptions) => KibanaResponse | Buffer | Stream>; + noContent: (options?: HttpResponseOptions) => KibanaResponse; + custom: (payload: string | Error | Record | Buffer | Stream | { + message: string | Error; + meta?: ResponseErrorMeta | undefined; + } | undefined, options: CustomHttpResponseOptions) => KibanaResponse | Buffer | Stream | { + message: string | Error; + meta?: ResponseErrorMeta | undefined; + }>; + redirected: (payload: HttpResponsePayload, options: RedirectResponseOptions) => KibanaResponse | Buffer | Stream>; + badRequest: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + unauthorized: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + forbidden: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + notFound: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + conflict: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; + internal: (error?: ResponseError, options?: HttpResponseOptions) => KibanaResponse; +}; + +// Warning: (ae-forgotten-export) The symbol "KnownKeys" needs to be exported by the entry point index.d.ts +// +// @public +export type KnownHeaders = KnownKeys; + +// @public @deprecated (undocumented) +export interface LegacyRequest extends Request { +} // @public export interface Logger { @@ -385,6 +467,39 @@ export type RecursiveReadonly = T extends (...args: any[]) => any ? T : T ext [K in keyof T]: RecursiveReadonly; }> : T; +// @public +export type RedirectResponseOptions = HttpResponseOptions & { + headers: { + location: string; + }; +}; + +// @public +export type RequestHandler

= (request: KibanaRequest, TypeOf, TypeOf>, response: KibanaResponseFactory) => KibanaResponse | Promise>; + +// @public +export type ResponseError = string | Error | { + message: string | Error; + meta?: ResponseErrorMeta; +}; + +// @public +export interface ResponseErrorMeta { + // (undocumented) + data?: Record; + // (undocumented) + docLink?: string; + // (undocumented) + errorCode?: string; +} + +// @public +export interface RouteConfig

{ + options?: RouteConfigOptions; + path: string; + validate: RouteSchemas | false; +} + // @public export interface RouteConfigOptions { authRequired?: boolean; @@ -394,13 +509,12 @@ export interface RouteConfigOptions { // @public export type RouteMethod = 'get' | 'post' | 'put' | 'delete'; -// @public (undocumented) +// @public export class Router { constructor(path: string); delete

(route: RouteConfig, handler: RequestHandler): void; - // Warning: (ae-forgotten-export) The symbol "RouteConfig" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "RequestHandler" needs to be exported by the entry point index.d.ts get

(route: RouteConfig, handler: RequestHandler): void; + // @internal getRoutes(): Readonly[]; // (undocumented) readonly path: string; @@ -515,14 +629,16 @@ export class SavedObjectsClient { // @public export type SavedObjectsClientContract = Pick; -// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export interface SavedObjectsClientProviderOptions { + // (undocumented) + excludedWrappers?: string[]; +} + +// @public export type SavedObjectsClientWrapperFactory = (options: SavedObjectsClientWrapperOptions) => SavedObjectsClientContract; -// Warning: (ae-missing-release-tag) "SavedObjectsClientWrapperOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface SavedObjectsClientWrapperOptions { // (undocumented) client: SavedObjectsClientContract; @@ -590,6 +706,25 @@ export class SavedObjectsErrorHelpers { static isSavedObjectsClientError(error: any): error is DecoratedError; } +// @public +export interface SavedObjectsExportOptions { + // (undocumented) + exportSizeLimit: number; + // (undocumented) + includeReferencesDeep?: boolean; + // (undocumented) + namespace?: string; + // (undocumented) + objects?: Array<{ + id: string; + type: string; + }>; + // (undocumented) + savedObjectsClient: SavedObjectsClientContract; + // (undocumented) + types?: string[]; +} + // @public (undocumented) export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { // (undocumented) @@ -628,12 +763,162 @@ export interface SavedObjectsFindResponse total: number; } +// @public +export interface SavedObjectsImportConflictError { + // (undocumented) + type: 'conflict'; +} + +// @public +export interface SavedObjectsImportError { + // (undocumented) + error: SavedObjectsImportConflictError | SavedObjectsImportUnsupportedTypeError | SavedObjectsImportMissingReferencesError | SavedObjectsImportUnknownError; + // (undocumented) + id: string; + // (undocumented) + title?: string; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportMissingReferencesError { + // (undocumented) + blocking: Array<{ + type: string; + id: string; + }>; + // (undocumented) + references: Array<{ + type: string; + id: string; + }>; + // (undocumented) + type: 'missing_references'; +} + +// @public +export interface SavedObjectsImportOptions { + // (undocumented) + namespace?: string; + // (undocumented) + objectLimit: number; + // (undocumented) + overwrite: boolean; + // (undocumented) + readStream: Readable; + // (undocumented) + savedObjectsClient: SavedObjectsClientContract; + // (undocumented) + supportedTypes: string[]; +} + +// @public +export interface SavedObjectsImportResponse { + // (undocumented) + errors?: SavedObjectsImportError[]; + // (undocumented) + success: boolean; + // (undocumented) + successCount: number; +} + +// @public +export interface SavedObjectsImportRetry { + // (undocumented) + id: string; + // (undocumented) + overwrite: boolean; + // (undocumented) + replaceReferences: Array<{ + type: string; + from: string; + to: string; + }>; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsImportUnknownError { + // (undocumented) + message: string; + // (undocumented) + statusCode: number; + // (undocumented) + type: 'unknown'; +} + +// @public +export interface SavedObjectsImportUnsupportedTypeError { + // (undocumented) + type: 'unsupported_type'; +} + // @public export interface SavedObjectsMigrationVersion { // (undocumented) [pluginName: string]: string; } +// Warning: (ae-missing-release-tag) "RawDoc" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export interface SavedObjectsRawDoc { + // (undocumented) + _id: string; + // (undocumented) + _primary_term?: number; + // (undocumented) + _seq_no?: number; + // (undocumented) + _source: any; + // (undocumented) + _type?: string; +} + +// @public +export interface SavedObjectsResolveImportErrorsOptions { + // (undocumented) + namespace?: string; + // (undocumented) + objectLimit: number; + // (undocumented) + readStream: Readable; + // (undocumented) + retries: SavedObjectsImportRetry[]; + // (undocumented) + savedObjectsClient: SavedObjectsClientContract; + // (undocumented) + supportedTypes: string[]; +} + +// Warning: (ae-missing-release-tag) "SavedObjectsSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class SavedObjectsSchema { + // Warning: (ae-forgotten-export) The symbol "SavedObjectsSchemaDefinition" needs to be exported by the entry point index.d.ts + constructor(schemaDefinition?: SavedObjectsSchemaDefinition); + // (undocumented) + getIndexForType(type: string): string | undefined; + // (undocumented) + isHiddenType(type: string): boolean; + // (undocumented) + isNamespaceAgnostic(type: string): boolean; +} + +// Warning: (ae-missing-release-tag) "SavedObjectsSerializer" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export class SavedObjectsSerializer { + constructor(schema: SavedObjectsSchema); + generateRawId(namespace: string | undefined, type: string, id?: string): string; + isRawSavedObject(rawDoc: SavedObjectsRawDoc): any; + // Warning: (ae-forgotten-export) The symbol "SanitizedSavedObjectDoc" needs to be exported by the entry point index.d.ts + rawToSavedObject(doc: SavedObjectsRawDoc): SanitizedSavedObjectDoc; + savedObjectToRaw(savedObj: SanitizedSavedObjectDoc): SavedObjectsRawDoc; + } + // @public (undocumented) export interface SavedObjectsService { // Warning: (ae-forgotten-export) The symbol "ScopedSavedObjectsClientProvider" needs to be exported by the entry point index.d.ts @@ -644,11 +929,20 @@ export interface SavedObjectsService { getSavedObjectsRepository(...rest: any[]): any; // (undocumented) getScopedSavedObjectsClient: ScopedSavedObjectsClientProvider['getClient']; + // (undocumented) + importExport: { + objectLimit: number; + importSavedObjects(options: SavedObjectsImportOptions): Promise; + resolveImportErrors(options: SavedObjectsResolveImportErrorsOptions): Promise; + getSortedObjectsForExport(options: SavedObjectsExportOptions): Promise; + }; // Warning: (ae-incompatible-release-tags) The symbol "SavedObjectsClient" is marked as @public, but its signature references "SavedObjectsClient" which is marked as @internal // // (undocumented) SavedObjectsClient: typeof SavedObjectsClient; // (undocumented) + schema: SavedObjectsSchema; + // (undocumented) types: string[]; } @@ -669,7 +963,7 @@ export interface SavedObjectsUpdateResponse | undefined); + constructor(internalAPICaller: APICaller, scopedAPICaller: APICaller, headers?: Headers | undefined); callAsCurrentUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; callAsInternalUser(endpoint: string, clientParams?: Record, options?: CallAPIOptions): Promise; } @@ -681,6 +975,14 @@ export interface SessionStorage { set(sessionValue: T): void; } +// @public +export interface SessionStorageCookieOptions { + encryptionKey: string; + isSecure: boolean; + name: string; + validate: (sessionValue: T) => boolean | Promise; +} + // @public export interface SessionStorageFactory { // (undocumented) @@ -690,6 +992,7 @@ export interface SessionStorageFactory { // Warnings were encountered during analysis: // +// src/core/server/http/router/response.ts:188:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/plugin_context.ts:34:10 - (ae-forgotten-export) The symbol "EnvironmentMode" needs to be exported by the entry point index.d.ts // src/core/server/plugins/plugins_service.ts:37:5 - (ae-forgotten-export) The symbol "DiscoveredPluginInternal" needs to be exported by the entry point index.d.ts diff --git a/src/core/utils/map_to_object.ts b/src/core/utils/map_to_object.ts index bfbe5c8ab0bea..edb2fc2bcbfc7 100644 --- a/src/core/utils/map_to_object.ts +++ b/src/core/utils/map_to_object.ts @@ -17,7 +17,7 @@ * under the License. */ -export function mapToObject(map: Map) { +export function mapToObject(map: ReadonlyMap) { const result: Record = Object.create(null); for (const [key, value] of map) { result[key] = value; diff --git a/src/dev/build/tasks/optimize_task.js b/src/dev/build/tasks/optimize_task.js index f6de0c717abfe..bbd8f9622535d 100644 --- a/src/dev/build/tasks/optimize_task.js +++ b/src/dev/build/tasks/optimize_task.js @@ -49,7 +49,7 @@ export const OptimizeBuildTask = { env: { FORCE_DLL_CREATION: 'true', KBN_CACHE_LOADER_WRITABLE: 'true', - NODE_OPTIONS: '--max-old-space-size=2048' + NODE_OPTIONS: '--max-old-space-size=3072' }, }); diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.js b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.js index 95b0740ca738c..962ba811c4fbb 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.js +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.js @@ -32,7 +32,7 @@ function generator({ artifactTarball, versionTag, license, usePublicArtifact }) # # ** THIS IS AN AUTO-GENERATED FILE ** # - + ################################################################################ # Build stage 0 # Extract Kibana and make various file manipulations. @@ -48,43 +48,49 @@ function generator({ artifactTarball, versionTag, license, usePublicArtifact }) # REF: https://docs.openshift.org/latest/creating_images/guidelines.html RUN chmod -R g=u /usr/share/kibana RUN find /usr/share/kibana -type d -exec chmod g+s {} \\; - + ################################################################################ # Build stage 1 # Copy prepared files from the previous stage and complete the image. ################################################################################ FROM centos:7 EXPOSE 5601 - + # Add Reporting dependencies. RUN yum update -y && yum install -y fontconfig freetype && yum clean all - + + # Add an init process, check the checksum to make sure it's a match + RUN curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 + RUN echo "37f2c1f0372a45554f1b89924fbb134fc24c3756efaedf11e07f599494e0eff9 /usr/local/bin/dumb-init" | sha256sum -c - + RUN chmod +x /usr/local/bin/dumb-init + + # Bring in Kibana from the initial stage. COPY --from=prep_files --chown=1000:0 /usr/share/kibana /usr/share/kibana WORKDIR /usr/share/kibana RUN ln -s /usr/share/kibana /opt/kibana - + ENV ELASTIC_CONTAINER true ENV PATH=/usr/share/kibana/bin:$PATH - + # Set some Kibana configuration defaults. COPY --chown=1000:0 config/kibana.yml /usr/share/kibana/config/kibana.yml - + # Add the launcher/wrapper script. It knows how to interpret environment # variables and translate them to Kibana CLI options. COPY --chown=1000:0 bin/kibana-docker /usr/local/bin/ - + # Ensure gid 0 write permissions for OpenShift. RUN chmod g+ws /usr/share/kibana && \\ find /usr/share/kibana -gid 0 -and -not -perm /g+w -exec chmod g+w {} \\; - + # Provide a non-root user to run the process. RUN groupadd --gid 1000 kibana && \\ useradd --uid 1000 --gid 1000 \\ --home-dir /usr/share/kibana --no-create-home \\ kibana USER kibana - + LABEL org.label-schema.schema-version="1.0" \\ org.label-schema.vendor="Elastic" \\ org.label-schema.name="kibana" \\ @@ -92,7 +98,9 @@ function generator({ artifactTarball, versionTag, license, usePublicArtifact }) org.label-schema.url="https://www.elastic.co/products/kibana" \\ org.label-schema.vcs-url="https://github.com/elastic/kibana" \\ license="${ license }" - + + ENTRYPOINT ["/usr/local/bin/dumb-init", "--"] + CMD ["/usr/local/bin/kibana-docker"] `); } diff --git a/src/dev/jest/setup/polyfills.js b/src/dev/jest/setup/polyfills.js index 293fbcf3616d7..9394de0aea936 100644 --- a/src/dev/jest/setup/polyfills.js +++ b/src/dev/jest/setup/polyfills.js @@ -23,17 +23,6 @@ const bluebird = require('bluebird'); bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, fn); }); const MutationObserver = require('mutation-observer'); -// There's a bug in mutation-observer around the `attributes` option -// https://dom.spec.whatwg.org/#mutationobserver -// If either options's attributeOldValue or attributeFilter is present and options's attributes is omitted, then set options's attributes to true. -const _observe = MutationObserver.prototype.observe; -MutationObserver.prototype.observe = function observe(target, options) { - const needsAttributes = options.hasOwnProperty('attributeOldValue') || options.hasOwnProperty('attributeFilter'); - if (needsAttributes && !options.hasOwnProperty('attributes')) { - options.attributes = true; - } - Function.prototype.call(_observe, this, target, options); -}; Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); require('whatwg-fetch'); diff --git a/src/docs/docs_repo.js b/src/docs/docs_repo.js index 7c415f58d499c..63fcd2a6de5ec 100644 --- a/src/docs/docs_repo.js +++ b/src/docs/docs_repo.js @@ -22,7 +22,7 @@ import { resolve } from 'path'; const kibanaDir = resolve(__dirname, '..', '..'); export function buildDocsScript(cmd) { - return resolve(process.cwd(), cmd.docrepo, 'build_docs.pl'); + return resolve(process.cwd(), cmd.docrepo, 'build_docs'); } export function buildDocsArgs(cmd) { diff --git a/src/legacy/core_plugins/console/public/_app.scss b/src/legacy/core_plugins/console/public/_app.scss index a694ea4f814b5..f3de2a9ee9e5b 100644 --- a/src/legacy/core_plugins/console/public/_app.scss +++ b/src/legacy/core_plugins/console/public/_app.scss @@ -61,3 +61,7 @@ z-index: $euiZLevel1 + 2; margin-top: 22px; } + +.conApp__settingsModal { + min-width: 460px; +} diff --git a/src/legacy/core_plugins/console/public/src/components/editor_example.tsx b/src/legacy/core_plugins/console/public/src/components/editor_example.tsx index c67b2a3644570..99309d7b8549c 100644 --- a/src/legacy/core_plugins/console/public/src/components/editor_example.tsx +++ b/src/legacy/core_plugins/console/public/src/components/editor_example.tsx @@ -19,7 +19,7 @@ import React, { useEffect } from 'react'; // @ts-ignore -import exampleText from 'raw-loader!./helpExample.txt'; +import exampleText from 'raw-loader!./help_example.txt'; import $ from 'jquery'; // @ts-ignore import SenseEditor from '../sense_editor/editor'; diff --git a/src/legacy/core_plugins/console/public/src/components/helpExample.txt b/src/legacy/core_plugins/console/public/src/components/help_example.txt similarity index 100% rename from src/legacy/core_plugins/console/public/src/components/helpExample.txt rename to src/legacy/core_plugins/console/public/src/components/help_example.txt diff --git a/src/legacy/core_plugins/console/public/src/components/settings_modal.tsx b/src/legacy/core_plugins/console/public/src/components/settings_modal.tsx index ac540f18bff07..f3ec577e43b71 100644 --- a/src/legacy/core_plugins/console/public/src/components/settings_modal.tsx +++ b/src/legacy/core_plugins/console/public/src/components/settings_modal.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useState } from 'react'; +import React, { Fragment, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -42,7 +42,7 @@ export type AutocompleteOptions = 'fields' | 'indices' | 'templates'; interface Props { onSaveSettings: (newSettings: DevToolsSettings) => Promise; onClose: () => void; - refreshAutocompleteSettings: () => void; + refreshAutocompleteSettings: (selectedSettings: any) => void; settings: DevToolsSettings; } @@ -106,9 +106,70 @@ export function DevToolsSettingsModal(props: Props) { }); } + // It only makes sense to show polling options if the user needs to fetch any data. + const pollingFields = + fields || indices || templates ? ( + + + } + helpText={ + + } + > + + } + onChange={e => setPolling(e.target.checked)} + /> + + + { + // Only refresh the currently selected settings. + props.refreshAutocompleteSettings({ + autocomplete: { + fields, + indices, + templates, + }, + }); + }} + > + + + + ) : ( + undefined + ); + return ( - + + setTripleQuotes(e.target.checked)} /> + - - } - helpText={ - - } - > - - } - onChange={e => setPolling(e.target.checked)} - /> - - - - + {pollingFields} diff --git a/src/legacy/core_plugins/console/public/src/helpers/settings_show_modal.tsx b/src/legacy/core_plugins/console/public/src/helpers/settings_show_modal.tsx index a88955c0ca8bf..c4f36d836dfda 100644 --- a/src/legacy/core_plugins/console/public/src/helpers/settings_show_modal.tsx +++ b/src/legacy/core_plugins/console/public/src/helpers/settings_show_modal.tsx @@ -20,7 +20,7 @@ import { I18nContext } from 'ui/i18n'; import React from 'react'; import ReactDOM from 'react-dom'; -import { DevToolsSettingsModal } from '../components/settings_modal'; +import { DevToolsSettingsModal, AutocompleteOptions } from '../components/settings_modal'; import { DevToolsSettings } from '../components/dev_tools_settings'; // @ts-ignore @@ -32,8 +32,8 @@ export function showSettingsModal() { const container = document.getElementById('consoleSettingsModal'); const curSettings = getCurrentSettings(); - const refreshAutocompleteSettings = () => { - mappings.retrieveAutoCompleteInfo(); + const refreshAutocompleteSettings = (selectedSettings: any) => { + mappings.retrieveAutoCompleteInfo(selectedSettings); }; const closeModal = () => { @@ -53,13 +53,30 @@ export function showSettingsModal() { newSettings: DevToolsSettings, prevSettings: DevToolsSettings ) => { - // We'll only retrieve settings if polling is on. - const isPollingChanged = prevSettings.polling !== newSettings.polling; + // We'll only retrieve settings if polling is on. The expectation here is that if the user + // disables polling it's because they want manual control over the fetch request (possibly + // because it's a very expensive request given their cluster and bandwidth). In that case, + // they would be unhappy with any request that's sent automatically. if (newSettings.polling) { const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); - if (autocompleteDiff.length > 0) { - mappings.retrieveAutoCompleteInfo(newSettings.autocomplete); + + const isSettingsChanged = autocompleteDiff.length > 0; + const isPollingChanged = prevSettings.polling !== newSettings.polling; + + if (isSettingsChanged) { + // If the user has changed one of the autocomplete settings, then we'll fetch just the + // ones which have changed. + const changedSettings: any = autocompleteDiff.reduce( + (changedSettingsAccum: any, setting: string): any => { + changedSettingsAccum[setting] = + newSettings.autocomplete[setting as AutocompleteOptions]; + return changedSettingsAccum; + }, + {} + ); + mappings.retrieveAutoCompleteInfo(changedSettings); } else if (isPollingChanged) { + // If the user has turned polling on, then we'll fetch all selected autocomplete settings. mappings.retrieveAutoCompleteInfo(); } } diff --git a/src/legacy/core_plugins/console/public/src/mappings.js b/src/legacy/core_plugins/console/public/src/mappings.js index 36e8d04291f62..3e223dbd99ca8 100644 --- a/src/legacy/core_plugins/console/public/src/mappings.js +++ b/src/legacy/core_plugins/console/public/src/mappings.js @@ -287,6 +287,16 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { } // Retrieve all selected settings by default. +// TODO: We should refactor this to be easier to consume. Ideally this function should retrieve +// whatever settings are specified, otherwise just use the saved settings. This requires changing +// the behavior to not *clear* whatever settings have been unselected, but it's hard to tell if +// this is possible without altering the autocomplete behavior. These are the scenarios we need to +// support: +// 1. Manual refresh. Specify what we want. Fetch specified, leave unspecified alone. +// 2. Changed selection and saved: Specify what we want. Fetch changed and selected, leave +// unchanged alone (both selected and unselected). +// 3. Poll: Use saved. Fetch selected. Ignore unselected. + function retrieveAutoCompleteInfo(settingsToRetrieve = settings.getAutocomplete()) { if (pollTimeoutId) { clearTimeout(pollTimeoutId); diff --git a/src/legacy/core_plugins/dashboard_embeddable_container/public/np_core.test.mocks.ts b/src/legacy/core_plugins/dashboard_embeddable_container/public/np_core.test.mocks.ts index a3909bc556b57..1e71e2b26970b 100644 --- a/src/legacy/core_plugins/dashboard_embeddable_container/public/np_core.test.mocks.ts +++ b/src/legacy/core_plugins/dashboard_embeddable_container/public/np_core.test.mocks.ts @@ -17,35 +17,11 @@ * under the License. */ -import { fatalErrorsServiceMock, notificationServiceMock } from '../../../../core/public/mocks'; - let modalContents: React.Component; export const getModalContents = () => modalContents; -jest.doMock('ui/new_platform', () => { - return { - npStart: { - core: { - overlays: { - openFlyout: jest.fn(), - openModal: (component: React.Component) => { - modalContents = component; - return { - close: jest.fn(), - }; - }, - }, - }, - }, - npSetup: { - core: { - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - notifications: notificationServiceMock.createSetupContract(), - }, - }, - }; -}); +jest.mock('ui/new_platform'); jest.doMock('ui/metadata', () => ({ metadata: { diff --git a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts index bb2ca806bbac3..043e1fc74c530 100644 --- a/src/legacy/core_plugins/data/public/expressions/expressions_service.ts +++ b/src/legacy/core_plugins/data/public/expressions/expressions_service.ts @@ -41,6 +41,7 @@ export type getInitialContextFunction = () => InitialContextObject; export interface Handlers { getInitialContext: getInitialContextFunction; inspectorAdapters?: Adapters; + abortSignal?: AbortSignal; } type Context = object; diff --git a/src/legacy/core_plugins/data/public/filter/apply_filters/directive.js b/src/legacy/core_plugins/data/public/filter/apply_filters/directive.js deleted file mode 100644 index f235bb3dab600..0000000000000 --- a/src/legacy/core_plugins/data/public/filter/apply_filters/directive.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import 'ngreact'; -import { uiModules } from 'ui/modules'; -import template from './directive.html'; -import { ApplyFiltersPopover } from './apply_filters_popover'; -import { mapAndFlattenFilters } from '../filter_manager/lib/map_and_flatten_filters'; -import { wrapInI18nContext } from 'ui/i18n'; - -const app = uiModules.get('app/data', ['react']); - -export function setupDirective() { - app.directive('applyFiltersPopoverComponent', (reactDirective) => { - return reactDirective(wrapInI18nContext(ApplyFiltersPopover)); - }); - - app.directive('applyFiltersPopover', (indexPatterns) => { - return { - template, - restrict: 'E', - scope: { - filters: '=', - onCancel: '=', - onSubmit: '=', - }, - link: function ($scope) { - $scope.state = {}; - - // Each time the new filters change we want to rebuild (not just re-render) the "apply filters" - // popover, because it has to reset its state whenever the new filters change. Setting a `key` - // property on the component accomplishes this due to how React handles the `key` property. - $scope.$watch('filters', filters => { - mapAndFlattenFilters(indexPatterns, filters).then(mappedFilters => { - $scope.state = { - filters: mappedFilters, - key: Date.now(), - }; - }); - }); - } - }; - }); -} diff --git a/src/legacy/core_plugins/data/public/filter/apply_filters/index.ts b/src/legacy/core_plugins/data/public/filter/apply_filters/index.ts index 36d2501e1d9fb..6b64230ed6a0c 100644 --- a/src/legacy/core_plugins/data/public/filter/apply_filters/index.ts +++ b/src/legacy/core_plugins/data/public/filter/apply_filters/index.ts @@ -18,6 +18,3 @@ */ export { ApplyFiltersPopover } from './apply_filters_popover'; - -// @ts-ignore -export { setupDirective } from './directive'; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx index ebb6c433450a2..1bb594c183bc6 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/index.tsx @@ -32,6 +32,7 @@ import { EuiSwitch, } from '@elastic/eui'; import { FieldFilter, Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; import React, { Component } from 'react'; @@ -293,7 +294,11 @@ class FilterEditorUI extends Component { private renderCustomEditor() { return ( - + { if (isCustomEditorOpen) { const { index, disabled, negate } = this.props.filter.meta; - const newIndex = index || this.props.indexPatterns[0].id; + const newIndex = index || this.props.indexPatterns[0].id!; const body = JSON.parse(queryDsl); const filter = buildCustomFilter(newIndex, body, disabled, negate, alias, $state.store); this.props.onSubmit(filter); } else if (indexPattern && field && operator) { - const filter = buildFilter(indexPattern, field, operator, params, alias, $state.store); + const filter = buildFilter( + indexPattern, + field, + operator, + this.props.filter.meta.disabled, + params, + alias, + $state.store + ); this.props.onSubmit(filter); } }; diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index 8190146a4258f..384fb68b79668 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -18,8 +18,8 @@ */ import { FilterStateStore, toggleFilterNegated } from '@kbn/es-query'; - import { fixtures } from '../../../../index_patterns'; +import { IndexPattern, Field } from '../../../../index'; import { buildFilter, getFieldFromFilter, @@ -58,6 +58,8 @@ jest.mock( ); const { mockFields, mockIndexPattern } = fixtures; +const mockedFields = mockFields as Field[]; +const mockedIndexPattern = mockIndexPattern as IndexPattern; describe('Filter editor utils', () => { describe('getQueryDslFromFilter', () => { @@ -70,14 +72,14 @@ describe('Filter editor utils', () => { describe('getIndexPatternFromFilter', () => { it('should return the index pattern from the filter', () => { - const indexPattern = getIndexPatternFromFilter(phraseFilter, [mockIndexPattern]); - expect(indexPattern).toBe(mockIndexPattern); + const indexPattern = getIndexPatternFromFilter(phraseFilter, [mockedIndexPattern]); + expect(indexPattern).toBe(mockedIndexPattern); }); }); describe('getFieldFromFilter', () => { it('should return the field from the filter', () => { - const field = getFieldFromFilter(phraseFilter, mockIndexPattern); + const field = getFieldFromFilter(phraseFilter, mockedIndexPattern); expect(field).not.toBeUndefined(); expect(field && field.name).toBe(phraseFilter.meta.key); }); @@ -169,12 +171,12 @@ describe('Filter editor utils', () => { describe('getFilterableFields', () => { it('returns the list of fields from the given index pattern', () => { - const fieldOptions = getFilterableFields(mockIndexPattern); + const fieldOptions = getFilterableFields(mockedIndexPattern); expect(fieldOptions.length).toBeGreaterThan(0); }); it('limits the fields to the filterable fields', () => { - const fieldOptions = getFilterableFields(mockIndexPattern); + const fieldOptions = getFilterableFields(mockedIndexPattern); const nonFilterableFields = fieldOptions.filter(field => !field.filterable); expect(nonFilterableFields.length).toBe(0); }); @@ -183,14 +185,14 @@ describe('Filter editor utils', () => { describe('getOperatorOptions', () => { it('returns range for number fields', () => { const [field] = mockFields.filter(({ type }) => type === 'number'); - const operatorOptions = getOperatorOptions(field); + const operatorOptions = getOperatorOptions(field as Field); const rangeOperator = operatorOptions.find(operator => operator.type === 'range'); expect(rangeOperator).not.toBeUndefined(); }); it('does not return range for string fields', () => { const [field] = mockFields.filter(({ type }) => type === 'string'); - const operatorOptions = getOperatorOptions(field); + const operatorOptions = getOperatorOptions(field as Field); const rangeOperator = operatorOptions.find(operator => operator.type === 'range'); expect(rangeOperator).toBeUndefined(); }); @@ -198,44 +200,49 @@ describe('Filter editor utils', () => { describe('isFilterValid', () => { it('should return false if index pattern is not provided', () => { - const isValid = isFilterValid(undefined, mockFields[0], isOperator, 'foo'); + const isValid = isFilterValid(undefined, mockedFields[0], isOperator, 'foo'); expect(isValid).toBe(false); }); it('should return false if field is not provided', () => { - const isValid = isFilterValid(mockIndexPattern, undefined, isOperator, 'foo'); + const isValid = isFilterValid(mockedIndexPattern, undefined, isOperator, 'foo'); expect(isValid).toBe(false); }); it('should return false if operator is not provided', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], undefined, 'foo'); + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], undefined, 'foo'); expect(isValid).toBe(false); }); it('should return false for phrases filter without phrases', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], isOneOfOperator, []); + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], isOneOfOperator, []); expect(isValid).toBe(false); }); it('should return true for phrases filter with phrases', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], isOneOfOperator, ['foo']); + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], isOneOfOperator, ['foo']); expect(isValid).toBe(true); }); it('should return false for range filter without range', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], isBetweenOperator, undefined); + const isValid = isFilterValid( + mockedIndexPattern, + mockedFields[0], + isBetweenOperator, + undefined + ); expect(isValid).toBe(false); }); it('should return true for range filter with from', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], isBetweenOperator, { + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], isBetweenOperator, { from: 'foo', }); expect(isValid).toBe(true); }); it('should return true for range filter with from/to', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], isBetweenOperator, { + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], isBetweenOperator, { from: 'foo', too: 'goo', }); @@ -243,7 +250,7 @@ describe('Filter editor utils', () => { }); it('should return true for exists filter without params', () => { - const isValid = isFilterValid(mockIndexPattern, mockFields[0], existsOperator); + const isValid = isFilterValid(mockedIndexPattern, mockedFields[0], existsOperator); expect(isValid).toBe(true); }); }); @@ -253,7 +260,15 @@ describe('Filter editor utils', () => { const params = 'foo'; const alias = 'bar'; const state = FilterStateStore.APP_STATE; - const filter = buildFilter(mockIndexPattern, mockFields[0], isOperator, params, alias, state); + const filter = buildFilter( + mockedIndexPattern, + mockedFields[0], + isOperator, + false, + params, + alias, + state + ); expect(filter.meta.negate).toBe(isOperator.negate); expect(filter.meta.alias).toBe(alias); @@ -268,9 +283,10 @@ describe('Filter editor utils', () => { const alias = 'bar'; const state = FilterStateStore.APP_STATE; const filter = buildFilter( - mockIndexPattern, - mockFields[0], + mockedIndexPattern, + mockedFields[0], isOneOfOperator, + false, params, alias, state @@ -289,9 +305,10 @@ describe('Filter editor utils', () => { const alias = 'bar'; const state = FilterStateStore.APP_STATE; const filter = buildFilter( - mockIndexPattern, - mockFields[0], + mockedIndexPattern, + mockedFields[0], isBetweenOperator, + false, params, alias, state @@ -309,9 +326,10 @@ describe('Filter editor utils', () => { const alias = 'bar'; const state = FilterStateStore.APP_STATE; const filter = buildFilter( - mockIndexPattern, - mockFields[0], + mockedIndexPattern, + mockedFields[0], existsOperator, + false, params, alias, state @@ -324,14 +342,31 @@ describe('Filter editor utils', () => { } }); + it('should include disabled state', () => { + const params = undefined; + const alias = 'bar'; + const state = FilterStateStore.APP_STATE; + const filter = buildFilter( + mockedIndexPattern, + mockedFields[0], + doesNotExistOperator, + true, + params, + alias, + state + ); + expect(filter.meta.disabled).toBe(true); + }); + it('should negate based on operator', () => { const params = undefined; const alias = 'bar'; const state = FilterStateStore.APP_STATE; const filter = buildFilter( - mockIndexPattern, - mockFields[0], + mockedIndexPattern, + mockedFields[0], doesNotExistOperator, + false, params, alias, state diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts index a4704e2b1d644..3b7c8c1feb44a 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -130,6 +130,7 @@ export function buildFilter( indexPattern: IndexPattern, field: Field, operator: Operator, + disabled: boolean, params: any, alias: string | null, store: FilterStateStore @@ -137,6 +138,7 @@ export function buildFilter( const filter = buildBaseFilter(indexPattern, field, operator, params); filter.meta.alias = alias; filter.meta.negate = operator.negate; + filter.meta.disabled = disabled; filter.$state = { store }; return filter; } diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/index.ts b/src/legacy/core_plugins/data/public/filter/filter_bar/index.ts index d0786734e42dd..438d292b9f583 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/index.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_bar/index.ts @@ -17,9 +17,4 @@ * under the License. */ -import './directive'; - export { FilterBar } from './filter_bar'; - -// @ts-ignore -export { setupDirective } from './directive'; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts index d27b0eab0e34a..3f3cbd0044a07 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.test.ts @@ -26,6 +26,7 @@ import { Filter, FilterStateStore } from '@kbn/es-query'; import { FilterStateManager } from './filter_state_manager'; import { FilterManager } from './filter_manager'; +import { IndexPatterns } from 'ui/index_patterns'; import { getFilter } from './test_helpers/get_stub_filter'; import { StubIndexPatterns } from './test_helpers/stub_index_pattern'; import { StubState } from './test_helpers/stub_state'; @@ -78,7 +79,7 @@ describe('filter_manager', () => { appStateStub = new StubState(); globalStateStub = new StubState(); indexPatterns = new StubIndexPatterns(); - filterManager = new FilterManager(indexPatterns); + filterManager = new FilterManager(indexPatterns as IndexPatterns); readyFilters = getFiltersArray(); // FilterStateManager is tested indirectly. @@ -217,6 +218,30 @@ describe('filter_manager', () => { expect(updateListener.called).toBeTruthy(); expect(updateListener.callCount).toBe(2); }); + + test('changing a disabled filter should fire only update event', async function() { + const updateStub = jest.fn(); + const fetchStub = jest.fn(); + const f1 = getFilter(FilterStateStore.GLOBAL_STATE, true, false, 'age', 34); + + await filterManager.setFilters([f1]); + + filterManager.getUpdates$().subscribe({ + next: updateStub, + }); + + filterManager.getFetches$().subscribe({ + next: fetchStub, + }); + + const f2 = _.cloneDeep(f1); + f2.meta.negate = true; + await filterManager.setFilters([f2]); + + // this time, events should be emitted + expect(fetchStub).toBeCalledTimes(0); + expect(updateStub).toBeCalledTimes(1); + }); }); describe('add filters', () => { diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts index 6c472ab76f9c0..ccb26c801c701 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_manager.ts @@ -35,6 +35,8 @@ import { extractTimeFilter } from './lib/extract_time_filter'; // @ts-ignore import { changeTimeFilter } from './lib/change_time_filter'; +import { onlyDisabledFiltersChanged } from './lib/only_disabled'; + import { PartitionedFilters } from './partitioned_filters'; import { IndexPatterns } from '../../index_patterns'; @@ -92,13 +94,14 @@ export class FilterManager { }); const filtersUpdated = !_.isEqual(this.filters, newFilters); + const updatedOnlyDisabledFilters = onlyDisabledFiltersChanged(newFilters, this.filters); this.filters = newFilters; if (filtersUpdated) { this.updated$.next(); - // Fired together with updated$, because historically (~4 years ago) there was a fetch optimization, that didn't call fetch for very specific cases. - // This optimization seems irrelevant at the moment, but I didn't want to change the logic of all consumers. - this.fetch$.next(); + if (!updatedOnlyDisabledFilters) { + this.fetch$.next(); + } } } diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts index b413efc0ba0f5..4152a0931b031 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.test.ts @@ -20,10 +20,9 @@ import sinon from 'sinon'; import { FilterStateStore } from '@kbn/es-query'; - -import { Subscription } from 'rxjs'; import { FilterStateManager } from './filter_state_manager'; +import { IndexPatterns } from 'ui/index_patterns'; import { StubState } from './test_helpers/stub_state'; import { getFilter } from './test_helpers/get_stub_filter'; import { FilterManager } from './filter_manager'; @@ -50,20 +49,13 @@ describe('filter_state_manager', () => { let appStateStub: StubState; let globalStateStub: StubState; - let subscription: Subscription | undefined; let filterManager: FilterManager; beforeEach(() => { appStateStub = new StubState(); globalStateStub = new StubState(); const indexPatterns = new StubIndexPatterns(); - filterManager = new FilterManager(indexPatterns); - }); - - afterEach(() => { - if (subscription) { - subscription.unsubscribe(); - } + filterManager = new FilterManager(indexPatterns as IndexPatterns); }); describe('app_state_undefined', () => { @@ -164,4 +156,25 @@ describe('filter_state_manager', () => { sinon.assert.calledOnce(globalStateStub.save); }); }); + + describe('bug fixes', () => { + /* + ** This test is here to reproduce a bug where a filter manager update + ** would cause filter state manager detects those changes + ** And triggers *another* filter manager update. + */ + test('should NOT re-trigger filter manager', async done => { + const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); + filterManager.setFilters([f1]); + const setFiltersSpy = sinon.spy(filterManager, 'setFilters'); + + f1.meta.negate = true; + await filterManager.setFilters([f1]); + + setTimeout(() => { + expect(setFiltersSpy.callCount).toEqual(1); + done(); + }, 100); + }); + }); }); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts index 032233cbc0a8b..06f91e35db96e 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/filter_state_manager.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Filter, FilterStateStore } from '@kbn/es-query'; +import { FilterStateStore } from '@kbn/es-query'; import _ from 'lodash'; import { State } from 'ui/state_management/state'; @@ -34,8 +34,6 @@ export class FilterStateManager { filterManager: FilterManager; globalState: State; getAppState: GetAppStateFunc; - prevGlobalFilters: Filter[] | undefined; - prevAppFilters: Filter[] | undefined; interval: NodeJS.Timeout | undefined; constructor(globalState: State, getAppState: GetAppStateFunc, filterManager: FilterManager) { @@ -67,10 +65,8 @@ export class FilterStateManager { const globalFilters = this.globalState.filters || []; const appFilters = (appState && appState.filters) || []; - const globalFilterChanged = !( - this.prevGlobalFilters && _.isEqual(this.prevGlobalFilters, globalFilters) - ); - const appFilterChanged = !(this.prevAppFilters && _.isEqual(this.prevAppFilters, appFilters)); + const globalFilterChanged = !_.isEqual(this.filterManager.getGlobalFilters(), globalFilters); + const appFilterChanged = !_.isEqual(this.filterManager.getAppFilters(), appFilters); const filterStateChanged = globalFilterChanged || appFilterChanged; if (!filterStateChanged) return; @@ -81,10 +77,6 @@ export class FilterStateManager { FilterManager.setFiltersStore(newGlobalFilters, FilterStateStore.GLOBAL_STATE); this.filterManager.setFilters(newGlobalFilters.concat(newAppFilters)); - - // store new filter changes - this.prevGlobalFilters = newGlobalFilters; - this.prevAppFilters = newAppFilters; }, 10); } diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/index.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/index.ts index 0e37e1ed4dec8..dc70ae910835d 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/index.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/index.ts @@ -22,3 +22,4 @@ export { FilterStateManager } from './filter_state_manager'; // @ts-ignore export { uniqFilters } from './lib/uniq_filters'; +export { onlyDisabledFiltersChanged } from './lib/only_disabled'; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/map_match_all.js b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/map_match_all.js index 8560784da98a9..e0d8fdc88f448 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/map_match_all.js +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/map_match_all.js @@ -21,7 +21,7 @@ import expect from '@kbn/expect'; import ngMock from 'ng_mock'; import { mapMatchAll } from '../map_match_all'; -describe('ui/filter_manager/lib', function () { +describe('filter_manager/lib', function () { describe('mapMatchAll()', function () { let filter; diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/only_disabled.js b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/only_disabled.js deleted file mode 100644 index a6f4c74a70bab..0000000000000 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/__tests__/only_disabled.js +++ /dev/null @@ -1,130 +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 { onlyDisabled } from '../only_disabled'; -import expect from '@kbn/expect'; - -describe('Filter Bar Directive', function () { - describe('onlyDisabled()', function () { - - it('should return true if all filters are disabled', function () { - const filters = [ - { meta: { disabled: true } }, - { meta: { disabled: true } }, - { meta: { disabled: true } } - ]; - const newFilters = [{ meta: { disabled: true } }]; - expect(onlyDisabled(newFilters, filters)).to.be(true); - }); - - it('should return false if all filters are not disabled', function () { - const filters = [ - { meta: { disabled: false } }, - { meta: { disabled: false } }, - { meta: { disabled: false } } - ]; - const newFilters = [{ meta: { disabled: false } }]; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should return false if only old filters are disabled', function () { - const filters = [ - { meta: { disabled: true } }, - { meta: { disabled: true } }, - { meta: { disabled: true } } - ]; - const newFilters = [{ meta: { disabled: false } }]; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should return false if new filters are not disabled', function () { - const filters = [ - { meta: { disabled: false } }, - { meta: { disabled: false } }, - { meta: { disabled: false } } - ]; - const newFilters = [{ meta: { disabled: true } }]; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should return true when all removed filters were disabled', function () { - const filters = [ - { meta: { disabled: true } }, - { meta: { disabled: true } }, - { meta: { disabled: true } } - ]; - const newFilters = []; - expect(onlyDisabled(newFilters, filters)).to.be(true); - }); - - it('should return false when all removed filters were not disabled', function () { - const filters = [ - { meta: { disabled: false } }, - { meta: { disabled: false } }, - { meta: { disabled: false } } - ]; - const newFilters = []; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should return true if all changed filters are disabled', function () { - const filters = [ - { meta: { disabled: true, negate: false } }, - { meta: { disabled: true, negate: false } } - ]; - const newFilters = [ - { meta: { disabled: true, negate: true } }, - { meta: { disabled: true, negate: true } } - ]; - expect(onlyDisabled(newFilters, filters)).to.be(true); - }); - - it('should return false if all filters remove were not disabled', function () { - const filters = [ - { meta: { disabled: false } }, - { meta: { disabled: false } }, - { meta: { disabled: true } } - ]; - const newFilters = [{ meta: { disabled: false } }]; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should return false when all removed filters are not disabled', function () { - const filters = [ - { meta: { disabled: true } }, - { meta: { disabled: false } }, - { meta: { disabled: true } } - ]; - const newFilters = []; - expect(onlyDisabled(newFilters, filters)).to.be(false); - }); - - it('should not throw with null filters', function () { - const filters = [ - null, - { meta: { disabled: true } } - ]; - const newFilters = []; - expect(function () { - onlyDisabled(newFilters, filters); - }).to.not.throwError(); - }); - - }); -}); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.test.ts b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.test.ts new file mode 100644 index 0000000000000..7a3b767b97b1b --- /dev/null +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { Filter } from '@kbn/es-query'; + +import { onlyDisabledFiltersChanged } from './only_disabled'; +import expect from '@kbn/expect'; + +describe('Filter Bar Directive', function() { + describe('onlyDisabledFiltersChanged()', function() { + it('should return true if all filters are disabled', function() { + const filters = [ + { meta: { disabled: true } }, + { meta: { disabled: true } }, + { meta: { disabled: true } }, + ] as Filter[]; + const newFilters = [{ meta: { disabled: true } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(true); + }); + + it('should return false if there are no old filters', function() { + const newFilters = [{ meta: { disabled: false } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, undefined)).to.be(false); + }); + + it('should return false if there are no new filters', function() { + const filters = [{ meta: { disabled: false } }] as Filter[]; + expect(onlyDisabledFiltersChanged(undefined, filters)).to.be(false); + }); + + it('should return false if all filters are not disabled', function() { + const filters = [ + { meta: { disabled: false } }, + { meta: { disabled: false } }, + { meta: { disabled: false } }, + ] as Filter[]; + const newFilters = [{ meta: { disabled: false } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should return false if only old filters are disabled', function() { + const filters = [ + { meta: { disabled: true } }, + { meta: { disabled: true } }, + { meta: { disabled: true } }, + ] as Filter[]; + const newFilters = [{ meta: { disabled: false } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should return false if new filters are not disabled', function() { + const filters = [ + { meta: { disabled: false } }, + { meta: { disabled: false } }, + { meta: { disabled: false } }, + ] as Filter[]; + const newFilters = [{ meta: { disabled: true } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should return true when all removed filters were disabled', function() { + const filters = [ + { meta: { disabled: true } }, + { meta: { disabled: true } }, + { meta: { disabled: true } }, + ] as Filter[]; + const newFilters = [] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(true); + }); + + it('should return false when all removed filters were not disabled', function() { + const filters = [ + { meta: { disabled: false } }, + { meta: { disabled: false } }, + { meta: { disabled: false } }, + ] as Filter[]; + const newFilters = [] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should return true if all changed filters are disabled', function() { + const filters = [ + { meta: { disabled: true, negate: false } }, + { meta: { disabled: true, negate: false } }, + ] as Filter[]; + const newFilters = [ + { meta: { disabled: true, negate: true } }, + { meta: { disabled: true, negate: true } }, + ] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(true); + }); + + it('should return false if all filters remove were not disabled', function() { + const filters = [ + { meta: { disabled: false } }, + { meta: { disabled: false } }, + { meta: { disabled: true } }, + ] as Filter[]; + const newFilters = [{ meta: { disabled: false } }] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should return false when all removed filters are not disabled', function() { + const filters = [ + { meta: { disabled: true } }, + { meta: { disabled: false } }, + { meta: { disabled: true } }, + ] as Filter[]; + const newFilters = [] as Filter[]; + expect(onlyDisabledFiltersChanged(newFilters, filters)).to.be(false); + }); + + it('should not throw with null filters', function() { + const filters = [null, { meta: { disabled: true } }] as Filter[]; + const newFilters = [] as Filter[]; + expect(function() { + onlyDisabledFiltersChanged(newFilters, filters); + }).to.not.throwError(); + }); + }); +}); diff --git a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.js b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.ts similarity index 66% rename from src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.js rename to src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.ts index b78fde4e28209..24f6b6db5352b 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.js +++ b/src/legacy/core_plugins/data/public/filter/filter_manager/lib/only_disabled.ts @@ -18,17 +18,19 @@ */ import _ from 'lodash'; +import { Filter } from '@kbn/es-query'; -const pluckDisabled = function (filter) { - return _.get(filter, 'meta.disabled'); +const isEnabled = function(filter: Filter) { + return filter && filter.meta && !filter.meta.disabled; }; - /** * Checks to see if only disabled filters have been changed * @returns {bool} Only disabled filters */ -export function onlyDisabled(newFilters, oldFilters) { - return _.every(newFilters.concat(oldFilters), function (newFilter) { - return pluckDisabled(newFilter); - }); +export function onlyDisabledFiltersChanged(newFilters?: Filter[], oldFilters?: Filter[]) { + // If it's the same - compare only enabled filters + const newEnabledFilters = _.filter(newFilters || [], isEnabled); + const oldEnabledFilters = _.filter(oldFilters || [], isEnabled); + + return _.isEqual(oldEnabledFilters, newEnabledFilters); } diff --git a/src/legacy/core_plugins/data/public/filter/filter_service.ts b/src/legacy/core_plugins/data/public/filter/filter_service.ts index 2dbe8da3e2ad7..27cf027e45907 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_service.ts +++ b/src/legacy/core_plugins/data/public/filter/filter_service.ts @@ -17,9 +17,6 @@ * under the License. */ -import { once } from 'lodash'; -import { FilterBar, setupDirective as setupFilterBarDirective } from './filter_bar'; -import { ApplyFiltersPopover, setupDirective as setupApplyFiltersDirective } from './apply_filters'; import { IndexPatterns } from '../index_patterns'; import { FilterManager } from './filter_manager'; /** @@ -37,14 +34,6 @@ export class FilterService { return { filterManager, - ui: { - ApplyFiltersPopover, - FilterBar, - }, - loadLegacyDirectives: once(() => { - setupFilterBarDirective(); - setupApplyFiltersDirective(); - }), }; } diff --git a/src/legacy/core_plugins/data/public/filter/index.tsx b/src/legacy/core_plugins/data/public/filter/index.tsx index d4dba0d834ffe..24452d3c35576 100644 --- a/src/legacy/core_plugins/data/public/filter/index.tsx +++ b/src/legacy/core_plugins/data/public/filter/index.tsx @@ -20,3 +20,5 @@ export { FilterService, FilterSetup } from './filter_service'; export { FilterBar } from './filter_bar'; + +export { ApplyFiltersPopover } from './apply_filters'; diff --git a/src/legacy/core_plugins/data/public/index.ts b/src/legacy/core_plugins/data/public/index.ts index 9cce64b0c5741..6d33349614294 100644 --- a/src/legacy/core_plugins/data/public/index.ts +++ b/src/legacy/core_plugins/data/public/index.ts @@ -17,75 +17,29 @@ * under the License. */ -// TODO these are imports from the old plugin world. -// Once the new platform is ready, they can get removed -// and handled by the platform itself in the setup method -// of the ExpressionExectorService -// @ts-ignore -import { renderersRegistry } from 'plugins/interpreter/registries'; -import { ExpressionsService, ExpressionsSetup } from './expressions'; -import { QueryService, QuerySetup } from './query'; -import { FilterService, FilterSetup } from './filter'; -import { IndexPatternsService, IndexPatternsSetup } from './index_patterns'; +// /// Define plugin function +import { DataPlugin as Plugin } from './plugin'; -export class DataPlugin { - // Exposed services, sorted alphabetically - private readonly expressions: ExpressionsService; - private readonly filter: FilterService; - private readonly indexPatterns: IndexPatternsService; - private readonly query: QueryService; - - constructor() { - this.indexPatterns = new IndexPatternsService(); - this.filter = new FilterService(); - this.query = new QueryService(); - this.expressions = new ExpressionsService(); - } - - public setup(): DataSetup { - // TODO: this is imported here to avoid circular imports. - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { getInterpreter } = require('plugins/interpreter/interpreter'); - const indexPatternsService = this.indexPatterns.setup(); - return { - expressions: this.expressions.setup({ - interpreter: { - getInterpreter, - renderersRegistry, - }, - }), - indexPatterns: indexPatternsService, - filter: this.filter.setup({ - indexPatterns: indexPatternsService.indexPatterns, - }), - query: this.query.setup(), - }; - } - - public stop() { - this.expressions.stop(); - this.indexPatterns.stop(); - this.filter.stop(); - this.query.stop(); - } +export function plugin() { + return new Plugin(); } -/** @public */ -export interface DataSetup { - expressions: ExpressionsSetup; - indexPatterns: IndexPatternsSetup; - filter: FilterSetup; - query: QuerySetup; -} +// /// Export types & static code /** @public types */ export { ExpressionRenderer, ExpressionRendererProps, ExpressionRunner } from './expressions'; /** @public types */ -export { IndexPattern, StaticIndexPattern, StaticIndexPatternField, Field } from './index_patterns'; -export { Query, QueryBar } from './query'; -export { FilterBar } from './filter'; -export { FilterManager, FilterStateManager, uniqFilters } from './filter/filter_manager'; +export { IndexPattern, IndexPatterns, StaticIndexPattern, Field } from './index_patterns'; +export { Query, QueryBar, QueryBarInput } from './query'; +export { FilterBar, ApplyFiltersPopover } from './filter'; +export { SearchBar, SearchBarProps } from './search'; +export { + FilterManager, + FilterStateManager, + uniqFilters, + onlyDisabledFiltersChanged, +} from './filter/filter_manager'; /** @public static code */ export { dateHistogramInterval } from '../common/date_histogram_interval'; diff --git a/src/legacy/core_plugins/data/public/index_patterns/index.ts b/src/legacy/core_plugins/data/public/index_patterns/index.ts index 471c646ff7671..ba211850d52f8 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/index.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/index.ts @@ -20,12 +20,12 @@ export { IndexPatternsService, IndexPatterns, + fixtures, + utils, // types IndexPatternsSetup, IndexPattern, StaticIndexPattern, - StaticIndexPatternField, Field, - fixtures, - utils, + FieldType, } from './index_patterns_service'; diff --git a/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts b/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts index 518a4b8c6c140..74918bebccf08 100644 --- a/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts +++ b/src/legacy/core_plugins/data/public/index_patterns/index_patterns_service.ts @@ -33,7 +33,7 @@ import { validateIndexPattern } from 'ui/index_patterns/index'; import { isFilterable, getFromSavedObject } from 'ui/index_patterns/static_utils'; -// IndexPattern, StaticIndexPattern, StaticIndexPatternField, Field +// IndexPattern, StaticIndexPattern, Field import * as types from 'ui/index_patterns'; const config = chrome.getUiSettingsClient(); @@ -94,7 +94,7 @@ export type IndexPattern = types.IndexPattern; export type StaticIndexPattern = types.StaticIndexPattern; /** @public */ -export type StaticIndexPatternField = types.StaticIndexPatternField; +export type Field = types.Field; /** @public */ -export type Field = types.Field; +export type FieldType = types.FieldType; diff --git a/src/legacy/core_plugins/data/public/legacy.ts b/src/legacy/core_plugins/data/public/legacy.ts new file mode 100644 index 0000000000000..14350a6284191 --- /dev/null +++ b/src/legacy/core_plugins/data/public/legacy.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. + */ + +/** + * New Platform Shim + * + * In this file, we import any legacy dependencies we have, and shim them into + * our plugin by manually constructing the values that the new platform will + * eventually be passing to the `setup` method of our plugin definition. + * + * The idea is that our `plugin.ts` can stay "pure" and not contain any legacy + * world code. Then when it comes time to migrate to the new platform, we can + * simply delete this shim file. + * + * We are also calling `setup` here and exporting our public contract so that + * other legacy plugins are able to import from '../core_plugins/data/legacy' + * and receive the response value of the `setup` contract, mimicking the + * data that will eventually be injected by the new platform. + */ + +import { npSetup } from 'ui/new_platform'; +// @ts-ignore +import { renderersRegistry } from 'plugins/interpreter/registries'; +// @ts-ignore +import { getInterpreter } from 'plugins/interpreter/interpreter'; +import { LegacyDependenciesPlugin } from './shim/legacy_dependencies_plugin'; +import { plugin } from '.'; + +const dataPlugin = plugin(); +const legacyPlugin = new LegacyDependenciesPlugin(); + +export const setup = dataPlugin.setup(npSetup.core, { + __LEGACY: legacyPlugin.setup(), + interpreter: { + renderersRegistry, + getInterpreter, + }, +}); diff --git a/src/legacy/core_plugins/data/public/plugin.ts b/src/legacy/core_plugins/data/public/plugin.ts new file mode 100644 index 0000000000000..f94e10e033a9b --- /dev/null +++ b/src/legacy/core_plugins/data/public/plugin.ts @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreSetup, CoreStart, Plugin } from '../../../../core/public'; +import { ExpressionsService, ExpressionsSetup } from './expressions'; +import { SearchService, SearchSetup } from './search'; +import { QueryService, QuerySetup } from './query'; +import { FilterService, FilterSetup } from './filter'; +import { IndexPatternsService, IndexPatternsSetup } from './index_patterns'; +import { LegacyDependenciesPluginSetup } from './shim/legacy_dependencies_plugin'; + +/** + * Interface for any dependencies on other plugins' `setup` contracts. + * + * @internal + */ +export interface DataPluginSetupDependencies { + __LEGACY: LegacyDependenciesPluginSetup; + interpreter: any; +} + +/** + * Interface for this plugin's returned `setup` contract. + * + * @public + */ +export interface DataSetup { + expressions: ExpressionsSetup; + indexPatterns: IndexPatternsSetup; + filter: FilterSetup; + query: QuerySetup; + search: SearchSetup; +} + +/** + * Data Plugin - public + * + * This is the entry point for the entire client-side public contract of the plugin. + * If something is not explicitly exported here, you can safely assume it is private + * to the plugin and not considered stable. + * + * All stateful contracts will be injected by the platform at runtime, and are defined + * in the setup/start interfaces. The remaining items exported here are either types, + * or static code. + */ +export class DataPlugin implements Plugin { + // Exposed services, sorted alphabetically + private readonly expressions: ExpressionsService = new ExpressionsService(); + private readonly filter: FilterService = new FilterService(); + private readonly indexPatterns: IndexPatternsService = new IndexPatternsService(); + private readonly query: QueryService = new QueryService(); + private readonly search: SearchService = new SearchService(); + + public setup(core: CoreSetup, { __LEGACY, interpreter }: DataPluginSetupDependencies): DataSetup { + const indexPatternsService = this.indexPatterns.setup(); + return { + expressions: this.expressions.setup({ + interpreter, + }), + indexPatterns: indexPatternsService, + filter: this.filter.setup({ + indexPatterns: indexPatternsService.indexPatterns, + }), + query: this.query.setup(), + search: this.search.setup(), + }; + } + + public start(core: CoreStart) {} + + public stop() { + this.expressions.stop(); + this.indexPatterns.stop(); + this.filter.stop(); + this.query.stop(); + this.search.stop(); + } +} diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar.test.tsx.snap b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar.test.tsx.snap index 8680f269d93ed..47838306620e2 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar.test.tsx.snap +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/__snapshots__/query_bar.test.tsx.snap @@ -79,6 +79,7 @@ exports[`QueryBar Should render the given query 1`] = ` dateFormat="YY" end="now" isAutoRefreshOnly={false} + isDisabled={false} isPaused={true} onTimeChange={[Function]} recentlyUsedRanges={ diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.test.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.test.tsx index 87bcebc4c510a..6b56ad49a23c4 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar.test.tsx @@ -23,6 +23,7 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; import './query_bar.test.mocks'; import { QueryBar } from './query_bar'; +import { IndexPattern } from '../../../index'; const noop = () => { return; @@ -63,7 +64,7 @@ const mockIndexPattern = { searchable: true, }, ], -}; +} as IndexPattern; describe('QueryBar', () => { const QUERY_INPUT_SELECTOR = 'InjectIntl(QueryBarInputUI)'; diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx index d51dda1e4f5d3..ff155dfccdac2 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.test.tsx @@ -28,6 +28,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { QueryLanguageSwitcher } from './language_switcher'; import { QueryBarInput, QueryBarInputUI } from './query_bar_input'; +import { IndexPattern } from '../../../index'; const noop = () => { return; @@ -73,7 +74,7 @@ const mockIndexPattern = { searchable: true, }, ], -}; +} as IndexPattern; describe('QueryBarInput', () => { beforeEach(() => { diff --git a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx index 28a31610a40fb..9bfdc68763721 100644 --- a/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx +++ b/src/legacy/core_plugins/data/public/query/query_bar/components/query_bar_input.tsx @@ -111,7 +111,7 @@ export class QueryBarInputUI extends Component { indexPattern => typeof indexPattern !== 'string' ) as IndexPattern[]; - const objectPatternsFromStrings = await fetchIndexPatterns(stringPatterns); + const objectPatternsFromStrings = (await fetchIndexPatterns(stringPatterns)) as IndexPattern[]; this.setState({ indexPatterns: [...objectPatterns, ...objectPatternsFromStrings], diff --git a/src/legacy/core_plugins/data/public/query/query_service.ts b/src/legacy/core_plugins/data/public/query/query_service.ts index 745fb1bac686b..db04b3a29a212 100644 --- a/src/legacy/core_plugins/data/public/query/query_service.ts +++ b/src/legacy/core_plugins/data/public/query/query_service.ts @@ -17,7 +17,7 @@ * under the License. */ -import { QueryBar, QueryBarInput, fromUser, toUser, getQueryLog } from './query_bar'; +import { fromUser, toUser, getQueryLog } from './query_bar'; /** * Query Service @@ -32,10 +32,6 @@ export class QueryService { toUser, getQueryLog, }, - ui: { - QueryBar, - QueryBarInput, - }, }; } @@ -47,4 +43,4 @@ export class QueryService { /** @public */ export type QuerySetup = ReturnType; -export { Query, QueryBar } from './query_bar'; +export { Query, QueryBar, QueryBarInput } from './query_bar'; diff --git a/src/legacy/core_plugins/data/public/search/index.ts b/src/legacy/core_plugins/data/public/search/index.ts new file mode 100644 index 0000000000000..6a9687ba7e325 --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { SearchService, SearchSetup } from './search_service'; + +export * from './search_bar'; diff --git a/src/legacy/core_plugins/kibana_react/public/search_bar/components/index.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/search_bar/components/index.tsx rename to src/legacy/core_plugins/data/public/search/search_bar/components/index.tsx diff --git a/src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.test.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx similarity index 97% rename from src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.test.tsx rename to src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx index 2a384aa540851..0fccf8bd6f5a4 100644 --- a/src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.test.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.test.tsx @@ -20,8 +20,9 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { SearchBar } from './search_bar'; +import { IndexPattern } from 'ui/index_patterns'; -jest.mock('../../../../data/public', () => { +jest.mock('../../../../../data/public', () => { return { FilterBar: () =>

, QueryBar: () =>
, @@ -60,7 +61,7 @@ const mockIndexPattern = { searchable: true, }, ], -}; +} as IndexPattern; const kqlQuery = { query: 'response:200', diff --git a/src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.tsx b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx similarity index 90% rename from src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.tsx rename to src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx index cf6ebc947c1e9..4edd82d2ebe3c 100644 --- a/src/legacy/core_plugins/kibana_react/public/search_bar/components/search_bar.tsx +++ b/src/legacy/core_plugins/data/public/search/search_bar/components/search_bar.tsx @@ -21,12 +21,13 @@ import { EuiFilterButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Filter } from '@kbn/es-query'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import { Storage } from 'ui/storage'; -import { IndexPattern, Query, QueryBar, FilterBar } from '../../../../data/public'; +import { IndexPattern, Query, QueryBar, FilterBar } from '../../../../../data/public'; interface DateRange { from: string; @@ -107,21 +108,27 @@ class SearchBarUI extends Component { } private getFilterTriggerButton() { - const filtersAppliedText = this.props.intl.formatMessage({ - id: 'kibana_react.search.searchBar.filtersButtonFiltersAppliedTitle', - defaultMessage: 'filters applied.', - }); + const filterCount = this.getFilterLength(); + const filtersAppliedText = this.props.intl.formatMessage( + { + id: 'data.search.searchBar.searchBar.filtersButtonFiltersAppliedTitle', + defaultMessage: + '{filterCount} {filterCount, plural, one {filter} other {filters}} applied.', + }, + { + filterCount, + } + ); const clickToShowOrHideText = this.state.isFiltersVisible ? this.props.intl.formatMessage({ - id: 'kibana_react.search.searchBar.filtersButtonClickToShowTitle', + id: 'data.search.searchBar.searchBar.filtersButtonClickToShowTitle', defaultMessage: 'Select to hide', }) : this.props.intl.formatMessage({ - id: 'kibana_react.search.searchBar.filtersButtonClickToHideTitle', + id: 'data.search.searchBar.searchBar.filtersButtonClickToHideTitle', defaultMessage: 'Select to show', }); - const filterCount = this.getFilterLength(); return ( { aria-expanded={!!this.state.isFiltersVisible} title={`${filterCount ? filtersAppliedText : ''} ${clickToShowOrHideText}`} > - Filters + {i18n.translate('data.search.searchBar.searchBar.filtersButtonLabel', { + defaultMessage: 'Filters', + description: 'The noun "filter" in plural.', + })} ); } diff --git a/src/legacy/core_plugins/kibana_react/public/search_bar/index.tsx b/src/legacy/core_plugins/data/public/search/search_bar/index.tsx similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/search_bar/index.tsx rename to src/legacy/core_plugins/data/public/search/search_bar/index.tsx diff --git a/src/legacy/core_plugins/data/public/search/search_service.ts b/src/legacy/core_plugins/data/public/search/search_service.ts new file mode 100644 index 0000000000000..efebe89180ced --- /dev/null +++ b/src/legacy/core_plugins/data/public/search/search_service.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Search Service + * @internal + */ + +export class SearchService { + public setup() { + return {}; + } + + public stop() {} +} + +/** @public */ + +export type SearchSetup = ReturnType; diff --git a/src/legacy/core_plugins/data/public/setup.ts b/src/legacy/core_plugins/data/public/setup.ts index 6329d2931ecdb..a99a2a4d06efe 100644 --- a/src/legacy/core_plugins/data/public/setup.ts +++ b/src/legacy/core_plugins/data/public/setup.ts @@ -17,11 +17,7 @@ * under the License. */ -import { DataPlugin } from './index'; +import { setup } from './legacy'; -/** - * We export data here so that users importing from 'plugins/data' - * will automatically receive the response value of the `setup` contract, mimicking - * the data that will eventually be injected by the new platform. - */ -export const data = new DataPlugin().setup(); +// for backwards compatibility with 7.3 +export const data = setup; diff --git a/src/legacy/core_plugins/data/public/filter/apply_filters/directive.html b/src/legacy/core_plugins/data/public/shim/apply_filter_directive.html similarity index 100% rename from src/legacy/core_plugins/data/public/filter/apply_filters/directive.html rename to src/legacy/core_plugins/data/public/shim/apply_filter_directive.html diff --git a/src/legacy/ui/public/vis/draggable/draggable_handle.js b/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts similarity index 60% rename from src/legacy/ui/public/vis/draggable/draggable_handle.js rename to src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts index ce8815c2a749f..4289d56b33c60 100644 --- a/src/legacy/ui/public/vis/draggable/draggable_handle.js +++ b/src/legacy/core_plugins/data/public/shim/legacy_dependencies_plugin.ts @@ -17,17 +17,25 @@ * under the License. */ -import { uiModules } from '../../modules'; +import chrome from 'ui/chrome'; +import { CoreStart, Plugin } from '../../../../../../src/core/public'; +import { initLegacyModule } from './legacy_module'; + +/** @internal */ +export interface LegacyDependenciesPluginSetup { + savedObjectsClient: any; +} + +export class LegacyDependenciesPlugin implements Plugin { + public setup() { + initLegacyModule(); -uiModules - .get('kibana') - .directive('draggableHandle', function () { return { - restrict: 'A', - require: '^draggableItem', - link($scope, $el, attr, ctrl) { - ctrl.registerHandle($el); - $el.addClass('gu-handle'); - } - }; - }); + savedObjectsClient: chrome.getSavedObjectsClient(), + } as LegacyDependenciesPluginSetup; + } + + public start(core: CoreStart) { + // nothing to do here yet + } +} diff --git a/src/legacy/core_plugins/data/public/shim/legacy_module.ts b/src/legacy/core_plugins/data/public/shim/legacy_module.ts new file mode 100644 index 0000000000000..46849196e9707 --- /dev/null +++ b/src/legacy/core_plugins/data/public/shim/legacy_module.ts @@ -0,0 +1,71 @@ +/* + * 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 { once } from 'lodash'; + +import { wrapInI18nContext } from 'ui/i18n'; +import { Filter } from '@kbn/es-query'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +import { IndexPatterns } from 'src/legacy/core_plugins/data/public'; +import { FilterBar, ApplyFiltersPopover } from '../filter'; +import template from './apply_filter_directive.html'; + +// @ts-ignore +import { mapAndFlattenFilters } from '../filter/filter_manager/lib/map_and_flatten_filters'; +// @ts-ignore + +/** @internal */ +export const initLegacyModule = once((): void => { + uiModules + .get('app/kibana') + .directive('filterBar', (reactDirective: any) => { + return reactDirective(wrapInI18nContext(FilterBar)); + }) + .directive('applyFiltersPopoverComponent', (reactDirective: any) => { + return reactDirective(wrapInI18nContext(ApplyFiltersPopover)); + }) + .directive('applyFiltersPopover', (indexPatterns: IndexPatterns) => { + return { + template, + restrict: 'E', + scope: { + filters: '=', + onCancel: '=', + onSubmit: '=', + }, + link($scope: any) { + $scope.state = {}; + + // Each time the new filters change we want to rebuild (not just re-render) the "apply filters" + // popover, because it has to reset its state whenever the new filters change. Setting a `key` + // property on the component accomplishes this due to how React handles the `key` property. + $scope.$watch('filters', (filters: any) => { + mapAndFlattenFilters(indexPatterns, filters).then((mappedFilters: Filter[]) => { + $scope.state = { + filters: mappedFilters, + key: Date.now(), + }; + }); + }); + }, + }; + }); +}); diff --git a/src/legacy/core_plugins/embeddable_api/public/ui_capabilities.test.mocks.ts b/src/legacy/core_plugins/embeddable_api/public/ui_capabilities.test.mocks.ts index b7e4a12fac247..69163522c2d99 100644 --- a/src/legacy/core_plugins/embeddable_api/public/ui_capabilities.test.mocks.ts +++ b/src/legacy/core_plugins/embeddable_api/public/ui_capabilities.test.mocks.ts @@ -24,3 +24,5 @@ jest.doMock('ui/capabilities', () => ({ }, }, })); + +jest.mock('ui/new_platform'); diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/options_tab.test.js.snap b/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/options_tab.test.js.snap index bc333351b307d..f78607e9cfa0a 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/options_tab.test.js.snap +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/__snapshots__/options_tab.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders OptionsTab 1`] = ` +exports[`OptionsTab should renders OptionsTab 1`] = ` { - return await this.props.scope.vis.API.indexPatterns.get(indexPatternId); + return await this.props.vis.API.indexPatterns.get(indexPatternId); } - setVisParam(paramName, paramValue) { - const params = _.cloneDeep(this.props.editorState.params); - params[paramName] = paramValue; - this.props.stageEditorParams(params); - } + onChange = value => this.props.setValue('controls', value) handleLabelChange = (controlIndex, evt) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.label = evt.target.value; - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } handleIndexPatternChange = (controlIndex, indexPatternId) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.indexPattern = indexPatternId; updatedControl.fieldName = ''; - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } handleFieldNameChange = (controlIndex, fieldName) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.fieldName = fieldName; - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } handleCheckboxOptionChange = (controlIndex, optionName, evt) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.options[optionName] = evt.target.checked; - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } handleNumberOptionChange = (controlIndex, optionName, evt) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.options[optionName] = parseFloat(evt.target.value); - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } handleRemoveControl = (controlIndex) => { - this.setVisParam('controls', removeControl(this.props.editorState.params.controls, controlIndex)); + this.onChange(removeControl(this.props.stateParams.controls, controlIndex)); } moveControl = (controlIndex, direction) => { - this.setVisParam('controls', moveControl(this.props.editorState.params.controls, controlIndex, direction)); + this.onChange(moveControl(this.props.stateParams.controls, controlIndex, direction)); } handleAddControl = () => { - this.setVisParam('controls', addControl(this.props.editorState.params.controls, newControl(this.state.type))); + this.onChange(addControl(this.props.stateParams.controls, newControl(this.state.type))); } handleParentChange = (controlIndex, evt) => { - const updatedControl = this.props.editorState.params.controls[controlIndex]; + const updatedControl = this.props.stateParams.controls[controlIndex]; updatedControl.parent = evt.target.value; - this.setVisParam('controls', setControl(this.props.editorState.params.controls, controlIndex, updatedControl)); + this.onChange(setControl(this.props.stateParams.controls, controlIndex, updatedControl)); } renderControls() { - const lineageMap = getLineageMap(this.props.editorState.params.controls); - return this.props.editorState.params.controls.map((controlParams, controlIndex) => { + const lineageMap = getLineageMap(this.props.stateParams.controls); + return this.props.stateParams.controls.map((controlParams, controlIndex) => { const parentCandidates = getParentCandidates( - this.props.editorState.params.controls, + this.props.stateParams.controls, controlParams.id, lineageMap); return ( @@ -187,8 +182,8 @@ class ControlsTabUi extends Component { } ControlsTabUi.propTypes = { - scope: PropTypes.object.isRequired, - stageEditorParams: PropTypes.func.isRequired + vis: PropTypes.object.isRequired, + setValue: PropTypes.func.isRequired }; export const ControlsTab = injectI18n(ControlsTabUi); diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js b/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js index add17262860a2..4a9433d4cd698 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/controls_tab.test.js @@ -18,7 +18,6 @@ */ import React from 'react'; -import sinon from 'sinon'; import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { getIndexPatternMock } from './__tests__/get_index_pattern_mock'; @@ -29,15 +28,17 @@ import { const indexPatternsMock = { get: getIndexPatternMock }; -const scopeMock = { - vis: { - API: { - indexPatterns: indexPatternsMock +let props; + +beforeEach(() => { + props = { + vis: { + API: { + indexPatterns: indexPatternsMock + }, }, - }, - editorState: { - params: { - 'controls': [ + stateParams: { + controls: [ { 'id': '1', 'indexPattern': 'indexPattern1', @@ -62,138 +63,111 @@ const scopeMock = { } } ] - } - } -}; -let stageEditorParams; - -beforeEach(() => { - stageEditorParams = sinon.spy(); + }, + setValue: jest.fn(), + }; }); test('renders ControlsTab', () => { - const component = shallowWithIntl(); - expect(component).toMatchSnapshot(); // eslint-disable-line + const component = shallowWithIntl(); + + expect(component).toMatchSnapshot(); }); describe('behavior', () => { test('add control button', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); + findTestSubject(component, 'inputControlEditorAddBtn').simulate('click'); - // Use custom match function since control.id is dynamically generated and never the same. - sinon.assert.calledWith(stageEditorParams, sinon.match((newParams) => { - if (newParams.controls.length !== 3) { - return false; - } - return true; - }, 'control not added to editorState.params')); + + // // Use custom match function since control.id is dynamically generated and never the same. + expect(props.setValue).toHaveBeenCalledWith( + 'controls', + expect.arrayContaining(props.stateParams.controls) + ); + expect(props.setValue.mock.calls[0][1].length).toEqual(3); }); test('remove control button', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); findTestSubject(component, 'inputControlEditorRemoveControl0').simulate('click'); - const expectedParams = { - 'controls': [ - { - 'id': '2', - 'indexPattern': 'indexPattern1', - 'fieldName': 'numberField', - 'label': '', - 'type': 'range', - 'options': { - 'step': 1 - } - } - ] - }; - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); + const expectedParams = ['controls', [{ + 'id': '2', + 'indexPattern': 'indexPattern1', + 'fieldName': 'numberField', + 'label': '', + 'type': 'range', + 'options': { + 'step': 1 + } + }]]; + + expect(props.setValue).toHaveBeenCalledWith(...expectedParams); }); test('move down control button', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); findTestSubject(component, 'inputControlEditorMoveDownControl0').simulate('click'); - const expectedParams = { - 'controls': [ - { - 'id': '2', - 'indexPattern': 'indexPattern1', - 'fieldName': 'numberField', - 'label': '', - 'type': 'range', - 'options': { - 'step': 1 - } - }, - { - 'id': '1', - 'indexPattern': 'indexPattern1', - 'fieldName': 'keywordField', - 'label': 'custom label', - 'type': 'list', - 'options': { - 'type': 'terms', - 'multiselect': true, - 'size': 5, - 'order': 'desc' - } + const expectedParams = ['controls', [ + { + 'id': '2', + 'indexPattern': 'indexPattern1', + 'fieldName': 'numberField', + 'label': '', + 'type': 'range', + 'options': { + 'step': 1 } - ] - }; - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); + }, + { + 'id': '1', + 'indexPattern': 'indexPattern1', + 'fieldName': 'keywordField', + 'label': 'custom label', + 'type': 'list', + 'options': { + 'type': 'terms', + 'multiselect': true, + 'size': 5, + 'order': 'desc' + } + } + ]]; + + expect(props.setValue).toHaveBeenCalledWith(...expectedParams); }); test('move up control button', () => { - const component = mountWithIntl(); + const component = mountWithIntl(); findTestSubject(component, 'inputControlEditorMoveUpControl1').simulate('click'); - const expectedParams = { - 'controls': [ - { - 'id': '2', - 'indexPattern': 'indexPattern1', - 'fieldName': 'numberField', - 'label': '', - 'type': 'range', - 'options': { - 'step': 1 - } - }, - { - 'id': '1', - 'indexPattern': 'indexPattern1', - 'fieldName': 'keywordField', - 'label': 'custom label', - 'type': 'list', - 'options': { - 'type': 'terms', - 'multiselect': true, - 'size': 5, - 'order': 'desc' - } + const expectedParams = ['controls', [ + { + 'id': '2', + 'indexPattern': 'indexPattern1', + 'fieldName': 'numberField', + 'label': '', + 'type': 'range', + 'options': { + 'step': 1 } - ] - }; - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); + }, + { + 'id': '1', + 'indexPattern': 'indexPattern1', + 'fieldName': 'keywordField', + 'label': 'custom label', + 'type': 'list', + 'options': { + 'type': 'terms', + 'multiselect': true, + 'size': 5, + 'order': 'desc' + } + } + ]]; + + expect(props.setValue).toHaveBeenCalledWith(...expectedParams); }); }); diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.js b/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.js index d63ef66117854..0045ec2508b95 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.js +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.js @@ -17,7 +17,6 @@ * under the License. */ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; @@ -31,22 +30,16 @@ import { FormattedMessage } from '@kbn/i18n/react'; export class OptionsTab extends Component { - setVisParam = (paramName, paramValue) => { - const params = _.cloneDeep(this.props.editorState.params); - params[paramName] = paramValue; - this.props.stageEditorParams(params); - } - handleUpdateFiltersChange = (evt) => { - this.setVisParam('updateFiltersOnChange', evt.target.checked); + this.props.setValue('updateFiltersOnChange', evt.target.checked); } handleUseTimeFilter = (evt) => { - this.setVisParam('useTimeFilter', evt.target.checked); + this.props.setValue('useTimeFilter', evt.target.checked); } handlePinFilters = (evt) => { - this.setVisParam('pinFilters', evt.target.checked); + this.props.setValue('pinFilters', evt.target.checked); } render() { @@ -60,7 +53,7 @@ export class OptionsTab extends Component { id="inputControl.editor.optionsTab.updateFilterLabel" defaultMessage="Update Kibana filters on each change" />} - checked={this.props.editorState.params.updateFiltersOnChange} + checked={this.props.stateParams.updateFiltersOnChange} onChange={this.handleUpdateFiltersChange} data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox" /> @@ -74,7 +67,7 @@ export class OptionsTab extends Component { id="inputControl.editor.optionsTab.useTimeFilterLabel" defaultMessage="Use time filter" />} - checked={this.props.editorState.params.useTimeFilter} + checked={this.props.stateParams.useTimeFilter} onChange={this.handleUseTimeFilter} data-test-subj="inputControlEditorUseTimeFilterCheckbox" /> @@ -88,7 +81,7 @@ export class OptionsTab extends Component { id="inputControl.editor.optionsTab.pinFiltersLabel" defaultMessage="Pin filters for all applications" />} - checked={this.props.editorState.params.pinFilters} + checked={this.props.stateParams.pinFilters} onChange={this.handlePinFilters} data-test-subj="inputControlEditorPinFiltersCheckbox" /> @@ -99,6 +92,6 @@ export class OptionsTab extends Component { } OptionsTab.propTypes = { - scope: PropTypes.object.isRequired, - stageEditorParams: PropTypes.func.isRequired + vis: PropTypes.object.isRequired, + setValue: PropTypes.func.isRequired }; diff --git a/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.test.js b/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.test.js index ba4d43bea133f..39f5f6a50a5a6 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.test.js +++ b/src/legacy/core_plugins/input_control_vis/public/components/editor/options_tab.test.js @@ -18,7 +18,6 @@ */ import React from 'react'; -import sinon from 'sinon'; import { shallow } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; @@ -26,70 +25,51 @@ import { OptionsTab, } from './options_tab'; -const scopeMock = { - editorState: { - params: { - updateFiltersOnChange: false, - useTimeFilter: false - } - } -}; -let stageEditorParams; +describe('OptionsTab', () => { + let props; -beforeEach(() => { - stageEditorParams = sinon.spy(); -}); + beforeEach(() => { + props = { + vis: {}, + stateParams: { + updateFiltersOnChange: false, + useTimeFilter: false + }, + setValue: jest.fn() + }; + }); -test('renders OptionsTab', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); // eslint-disable-line -}); + it('should renders OptionsTab', () => { + const component = shallow(); -test('updateFiltersOnChange', () => { - const component = mountWithIntl(); - const checkbox = component.find('[data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox"] input[type="checkbox"]'); - checkbox.simulate('change', { target: { checked: true } }); - const expectedParams = { - updateFiltersOnChange: true - }; - sinon.assert.calledOnce(stageEditorParams); - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); -}); + expect(component).toMatchSnapshot(); + }); -test('useTimeFilter', () => { - const component = mountWithIntl(); - const checkbox = component.find('[data-test-subj="inputControlEditorUseTimeFilterCheckbox"] input[type="checkbox"]'); - checkbox.simulate('change', { target: { checked: true } }); - const expectedParams = { - useTimeFilter: true - }; - sinon.assert.calledOnce(stageEditorParams); - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); -}); + it('should update updateFiltersOnChange', () => { + const component = mountWithIntl(); + const checkbox = component.find('[data-test-subj="inputControlEditorUpdateFiltersOnChangeCheckbox"] input[type="checkbox"]'); + checkbox.simulate('change', { target: { checked: true } }); + + expect(props.setValue).toHaveBeenCalledTimes(1); + expect(props.setValue).toHaveBeenCalledWith('updateFiltersOnChange', true); + }); + + it('should update useTimeFilter', () => { + const component = mountWithIntl(); + const checkbox = component.find('[data-test-subj="inputControlEditorUseTimeFilterCheckbox"] input[type="checkbox"]'); + checkbox.simulate('change', { target: { checked: true } }); + + expect(props.setValue).toHaveBeenCalledTimes(1); + expect(props.setValue).toHaveBeenCalledWith('useTimeFilter', true); + }); + + it('should update pinFilters', () => { + const component = mountWithIntl(); + const checkbox = component.find('[data-test-subj="inputControlEditorPinFiltersCheckbox"] input[type="checkbox"]'); + checkbox.simulate('change', { target: { checked: true } }); + + expect(props.setValue).toHaveBeenCalledTimes(1); + expect(props.setValue).toHaveBeenCalledWith('pinFilters', true); + }); -test('pinFilters', () => { - const component = mountWithIntl(); - const checkbox = component.find('[data-test-subj="inputControlEditorPinFiltersCheckbox"] input[type="checkbox"]'); - checkbox.simulate('change', { target: { checked: true } }); - const expectedParams = { - pinFilters: true - }; - sinon.assert.calledOnce(stageEditorParams); - sinon.assert.calledWith(stageEditorParams, sinon.match(expectedParams)); }); diff --git a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/range_control.test.js.snap b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/range_control.test.js.snap index efd58cdaff54b..2e6c1058e160d 100644 --- a/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/range_control.test.js.snap +++ b/src/legacy/core_plugins/input_control_vis/public/components/vis/__snapshots__/range_control.test.js.snap @@ -9,7 +9,9 @@ exports[`disabled 1`] = ` > @@ -24,6 +26,8 @@ exports[`renders RangeControl 1`] = ` > - +

+ +

+ + } + iconColor="subdued" + title={ +

+ +

+ } > - -
-
- -

- -

- - } - iconColor="subdued" - title={ -

- -

- } + +

+ + No data available + +

+
+
+ + +
- - + - -

- - No data available - -

-
- -
- - -
-

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

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

- -
-
- - + + + +
+ `; @@ -328,91 +312,85 @@ exports[`Inspector Data View component should render loading state 1`] = ` } title="Test Data" > - - -
-
- + + + + + + + +
+ + +
- -
+ - - - - - - - - - -
- - -
-

- - Gathering data - -

-
-
-
- + Gathering data +
+

- +
- +
-
- - + +
+ `; diff --git a/src/legacy/core_plugins/inspector_views/public/data/data_view.js b/src/legacy/core_plugins/inspector_views/public/data/data_view.js index 32c340f110fc1..265e4dde29636 100644 --- a/src/legacy/core_plugins/inspector_views/public/data/data_view.js +++ b/src/legacy/core_plugins/inspector_views/public/data/data_view.js @@ -29,8 +29,6 @@ import { EuiText, } from '@elastic/eui'; -import { InspectorView } from 'ui/inspector'; - import { DataTableFormat, } from './data_table'; @@ -102,54 +100,51 @@ class DataViewComponent extends Component { renderNoData() { return ( - - + + + + } + body={ + +

- - } - body={ - -

- -

-
- } - /> -
+

+ + } + /> ); } renderLoading() { return ( - - - - - - - -

- -

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

+ +

+
+
+
+
); } @@ -161,13 +156,11 @@ class DataViewComponent extends Component { } return ( - - - + ); } } diff --git a/src/legacy/core_plugins/inspector_views/public/data/data_view.test.js b/src/legacy/core_plugins/inspector_views/public/data/data_view.test.js index 29d536d60e83e..c616a7504d3e9 100644 --- a/src/legacy/core_plugins/inspector_views/public/data/data_view.test.js +++ b/src/legacy/core_plugins/inspector_views/public/data/data_view.test.js @@ -22,6 +22,7 @@ import { DataView } from './data_view'; import { DataAdapter } from 'ui/inspector/adapters'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; +jest.mock('ui/new_platform'); jest.mock('./lib/export_csv', () => ({ exportAsCsv: jest.fn(), })); diff --git a/src/legacy/core_plugins/inspector_views/public/requests/requests_view.js b/src/legacy/core_plugins/inspector_views/public/requests/requests_view.js index 2ce47f62ef956..a949b2ebe6ea1 100644 --- a/src/legacy/core_plugins/inspector_views/public/requests/requests_view.js +++ b/src/legacy/core_plugins/inspector_views/public/requests/requests_view.js @@ -26,7 +26,6 @@ import { EuiTextColor, } from '@elastic/eui'; -import { InspectorView } from 'ui/inspector'; import { RequestStatus } from 'ui/inspector/adapters'; import { RequestSelector } from './request_selector'; @@ -68,36 +67,34 @@ class RequestsViewComponent extends Component { renderEmptyRequests() { return ( - - + + + + } + body={ + +

- - } - body={ - -

- -

-

- -

-
- } - /> -
+

+

+ +

+ + } + /> ); } @@ -111,7 +108,7 @@ class RequestsViewComponent extends Component { ).length; return ( - + <>

} - + ); } } diff --git a/src/legacy/core_plugins/kbn_doc_views/public/__tests__/doc_views.js b/src/legacy/core_plugins/kbn_doc_views/public/__tests__/doc_views.js index 298cddede6cbe..dfd0b39bb20a1 100644 --- a/src/legacy/core_plugins/kbn_doc_views/public/__tests__/doc_views.js +++ b/src/legacy/core_plugins/kbn_doc_views/public/__tests__/doc_views.js @@ -22,7 +22,7 @@ import _ from 'lodash'; import sinon from 'sinon'; import expect from '@kbn/expect'; import ngMock from 'ng_mock'; -import 'ui/render_directive'; +import 'ui/directives/render_directive'; import '../views/table'; import { DocViewsRegistryProvider } from 'ui/registry/doc_views'; import StubbedLogstashIndexPattern from 'fixtures/stubbed_logstash_index_pattern'; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/basic_options.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/basic_options.tsx new file mode 100644 index 0000000000000..559c3ee0b21f8 --- /dev/null +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/basic_options.tsx @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { VisOptionsProps } from 'ui/vis/editors/default'; +import { SwitchOption } from './switch'; +import { SelectOption } from './select'; + +function BasicOptions({ stateParams, setValue, vis }: VisOptionsProps) { + return ( + <> + + + + ); +} + +export { BasicOptions }; diff --git a/src/legacy/ui/public/vis/editors/default/agg_add.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/select.tsx similarity index 54% rename from src/legacy/ui/public/vis/editors/default/agg_add.js rename to src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/select.tsx index a60e9146b01a2..7822c3ca72723 100644 --- a/src/legacy/ui/public/vis/editors/default/agg_add.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/select.tsx @@ -17,18 +17,29 @@ * under the License. */ -import { uiModules } from '../../../modules'; -import { DefaultEditorAggAdd } from './components/default_editor_agg_add'; -import { wrapInI18nContext } from 'ui/i18n'; +import React from 'react'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { VisOptionsSetValue } from 'ui/vis/editors/default'; -uiModules - .get('kibana') - .directive('visEditorAggAdd', reactDirective => - reactDirective(wrapInI18nContext(DefaultEditorAggAdd), [ - ['group', { watchDepth: 'collection' }], - ['schemas', { watchDepth: 'collection' }], - ['stats', { watchDepth: 'reference' }], - 'groupName', - 'addSchema' - ]) +interface SelectOptionProps { + label: string; + options: Array<{ value: string; text: string }>; + paramName: string; + value: string; + setValue: VisOptionsSetValue; +} + +function SelectOption({ label, options, paramName, value, setValue }: SelectOptionProps) { + return ( + + setValue(paramName, ev.target.value)} + fullWidth={true} + /> + ); +} + +export { SelectOption }; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/switch.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/switch.tsx new file mode 100644 index 0000000000000..7d43df81a6e4d --- /dev/null +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/switch.tsx @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; + +import { EuiFormRow, EuiSwitch, EuiToolTip } from '@elastic/eui'; +import { VisOptionsSetValue } from 'ui/vis/editors/default'; + +interface SwitchOptionProps { + dataTestSubj?: string; + label?: string; + tooltip?: string; + disabled?: boolean; + value?: boolean; + paramName: string; + setValue: VisOptionsSetValue; +} + +function SwitchOption({ + dataTestSubj, + tooltip, + label, + disabled, + paramName, + value = false, + setValue, +}: SwitchOptionProps) { + return ( + + + setValue(paramName, ev.target.checked)} + /> + + + ); +} + +export { SwitchOption }; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/truncate_labels.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/truncate_labels.tsx new file mode 100644 index 0000000000000..3778b2b7e8cd9 --- /dev/null +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/truncate_labels.tsx @@ -0,0 +1,47 @@ +/* + * 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, { ChangeEvent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFormRow, EuiFieldNumber } from '@elastic/eui'; +import { VisOptionsSetValue } from 'ui/vis/editors/default'; + +interface TruncateLabelsOptionProps { + value: number | null; + setValue: VisOptionsSetValue; +} + +function TruncateLabelsOption({ value, setValue }: TruncateLabelsOptionProps) { + const onChange = (ev: ChangeEvent) => + setValue('truncate', ev.target.value === '' ? null : parseFloat(ev.target.value)); + + return ( + + + + ); +} + +export { TruncateLabelsOption }; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/vislib_basic_options.html b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/vislib_basic_options.html index 922b7b75c645d..51bddd6c39eab 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/vislib_basic_options.html +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/controls/vislib_basic_options.html @@ -4,7 +4,7 @@ class="visEditorSidebar__formLabel" for="visualizeBasicSettingsLegendPosition" i18n-id="kbnVislibVisTypes.controls.vislibBasicOptions.legendPositionLabel" - i18n-default-message="Legend Position" + i18n-default-message="Legend position" >

diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.html b/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.html deleted file mode 100644 index cd1a6f85d27f8..0000000000000 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.html +++ /dev/null @@ -1,92 +0,0 @@ -
-
-
-
-
- -
- -
- -
-
-
- - -
- - -
-
-
-
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.tsx new file mode 100644 index 0000000000000..c37c5f4574df2 --- /dev/null +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/editors/pie.tsx @@ -0,0 +1,99 @@ +/* + * 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 { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { VisOptionsProps, VisOptionsSetValue } from 'ui/vis/editors/default'; +import { BasicOptions } from '../controls/basic_options'; +import { SwitchOption } from '../controls/switch'; +import { TruncateLabelsOption } from '../controls/truncate_labels'; + +function PieOptions(props: VisOptionsProps) { + const { stateParams, setValue } = props; + const setLabels: VisOptionsSetValue = (paramName, value) => + setValue('labels', { ...stateParams.labels, [paramName]: value }); + + return ( + <> + + +
+ +
+
+ + + +
+ + + + + +
+ +
+
+ + + + + +
+ + ); +} + +export { PieOptions }; diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/pie.js b/src/legacy/core_plugins/kbn_vislib_vis_types/public/pie.js index e8b8a969bfb34..85727989f0982 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/pie.js +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/pie.js @@ -20,7 +20,7 @@ import { VisFactoryProvider } from 'ui/vis/vis_factory'; import { i18n } from '@kbn/i18n'; import { Schemas } from 'ui/vis/editors/default/schemas'; -import pieTemplate from './editors/pie.html'; +import { PieOptions } from './editors/pie'; export default function HistogramVisType(Private) { const VisFactory = Private(VisFactoryProvider); @@ -50,21 +50,34 @@ export default function HistogramVisType(Private) { }, editorConfig: { collections: { - legendPositions: [{ - value: 'left', - text: 'left', - }, { - value: 'right', - text: 'right', - }, { - value: 'top', - text: 'top', - }, { - value: 'bottom', - text: 'bottom', - }], + legendPositions: [ + { + text: i18n.translate('kbnVislibVisTypes.pie.editorConfig.legendPositions.leftText', { + defaultMessage: 'Left' + }), + value: 'left' + }, + { + text: i18n.translate('kbnVislibVisTypes.pie.editorConfig.legendPositions.rightText', { + defaultMessage: 'Right' + }), + value: 'right' + }, + { + text: i18n.translate('kbnVislibVisTypes.pie.editorConfig.legendPositions.topText', { + defaultMessage: 'Top' + }), + value: 'top' + }, + { + text: i18n.translate('kbnVislibVisTypes.pie.editorConfig.legendPositions.bottomText', { + defaultMessage: 'Bottom' + }), + value: 'bottom' + }, + ], }, - optionsTemplate: pieTemplate, + optionsTemplate: PieOptions, schemas: new Schemas([ { group: 'metrics', diff --git a/src/legacy/core_plugins/kibana/common/tutorials/functionbeat_instructions.js b/src/legacy/core_plugins/kibana/common/tutorials/functionbeat_instructions.js index 8c1135ce12154..a62c9707026b8 100644 --- a/src/legacy/core_plugins/kibana/common/tutorials/functionbeat_instructions.js +++ b/src/legacy/core_plugins/kibana/common/tutorials/functionbeat_instructions.js @@ -88,7 +88,7 @@ Kibana index pattern. It is normally safe to omit this command.', }), commands: [ './functionbeat setup', - './functionbeat deploy fn_cloudwatch_logs', + './functionbeat deploy fn-cloudwatch-logs', ] }, WINDOWS: { @@ -102,7 +102,7 @@ Kibana index pattern. It is normally safe to omit this command.', }), commands: [ '.\\functionbeat.exe setup', - '.\\functionbeat.exe deploy fn_cloudwatch_logs', + '.\\functionbeat.exe deploy fn-cloudwatch-logs', ], }, }, @@ -217,7 +217,7 @@ export function functionbeatEnableInstructions() { }); const defaultCommands = [ 'functionbeat.provider.aws.functions:', - ' - name: fn_cloudwatch_logs', + ' - name: fn-cloudwatch-logs', ' enabled: true', ' type: cloudwatch_logs', ' triggers:', diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 2ff4d1bdf9d44..0b3aedee7a7a1 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -235,9 +235,22 @@ export default function (kibana) { }, injectDefaultVars(server, options) { + const mapConfig = server.config().get('map'); + const tilemap = mapConfig.tilemap; + return { kbnIndex: options.index, kbnBaseUrl, + + // required on all pages due to hacks that use these values + mapConfig, + tilemapsConfig: { + deprecated: { + // If url is set, old settings must be used for backward compatibility + isOverridden: typeof tilemap.url === 'string' && tilemap.url !== '', + config: tilemap, + }, + }, }; }, diff --git a/src/legacy/core_plugins/kibana/inject_vars.js b/src/legacy/core_plugins/kibana/inject_vars.js index c91d1b7214cdb..e6b4ff014209a 100644 --- a/src/legacy/core_plugins/kibana/inject_vars.js +++ b/src/legacy/core_plugins/kibana/inject_vars.js @@ -21,10 +21,6 @@ export function injectVars(server) { const serverConfig = server.config(); const mapConfig = serverConfig.get('map'); const regionmap = mapConfig.regionmap; - const tilemap = mapConfig.tilemap; - - // If url is set, old settings must be used for backward compatibility - const isOverridden = typeof tilemap.url === 'string' && tilemap.url !== ''; // Get types that are import and exportable, by default yes unless isImportableAndExportable is set to false const { types: allTypes } = server.savedObjects; @@ -37,15 +33,8 @@ export function injectVars(server) { kbnDefaultAppId: serverConfig.get('kibana.defaultAppId'), disableWelcomeScreen: serverConfig.get('kibana.disableWelcomeScreen'), regionmapsConfig: regionmap, - mapConfig: mapConfig, importAndExportableTypes, autocompleteTerminateAfter: serverConfig.get('kibana.autocompleteTerminateAfter'), autocompleteTimeout: serverConfig.get('kibana.autocompleteTimeout'), - tilemapsConfig: { - deprecated: { - isOverridden: isOverridden, - config: tilemap, - }, - }, }; } diff --git a/src/legacy/core_plugins/kibana/public/context/api/context.ts b/src/legacy/core_plugins/kibana/public/context/api/context.ts index 28bf73ecfd6dd..be99fa63b07cc 100644 --- a/src/legacy/core_plugins/kibana/public/context/api/context.ts +++ b/src/legacy/core_plugins/kibana/public/context/api/context.ts @@ -20,8 +20,8 @@ // @ts-ignore import { SearchSourceProvider, SearchSource } from 'ui/courier'; import { IPrivate } from 'ui/private'; -import { IndexPatternEnhanced, IndexPatternGetProvider } from 'ui/index_patterns/_index_pattern'; import { Filter } from '@kbn/es-query'; +import { IndexPatterns, IndexPattern } from 'ui/index_patterns'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { extractNanos, convertIsoToMillis } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; @@ -42,7 +42,7 @@ const DAY_MILLIS = 24 * 60 * 60 * 1000; // look from 1 day up to 10000 days into the past and future const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map(days => days * DAY_MILLIS); -function fetchContextProvider(indexPatterns: IndexPatternGetProvider, Private: IPrivate) { +function fetchContextProvider(indexPatterns: IndexPatterns, Private: IPrivate) { const SearchSourcePrivate: any = Private(SearchSourceProvider); return { @@ -112,7 +112,7 @@ function fetchContextProvider(indexPatterns: IndexPatternGetProvider, Private: I return documents; } - async function createSearchSource(indexPattern: IndexPatternEnhanced, filters: Filter[]) { + async function createSearchSource(indexPattern: IndexPattern, filters: Filter[]) { return new SearchSourcePrivate() .setParent(false) .setField('index', indexPattern) diff --git a/src/legacy/core_plugins/kibana/public/context/app.js b/src/legacy/core_plugins/kibana/public/context/app.js index 243a58a63b457..8a7de39d402c7 100644 --- a/src/legacy/core_plugins/kibana/public/context/app.js +++ b/src/legacy/core_plugins/kibana/public/context/app.js @@ -38,8 +38,8 @@ import { } from './query'; import { timefilter } from 'ui/timefilter'; -import { data } from 'plugins/data/setup'; -data.filter.loadLegacyDirectives(); +// load directives +import '../../../data/public/legacy'; const module = uiModules.get('apps/context', [ 'elasticsearch', diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts index 81a894985c57f..eac9c18670c89 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_state.test.ts @@ -49,6 +49,7 @@ describe('DashboardState', function() { isAutoRefreshSelectorEnabled: true, isTimeRangeSelectorEnabled: true, }; + function initDashboardState() { dashboardState = new DashboardStateManager({ savedDashboard, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.js b/src/legacy/core_plugins/kibana/public/dashboard/index.js index 6c97d4f86478e..3eff43db2fd9d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.js @@ -39,8 +39,8 @@ import { DashboardListing, EMPTY_FILTER } from './listing/dashboard_listing'; import { uiModules } from 'ui/modules'; import 'ui/capabilities/route_setup'; -import { data } from 'plugins/data/setup'; -data.filter.loadLegacyDirectives(); +// load directives +import '../../../data/public'; const app = uiModules.get('app/dashboard', [ 'ngRoute', diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts index f18a1b29f7181..2c9c6ca65a92e 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts @@ -34,6 +34,8 @@ jest.mock( { virtual: true } ); +jest.mock('ui/new_platform'); + import { migratePanelsTo730 } from './migrate_to_730_panels'; import { SavedDashboardPanelTo60, SavedDashboardPanel730ToLatest } from '../types'; import { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_core.test.mocks.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_core.test.mocks.ts index fff5aeab599ea..e1d9cfac95268 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_core.test.mocks.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_core.test.mocks.ts @@ -17,35 +17,11 @@ * under the License. */ -import { fatalErrorsServiceMock, notificationServiceMock } from '../../../../../core/public/mocks'; - let modalContents: React.Component; export const getModalContents = () => modalContents; -jest.doMock('ui/new_platform', () => { - return { - npStart: { - core: { - overlays: { - openFlyout: jest.fn(), - openModal: (component: React.Component) => { - modalContents = component; - return { - close: jest.fn(), - }; - }, - }, - }, - }, - npSetup: { - core: { - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - notifications: notificationServiceMock.createSetupContract(), - }, - }, - }; -}); +jest.mock('ui/new_platform'); jest.doMock('ui/metadata', () => ({ metadata: { diff --git a/src/legacy/core_plugins/kibana/public/discover/_discover.scss b/src/legacy/core_plugins/kibana/public/discover/_discover.scss index 2e142d475be24..b625e394a1383 100644 --- a/src/legacy/core_plugins/kibana/public/discover/_discover.scss +++ b/src/legacy/core_plugins/kibana/public/discover/_discover.scss @@ -1,5 +1,11 @@ +@import 'node_modules/@elastic/eui/src/components/panel/mixins'; + discover-app { - background-color: $euiColorEmptyShade; + flex-grow: 1; + + .sidebar-container { + background-color: transparent; + } } // SASSTODO: replace the margin-top value with a variable @@ -14,14 +20,20 @@ discover-app { // SASSTODO: replace the z-index value with a variable .dscWrapper { - padding-right: 0; + padding-right: $euiSizeS; padding-left: 21px; - z-index: 1 + z-index: 1; } +@include euiPanel('dscWrapper__content'); + .dscWrapper__content { - padding-right: $euiSize; - clear: both; + padding-top: $euiSizeXS; + background-color: $euiColorEmptyShade; + + .kbn-table { + margin-bottom: 0; + } } .dscTimechart { @@ -41,11 +53,11 @@ discover-app { padding-left: $euiSizeM; .dscResultHits { - padding-left: $euiSizeXS; + padding-left: $euiSizeXS; } > .kuiLink { - padding-left: $euiSizeM; + padding-left: $euiSizeM; } } @@ -136,7 +148,7 @@ discover-app { // SASSTODO: replace the padding value with a variable .dscFieldDetails { padding: 10px; - background-color: shade($euiColorLightestShade, 5%); + background-color: $euiColorLightestShade; color: $euiTextColor; } diff --git a/src/legacy/core_plugins/kibana/public/discover/_hacks.scss b/src/legacy/core_plugins/kibana/public/discover/_hacks.scss index 7f2b5b3400046..cdc8e04dff578 100644 --- a/src/legacy/core_plugins/kibana/public/discover/_hacks.scss +++ b/src/legacy/core_plugins/kibana/public/discover/_hacks.scss @@ -1,7 +1,6 @@ -// SASSTODO: the classname is dinamically generated with ng-class +// SASSTODO: the classname is dynamically generated with ng-class .tab-discover { overflow: hidden; - background: $euiColorEmptyShade; } // SASSTODO: these are Angular Bootstrap classes. Will be replaced by EUI diff --git a/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js b/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js index 94a62511bc2ad..377fd72e9c771 100644 --- a/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js +++ b/src/legacy/core_plugins/kibana/public/discover/controllers/discover.js @@ -31,7 +31,7 @@ import { getSort } from '../doc_table/lib/get_sort'; import * as columnActions from '../doc_table/actions/columns'; import * as filterActions from '../doc_table/actions/filter'; -import 'ui/listen'; +import 'ui/directives/listen'; import 'ui/visualize'; import 'ui/fixed_scroll'; import 'ui/index_patterns'; @@ -530,6 +530,14 @@ function discoverController( indexPatternList: $route.current.locals.ip.list, }; + const shouldSearchOnPageLoad = () => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + return config.get('discover:searchOnPageLoad') + || savedSearch.id !== undefined + || _.get($scope, 'refreshInterval.pause') === false; + }; + const init = _.once(function () { stateMonitor = stateMonitorFactory.create($state, getStateDefaults()); stateMonitor.onChange((status) => { @@ -546,8 +554,8 @@ function discoverController( $scope.$watchCollection('state.sort', function (sort) { if (!sort) return; - // get the current sort from {key: val} to ["key", "val"]; - const currentSort = _.pairs($scope.searchSource.getField('sort')).pop(); + // get the current sort from searchSource as array of arrays + const currentSort = getSort.array($scope.searchSource.getField('sort'), $scope.indexPattern); // if the searchSource doesn't know, tell it so if (!angular.equals(sort, currentSort)) $scope.fetch(); @@ -577,8 +585,10 @@ function discoverController( $scope.enableTimeRangeSelector = !!timefield; }); - $scope.$watch('state.interval', function () { - $scope.fetch(); + $scope.$watch('state.interval', function (newInterval, oldInterval) { + if (newInterval !== oldInterval) { + $scope.fetch(); + } }); $scope.$watch('vis.aggs', function () { @@ -592,9 +602,11 @@ function discoverController( } }); - $scope.$watch('state.query', (newQuery) => { - const query = migrateLegacyQuery(newQuery); - $scope.updateQueryAndFetch({ query }); + $scope.$watch('state.query', (newQuery, oldQuery) => { + if (!_.isEqual(newQuery, oldQuery)) { + const query = migrateLegacyQuery(newQuery); + $scope.updateQueryAndFetch({ query }); + } }); $scope.$watchMulti([ @@ -603,19 +615,25 @@ function discoverController( ], (function updateResultState() { let prev = {}; const status = { + UNINITIALIZED: 'uninitialized', LOADING: 'loading', // initial data load READY: 'ready', // results came back NO_RESULTS: 'none' // no results came back }; function pick(rows, oldRows, fetchStatus) { - // initial state, pretend we are loading - if (rows == null && oldRows == null) return status.LOADING; + // initial state, pretend we're already loading if we're about to execute a search so + // that the uninitilized message doesn't flash on screen + if (rows == null && oldRows == null && shouldSearchOnPageLoad()) { + return status.LOADING; + } + + if (fetchStatus === fetchStatuses.UNINITIALIZED) { + return status.UNINITIALIZED; + } const rowsEmpty = _.isEmpty(rows); - const preparingForFetch = fetchStatus === fetchStatuses.UNINITIALIZED; - if (preparingForFetch) return status.LOADING; - else if (rowsEmpty && fetchStatus === fetchStatuses.LOADING) return status.LOADING; + if (rowsEmpty && fetchStatus === fetchStatuses.LOADING) return status.LOADING; else if (!rowsEmpty) return status.READY; else return status.NO_RESULTS; } @@ -644,6 +662,10 @@ function discoverController( init.complete = true; $state.replace(); + + if (shouldSearchOnPageLoad()) { + $scope.fetch(); + } }); }); @@ -807,7 +829,14 @@ function discoverController( }; $scope.updateRefreshInterval = function () { - $scope.refreshInterval = timefilter.getRefreshInterval(); + const newInterval = timefilter.getRefreshInterval(); + const shouldFetch = _.get($scope, 'refreshInterval.pause') === true && newInterval.pause === false; + + $scope.refreshInterval = newInterval; + + if (shouldFetch) { + $scope.fetch(); + } }; $scope.onRefreshChange = function ({ isPaused, refreshInterval }) { @@ -833,8 +862,8 @@ function discoverController( .setField('filter', queryFilter.getFilters()); }); - $scope.setSortOrder = function setSortOrder(columnName, direction) { - $scope.state.sort = [columnName, direction]; + $scope.setSortOrder = function setSortOrder(sortPair) { + $scope.state.sort = sortPair; }; // TODO: On array fields, negating does not negate the combination, rather all terms diff --git a/src/legacy/core_plugins/kibana/public/discover/directives/index.js b/src/legacy/core_plugins/kibana/public/discover/directives/index.js index 30e8bf5a07c22..d13448bbf9c8a 100644 --- a/src/legacy/core_plugins/kibana/public/discover/directives/index.js +++ b/src/legacy/core_plugins/kibana/public/discover/directives/index.js @@ -21,21 +21,24 @@ import 'ngreact'; import { wrapInI18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; -import { - DiscoverNoResults, -} from './no_results'; +import { DiscoverNoResults } from './no_results'; -import { - DiscoverUnsupportedIndexPattern, -} from './unsupported_index_pattern'; +import { DiscoverUninitialized } from './uninitialized'; + +import { DiscoverUnsupportedIndexPattern } from './unsupported_index_pattern'; import './timechart'; const app = uiModules.get('apps/discover', ['react']); -app.directive('discoverNoResults', reactDirective => reactDirective(wrapInI18nContext(DiscoverNoResults))); +app.directive('discoverNoResults', reactDirective => + reactDirective(wrapInI18nContext(DiscoverNoResults)) +); + +app.directive('discoverUninitialized', reactDirective => + reactDirective(wrapInI18nContext(DiscoverUninitialized)) +); -app.directive( - 'discoverUnsupportedIndexPattern', - reactDirective => reactDirective(wrapInI18nContext(DiscoverUnsupportedIndexPattern), ['unsupportedType']) +app.directive('discoverUnsupportedIndexPattern', reactDirective => + reactDirective(wrapInI18nContext(DiscoverUnsupportedIndexPattern), ['unsupportedType']) ); diff --git a/src/legacy/core_plugins/kibana/public/discover/directives/uninitialized.tsx b/src/legacy/core_plugins/kibana/public/discover/directives/uninitialized.tsx new file mode 100644 index 0000000000000..f40865800098e --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/directives/uninitialized.tsx @@ -0,0 +1,65 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton, EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; + +interface Props { + onRefresh: () => void; +} + +export const DiscoverUninitialized = ({ onRefresh }: Props) => { + return ( + + + + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+ ); +}; diff --git a/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/get_sort.js b/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/get_sort.js index bec26d38d0fa7..b8b962b9f92d7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/get_sort.js +++ b/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/get_sort.js @@ -23,7 +23,7 @@ import ngMock from 'ng_mock'; import { getSort } from '../../lib/get_sort'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -const defaultSort = { time: 'desc' }; +const defaultSort = [{ time: 'desc' }]; let indexPattern; describe('docTable', function () { @@ -38,11 +38,11 @@ describe('docTable', function () { expect(getSort).to.be.a(Function); }); - it('should return an object if passed a 2 item array', function () { - expect(getSort(['bytes', 'desc'], indexPattern)).to.eql({ bytes: 'desc' }); + it('should return an array of objects if passed a 2 item array', function () { + expect(getSort(['bytes', 'desc'], indexPattern)).to.eql([{ bytes: 'desc' }]); delete indexPattern.timeFieldName; - expect(getSort(['bytes', 'desc'], indexPattern)).to.eql({ bytes: 'desc' }); + expect(getSort(['bytes', 'desc'], indexPattern)).to.eql([{ bytes: 'desc' }]); }); it('should sort by the default when passed an unsortable field', function () { @@ -50,7 +50,7 @@ describe('docTable', function () { expect(getSort(['lol_nope', 'asc'], indexPattern)).to.eql(defaultSort); delete indexPattern.timeFieldName; - expect(getSort(['non-sortable', 'asc'], indexPattern)).to.eql({ _score: 'desc' }); + expect(getSort(['non-sortable', 'asc'], indexPattern)).to.eql([{ _score: 'desc' }]); }); it('should sort in reverse chrono order otherwise on time based patterns', function () { @@ -62,9 +62,9 @@ describe('docTable', function () { it('should sort by score on non-time patterns', function () { delete indexPattern.timeFieldName; - expect(getSort([], indexPattern)).to.eql({ _score: 'desc' }); - expect(getSort(['foo'], indexPattern)).to.eql({ _score: 'desc' }); - expect(getSort({ foo: 'bar' }, indexPattern)).to.eql({ _score: 'desc' }); + expect(getSort([], indexPattern)).to.eql([{ _score: 'desc' }]); + expect(getSort(['foo'], indexPattern)).to.eql([{ _score: 'desc' }]); + expect(getSort({ foo: 'bar' }, indexPattern)).to.eql([{ _score: 'desc' }]); }); }); @@ -73,8 +73,8 @@ describe('docTable', function () { expect(getSort.array).to.be.a(Function); }); - it('should return an array for sortable fields', function () { - expect(getSort.array(['bytes', 'desc'], indexPattern)).to.eql([ 'bytes', 'desc' ]); + it('should return an array of arrays for sortable fields', function () { + expect(getSort.array(['bytes', 'desc'], indexPattern)).to.eql([[ 'bytes', 'desc' ]]); }); }); }); diff --git a/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/rows_headers.js b/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/rows_headers.js index b2660facf7a5a..1081528e2566f 100644 --- a/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/rows_headers.js +++ b/src/legacy/core_plugins/kibana/public/discover/doc_table/__tests__/lib/rows_headers.js @@ -27,9 +27,6 @@ import $ from 'jquery'; import 'plugins/kibana/discover/index'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -const SORTABLE_FIELDS = ['bytes', '@timestamp']; -const UNSORTABLE_FIELDS = ['request_body']; - describe('Doc Table', function () { let $parentScope; let $scope; @@ -119,155 +116,6 @@ describe('Doc Table', function () { }); }; - describe('kbnTableHeader', function () { - const $elem = angular.element(` - - `); - - beforeEach(function () { - init($elem, { - columns: [], - sortOrder: [], - onChangeSortOrder: sinon.stub(), - moveColumn: sinon.spy(), - removeColumn: sinon.spy(), - }); - }); - - afterEach(function () { - destroy(); - }); - - describe('adding and removing columns', function () { - columnTests('[data-test-subj~="docTableHeaderField"]', $elem); - }); - - describe('sorting button', function () { - beforeEach(function () { - $parentScope.columns = ['bytes', '_source']; - $elem.scope().$digest(); - }); - - it('should show for sortable columns', function () { - expect($elem.find(`[data-test-subj="docTableHeaderFieldSort_bytes"]`).length).to.be(1); - }); - - it('should not be shown for unsortable columns', function () { - expect($elem.find(`[data-test-subj="docTableHeaderFieldSort__source"]`).length).to.be(0); - }); - }); - - describe('cycleSortOrder function', function () { - it('should exist', function () { - expect($scope.cycleSortOrder).to.be.a(Function); - }); - - it('should call onChangeSortOrder with ascending order for a sortable field without sort order', function () { - $scope.sortOrder = []; - $scope.cycleSortOrder(SORTABLE_FIELDS[0]); - expect($scope.onChangeSortOrder.callCount).to.be(1); - expect($scope.onChangeSortOrder.firstCall.args).to.eql([SORTABLE_FIELDS[0], 'asc']); - }); - - it('should call onChangeSortOrder with ascending order for a sortable field already sorted by in descending order', function () { - $scope.sortOrder = [SORTABLE_FIELDS[0], 'desc']; - $scope.cycleSortOrder(SORTABLE_FIELDS[0]); - expect($scope.onChangeSortOrder.callCount).to.be(1); - expect($scope.onChangeSortOrder.firstCall.args).to.eql([SORTABLE_FIELDS[0], 'asc']); - }); - - it('should call onChangeSortOrder with ascending order for a sortable field when already sorted by an different field', function () { - $scope.sortOrder = [SORTABLE_FIELDS[1], 'asc']; - $scope.cycleSortOrder(SORTABLE_FIELDS[0]); - expect($scope.onChangeSortOrder.callCount).to.be(1); - expect($scope.onChangeSortOrder.firstCall.args).to.eql([SORTABLE_FIELDS[0], 'asc']); - }); - - it('should call onChangeSortOrder with descending order for a sortable field already sorted by in ascending order', function () { - $scope.sortOrder = [SORTABLE_FIELDS[0], 'asc']; - $scope.cycleSortOrder(SORTABLE_FIELDS[0]); - expect($scope.onChangeSortOrder.callCount).to.be(1); - expect($scope.onChangeSortOrder.firstCall.args).to.eql([SORTABLE_FIELDS[0], 'desc']); - }); - - it('should not call onChangeSortOrder for an unsortable field', function () { - $scope.sortOrder = []; - $scope.cycleSortOrder(UNSORTABLE_FIELDS[0]); - expect($scope.onChangeSortOrder.callCount).to.be(0); - }); - - it('should not try to call onChangeSortOrder when it is not defined', function () { - $scope.onChangeSortOrder = undefined; - expect(() => $scope.cycleSortOrder(SORTABLE_FIELDS[0])).to.not.throwException(); - }); - }); - - describe('headerClass function', function () { - it('should exist', function () { - expect($scope.headerClass).to.be.a(Function); - }); - - it('should return list including kbnDocTableHeader__sortChange for a sortable field not currently sorted by', function () { - expect($scope.headerClass(SORTABLE_FIELDS[0])).to.contain('kbnDocTableHeader__sortChange'); - }); - - it('should return undefined for an unsortable field', function () { - expect($scope.headerClass(UNSORTABLE_FIELDS[0])).to.be(undefined); - }); - - it('should return list including fa-sort-up for a sortable field not currently sorted by', function () { - expect($scope.headerClass(SORTABLE_FIELDS[0])).to.contain('fa-sort-up'); - }); - - it('should return list including fa-sort-up for a sortable field currently sorted by in ascending order', function () { - $scope.sortOrder = [SORTABLE_FIELDS[0], 'asc']; - expect($scope.headerClass(SORTABLE_FIELDS[0])).to.contain('fa-sort-up'); - }); - - it('should return list including fa-sort-down for a sortable field currently sorted by in descending order', function () { - $scope.sortOrder = [SORTABLE_FIELDS[0], 'desc']; - expect($scope.headerClass(SORTABLE_FIELDS[0])).to.contain('fa-sort-down'); - }); - }); - - describe('moving columns', function () { - beforeEach(function () { - $parentScope.columns = ['bytes', 'request_body', '@timestamp', 'point']; - $elem.scope().$digest(); - }); - - it('should move columns to the right', function () { - $scope.moveColumnRight('bytes'); - expect($scope.onMoveColumn.callCount).to.be(1); - expect($scope.onMoveColumn.firstCall.args).to.eql(['bytes', 1]); - }); - - it('shouldnt move the last column to the right', function () { - $scope.moveColumnRight('point'); - expect($scope.onMoveColumn.callCount).to.be(0); - }); - - it('should move columns to the left', function () { - $scope.moveColumnLeft('@timestamp'); - expect($scope.onMoveColumn.callCount).to.be(1); - expect($scope.onMoveColumn.firstCall.args).to.eql(['@timestamp', 1]); - }); - - it('shouldnt move the first column to the left', function () { - $scope.moveColumnLeft('bytes'); - expect($scope.onMoveColumn.callCount).to.be(0); - }); - }); - }); - describe('kbnTableRow', function () { const $elem = angular.element( ' - - - - - - - - - - {{getShortDotsName(name)}} - - - - - - - diff --git a/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header.js b/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header.js index 60d440b1f957d..3af22a363e6db 100644 --- a/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header.js +++ b/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header.js @@ -16,133 +16,19 @@ * specific language governing permissions and limitations * under the License. */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { shortenDottedString } from '../../../../common/utils/shorten_dotted_string'; -import headerHtml from './table_header.html'; +import { wrapInI18nContext } from 'ui/i18n'; import { uiModules } from 'ui/modules'; +import { TableHeader } from './table_header/table_header'; const module = uiModules.get('app/discover'); - -module.directive('kbnTableHeader', function () { - return { - restrict: 'A', - scope: { - columns: '=', - sortOrder: '=', - indexPattern: '=', - onChangeSortOrder: '=?', - onRemoveColumn: '=?', - onMoveColumn: '=?', - }, - template: headerHtml, - controller: function ($scope, config) { - $scope.hideTimeColumn = config.get('doc_table:hideTimeColumn'); - $scope.isShortDots = config.get('shortDots:enable'); - - $scope.getShortDotsName = function getShortDotsName(columnName) { - return $scope.isShortDots ? shortenDottedString(columnName) : columnName; - }; - - $scope.isSortableColumn = function isSortableColumn(columnName) { - return ( - !!$scope.indexPattern - && _.isFunction($scope.onChangeSortOrder) - && _.get($scope, ['indexPattern', 'fields', 'byName', columnName, 'sortable'], false) - ); - }; - - $scope.tooltip = function (column) { - if (!$scope.isSortableColumn(column)) return ''; - const name = $scope.isShortDots ? shortenDottedString(column) : column; - return i18n.translate('kbn.docTable.tableHeader.sortByColumnTooltip', { - defaultMessage: 'Sort by {columnName}', - values: { columnName: name }, - }); - }; - - $scope.canMoveColumnLeft = function canMoveColumn(columnName) { - return ( - _.isFunction($scope.onMoveColumn) - && $scope.columns.indexOf(columnName) > 0 - ); - }; - - $scope.canMoveColumnRight = function canMoveColumn(columnName) { - return ( - _.isFunction($scope.onMoveColumn) - && $scope.columns.indexOf(columnName) < $scope.columns.length - 1 - ); - }; - - $scope.canRemoveColumn = function canRemoveColumn(columnName) { - return ( - _.isFunction($scope.onRemoveColumn) - && (columnName !== '_source' || $scope.columns.length > 1) - ); - }; - - $scope.headerClass = function (column) { - if (!$scope.isSortableColumn(column)) return; - - const sortOrder = $scope.sortOrder; - const defaultClass = ['fa', 'fa-sort-up', 'kbnDocTableHeader__sortChange']; - - if (!sortOrder || column !== sortOrder[0]) return defaultClass; - return ['fa', sortOrder[1] === 'asc' ? 'fa-sort-up' : 'fa-sort-down']; - }; - - $scope.moveColumnLeft = function moveLeft(columnName) { - const newIndex = $scope.columns.indexOf(columnName) - 1; - - if (newIndex < 0) { - return; - } - - $scope.onMoveColumn(columnName, newIndex); - }; - - $scope.moveColumnRight = function moveRight(columnName) { - const newIndex = $scope.columns.indexOf(columnName) + 1; - - if (newIndex >= $scope.columns.length) { - return; - } - - $scope.onMoveColumn(columnName, newIndex); - }; - - $scope.cycleSortOrder = function cycleSortOrder(columnName) { - if (!$scope.isSortableColumn(columnName)) { - return; - } - - const [currentColumnName, currentDirection = 'asc'] = $scope.sortOrder; - const newDirection = ( - (columnName === currentColumnName && currentDirection === 'asc') - ? 'desc' - : 'asc' - ); - - $scope.onChangeSortOrder(columnName, newDirection); - }; - - $scope.getAriaLabelForColumn = function getAriaLabelForColumn(name) { - if (!$scope.isSortableColumn(name)) return null; - - const [currentColumnName, currentDirection = 'asc'] = $scope.sortOrder; - if(name === currentColumnName && currentDirection === 'asc') { - return i18n.translate('kbn.docTable.tableHeader.sortByColumnDescendingAriaLabel', { - defaultMessage: 'Sort {columnName} descending', - values: { columnName: name }, - }); - } - return i18n.translate('kbn.docTable.tableHeader.sortByColumnAscendingAriaLabel', { - defaultMessage: 'Sort {columnName} ascending', - values: { columnName: name }, - }); - }; +module.directive('kbnTableHeader', function (reactDirective, config) { + return reactDirective( + wrapInI18nContext(TableHeader), + undefined, + { restrict: 'A' }, + { + hideTimeColumn: config.get('doc_table:hideTimeColumn'), + isShortDots: config.get('shortDots:enable'), } - }; + ); }); diff --git a/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap b/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap new file mode 100644 index 0000000000000..3860a03d52716 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/discover/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap @@ -0,0 +1,221 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableHeader with time column renders correctly 1`] = ` + + + + + Time + +
diff --git a/src/legacy/core_plugins/kibana/public/doc_viewer/doc_viewer.js b/src/legacy/core_plugins/kibana/public/doc_viewer/doc_viewer.js index cfc45e4af5251..060658b11400e 100644 --- a/src/legacy/core_plugins/kibana/public/doc_viewer/doc_viewer.js +++ b/src/legacy/core_plugins/kibana/public/doc_viewer/doc_viewer.js @@ -21,7 +21,7 @@ import $ from 'jquery'; import { uiModules } from 'ui/modules'; import { DocViewsRegistryProvider } from 'ui/registry/doc_views'; -import 'ui/render_directive'; +import 'ui/directives/render_directive'; uiModules.get('apps/discover') .directive('docViewer', function (Private) { diff --git a/src/legacy/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap b/src/legacy/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap index f30ed0d1fac71..bc64579a4d71b 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/home/components/tutorial/__snapshots__/introduction.test.js.snap @@ -13,10 +13,10 @@ exports[`props exportedFieldsUrl 1`] = ` -

+

Great tutorial   -

+
@@ -70,10 +70,10 @@ exports[`props iconType 1`] = ` -

+

Great tutorial   -

+
@@ -100,13 +100,13 @@ exports[`props isBeta 1`] = ` -

+

Great tutorial   -

+
@@ -133,10 +133,10 @@ exports[`props previewUrl 1`] = ` -

+

Great tutorial   -

+
@@ -172,10 +172,10 @@ exports[`render 1`] = ` -

+

Great tutorial   -

+
diff --git a/src/legacy/core_plugins/kibana/public/home/components/tutorial/introduction.js b/src/legacy/core_plugins/kibana/public/home/components/tutorial/introduction.js index 60cf56a51698e..3ba2024d6b8cd 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/tutorial/introduction.js +++ b/src/legacy/core_plugins/kibana/public/home/components/tutorial/introduction.js @@ -96,10 +96,10 @@ function IntroductionUI({ description, previewUrl, title, exportedFieldsUrl, ico {icon} -

+

{title}   {betaBadge} -

+
diff --git a/src/legacy/core_plugins/kibana/public/home/sample_data_client.js b/src/legacy/core_plugins/kibana/public/home/sample_data_client.js index 943eedb290f8c..da46b3e16c093 100644 --- a/src/legacy/core_plugins/kibana/public/home/sample_data_client.js +++ b/src/legacy/core_plugins/kibana/public/home/sample_data_client.js @@ -24,7 +24,7 @@ import { indexPatternService } from './kibana_services'; const sampleDataUrl = '/api/sample_data'; function clearIndexPatternsCache() { - indexPatternService.getIds.clearCache(); + indexPatternService.clearCache(); } export async function listSampleDataSets() { diff --git a/src/legacy/core_plugins/kibana/public/management/_hacks.scss b/src/legacy/core_plugins/kibana/public/management/_hacks.scss index b2ffca9ef964a..59af9c9617a30 100644 --- a/src/legacy/core_plugins/kibana/public/management/_hacks.scss +++ b/src/legacy/core_plugins/kibana/public/management/_hacks.scss @@ -23,21 +23,6 @@ kbn-management-objects-view { .ace_editor { height: 300px; } } -// SASSTODO: These are some dragula settings. -.gu-handle { - cursor: move; - cursor: grab; - cursor: -moz-grab; - cursor: -webkit-grab; -} - -.gu-mirror, -.gu-mirror .gu-handle { - cursor: grabbing; - cursor: -moz-grabbing; - cursor: -webkit-grabbing; -} - // Hack because the management wrapper is flat HTML and needs a class .mgtPage__body { max-width: map-get($euiBreakpoints, 'xl'); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__jest__/create_index_pattern_wizard.test.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__jest__/create_index_pattern_wizard.test.js index 8ddb89797361e..20e2fb779abeb 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__jest__/create_index_pattern_wizard.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/__jest__/create_index_pattern_wizard.test.js @@ -55,6 +55,7 @@ const services = { config: {}, changeUrl: () => {}, scopeApply: () => {}, + indexPatternCreationType: mockIndexPatternCreationType, }; @@ -183,7 +184,7 @@ describe('CreateIndexPatternWizard', () => { get: () => ({ create, }), - cache: { clear } + clearCache: clear, }, changeUrl, indexPatternCreationType: mockIndexPatternCreationType diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/__jest__/__snapshots__/step_time_field.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/__jest__/__snapshots__/step_time_field.test.js.snap index 7b442d569ae83..3f25b180c9e1c 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/__jest__/__snapshots__/step_time_field.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/__jest__/__snapshots__/step_time_field.test.js.snap @@ -31,7 +31,7 @@ exports[`StepTimeField should render "Custom index pattern ID already exists" wh /> ({ const mockIndexPatternCreationType = { getIndexPatternType: () => 'default', - getIndexPatternName: () => 'name' + getIndexPatternName: () => 'name', + getFetchForWildcardOptions: () => {} }; const noop = () => {}; const indexPatternsService = { - fieldsFetcher: { - fetchForWildcard: noop, - } + make: async () => ({ + fieldsFetcher: { + fetch: noop + } + }) }; describe('StepTimeField', () => { diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/step_time_field.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/step_time_field.js index d500924376df3..4476ad868cbcf 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/step_time_field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/components/step_time_field/step_time_field.js @@ -81,12 +81,15 @@ export class StepTimeFieldComponent extends Component { } fetchTimeFields = async () => { - const { indexPatternsService, indexPattern } = this.props; + const { indexPatternsService, indexPattern: pattern } = this.props; const { getFetchForWildcardOptions } = this.props.indexPatternCreationType; + const indexPattern = await indexPatternsService.make(); + indexPattern.title = pattern; + this.setState({ isFetchingTimeFields: true }); const fields = await ensureMinimumTime( - indexPatternsService.fieldsFetcher.fetchForWildcard(indexPattern, getFetchForWildcardOptions()) + indexPattern.fieldsFetcher.fetch(getFetchForWildcardOptions()) ); const timeFields = extractTimeFields(fields); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/create_index_pattern_wizard.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/create_index_pattern_wizard.js index 5bf791d4a662c..021b42c9f59f7 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/create_index_pattern_wizard.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/create_index_pattern_wizard/create_index_pattern_wizard.js @@ -76,7 +76,7 @@ export class CreateIndexPatternWizard extends Component { this.setState(prevState => ({ toasts: prevState.toasts.concat([{ title: errorMsg, - id: errorMsg, + id: errorMsg.props.id, color: 'warning', iconType: 'alert', }]) @@ -146,7 +146,7 @@ export class CreateIndexPatternWizard extends Component { await services.config.set('defaultIndex', createdId); } - services.indexPatterns.cache.clear(createdId); + services.indexPatterns.clearCache(createdId); services.changeUrl(`/management/kibana/index_patterns/${createdId}`); } diff --git a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js index a67c3ed995505..4e4443f6761b2 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/index_patterns/edit_index_pattern/edit_index_pattern.js @@ -263,7 +263,7 @@ uiModules.get('apps/management') } } - Promise.resolve(indexPatterns.delete($scope.indexPattern)) + Promise.resolve($scope.indexPattern.destroy()) .then(function () { $location.url('/management/kibana/index_patterns'); }) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap index 9dbfc9866536b..f9b79266d4253 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/__snapshots__/objects_table.test.js.snap @@ -209,9 +209,7 @@ exports[`ObjectsTable import should show the flyout 1`] = ` done={[Function]} indexPatterns={ Object { - "cache": Object { - "clearAll": [MockFunction], - }, + "clearCache": [MockFunction], } } newIndexPatternUrl="" diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js index 236970cf3c3f3..51f33f7568369 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/__jest__/objects_table.test.js @@ -123,9 +123,7 @@ const defaultProps = { bulkGet: jest.fn(), }, indexPatterns: { - cache: { - clearAll: jest.fn(), - } + clearCache: jest.fn(), }, $http, basePath: '', @@ -531,7 +529,7 @@ describe('ObjectsTable', () => { await component.instance().delete(); - expect(defaultProps.indexPatterns.cache.clearAll).toHaveBeenCalled(); + expect(defaultProps.indexPatterns.clearCache).toHaveBeenCalled(); expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith(mockSavedObjects[0].type, mockSavedObjects[0].id); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith(mockSavedObjects[1].type, mockSavedObjects[1].id); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap index 015683cdde671..0586647254e9c 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/flyout/__jest__/__snapshots__/flyout.test.js.snap @@ -611,6 +611,7 @@ exports[`Flyout should render import step 1`] = ` > { const options = this.state.indexPatterns.map(indexPattern => ({ - text: indexPattern.get('title'), + text: indexPattern.title, value: indexPattern.id, - ['data-test-subj']: `indexPatternOption-${indexPattern.get('title')}`, + ['data-test-subj']: `indexPatternOption-${indexPattern.title}`, })); options.unshift({ diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js index 9c1f1a84e1bb6..263b23859c8a5 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/objects_table.js @@ -369,7 +369,7 @@ class ObjectsTableUI extends Component { object => object.type === 'index-pattern' ); if (indexPatterns.length) { - await this.props.indexPatterns.cache.clearAll(); + await this.props.indexPatterns.clearCache(); } const objects = await savedObjectsClient.bulkGet(selectedSavedObjects); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js index cb6e81c714939..1468b3d90400d 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/lib/resolve_saved_objects.js @@ -96,7 +96,7 @@ async function importIndexPattern(doc, indexPatterns, overwriteAll, confirmModal return; } } - indexPatterns.cache.clear(newId); + indexPatterns.clearCache(newId); return newId; } diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap index b0e3503b73f25..e7f66a14cc827 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/__snapshots__/field.test.js.snap @@ -899,6 +899,7 @@ exports[`Field for image setting should render as read only if saving is disable compressed={false} data-test-subj="advancedSetting-editField-image:test:setting" disabled={true} + display="large" initialPromptText="Select or drag and drop a file" onChange={[Function]} onKeyDown={[Function]} @@ -1069,6 +1070,7 @@ exports[`Field for image setting should render custom setting icon if it is cust compressed={false} data-test-subj="advancedSetting-editField-image:test:setting" disabled={false} + display="large" initialPromptText="Select or drag and drop a file" onChange={[Function]} onKeyDown={[Function]} @@ -1133,6 +1135,7 @@ exports[`Field for image setting should render default value if there is no user compressed={false} data-test-subj="advancedSetting-editField-image:test:setting" disabled={false} + display="large" initialPromptText="Select or drag and drop a file" onChange={[Function]} onKeyDown={[Function]} diff --git a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts index a59029fc36ef1..c1b9bfd42cc43 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts @@ -38,8 +38,8 @@ import { import { Subscription } from 'rxjs'; import * as Rx from 'rxjs'; import { TimeRange } from 'ui/timefilter/time_history'; -import { Query } from 'src/legacy/core_plugins/data/public'; import { Filter } from '@kbn/es-query'; +import { Query, onlyDisabledFiltersChanged } from '../../../../data/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -76,6 +76,7 @@ export class VisualizeEmbeddable extends Embeddable { - this.reload(); this.handleChanges(); }); } @@ -138,14 +138,17 @@ export class VisualizeEmbeddable extends Embeddable { - this.uiState.set(key, visCustomizations[key]); - }); - this.uiState.on('change', this.uiStateChangeHandler); + if (!_.isEqual(visCustomizations, this.visCustomizations)) { + this.visCustomizations = visCustomizations; + // Turn this off or the uiStateChangeHandler will fire for every modification. + this.uiState.off('change', this.uiStateChangeHandler); + this.uiState.clearAllKeys(); + this.uiState.set('vis', visCustomizations); + getKeys(visCustomizations).forEach(key => { + this.uiState.set(key, visCustomizations[key]); + }); + this.uiState.on('change', this.uiStateChangeHandler); + } } else { this.uiState.clearAllKeys(); } @@ -157,19 +160,19 @@ export class VisualizeEmbeddable extends Embeddable { +jest.mock('../../../../core_plugins/data/public', () => { return { SearchBar: () =>
, SearchBarProps: {}, diff --git a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx index 31ad676bb4cb7..e0c705ece7b4b 100644 --- a/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx +++ b/src/legacy/core_plugins/kibana_react/public/top_nav_menu/top_nav_menu.tsx @@ -23,7 +23,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { I18nProvider } from '@kbn/i18n/react'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { SearchBar, SearchBarProps } from '../search_bar'; +import { SearchBar, SearchBarProps } from '../../../../core_plugins/data/public'; type Props = Partial & { name: string; diff --git a/src/legacy/core_plugins/metrics/index.js b/src/legacy/core_plugins/metric_vis/index.ts similarity index 60% rename from src/legacy/core_plugins/metrics/index.js rename to src/legacy/core_plugins/metric_vis/index.ts index 0771bf9726c27..f77234a26ce48 100644 --- a/src/legacy/core_plugins/metrics/index.js +++ b/src/legacy/core_plugins/metric_vis/index.ts @@ -18,34 +18,27 @@ */ import { resolve } from 'path'; +import { Legacy } from 'kibana'; -import { fieldsRoutes } from './server/routes/fields'; -import { visDataRoutes } from './server/routes/vis'; -import { SearchStrategiesRegister } from './server/lib/search_strategies/search_strategies_register'; - -export default function(kibana) { - return new kibana.Plugin({ - require: ['kibana', 'elasticsearch'], +import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; +const metricPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => + new Plugin({ + id: 'metric_vis', + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + publicDir: resolve(__dirname, 'public'), uiExports: { - visTypes: ['plugins/metrics/kbn_vis_types'], - interpreter: ['plugins/metrics/tsvb_fn'], styleSheetPaths: resolve(__dirname, 'public/index.scss'), + hacks: [resolve(__dirname, 'public/legacy')], + injectDefaultVars: server => ({}), }, - - config(Joi) { + init: (server: Legacy.Server) => ({}), + config(Joi: any) { return Joi.object({ enabled: Joi.boolean().default(true), - chartResolution: Joi.number().default(150), - minimumBucketSize: Joi.number().default(10), }).default(); }, + } as Legacy.PluginSpecOptions); - init(server) { - fieldsRoutes(server); - visDataRoutes(server); - - SearchStrategiesRegister.init(server); - }, - }); -} +// eslint-disable-next-line import/no-default-export +export default metricPluginInitializer; diff --git a/src/legacy/core_plugins/metric_vis/public/__snapshots__/metric_vis_fn.test.js.snap b/src/legacy/core_plugins/metric_vis/public/__snapshots__/metric_vis_fn.test.ts.snap similarity index 100% rename from src/legacy/core_plugins/metric_vis/public/__snapshots__/metric_vis_fn.test.js.snap rename to src/legacy/core_plugins/metric_vis/public/__snapshots__/metric_vis_fn.test.ts.snap diff --git a/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis.js b/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis.js index a8bfec6c86203..7adf7007e8603 100644 --- a/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis.js +++ b/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis.js @@ -23,9 +23,11 @@ import expect from '@kbn/expect'; import { VisProvider } from 'ui/vis'; import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern'; -import MetricVisProvider from '../metric_vis'; -describe('metric vis', () => { +import { visualizations } from '../../../visualizations/public'; +import { createMetricVisTypeDefinition } from '../metric_vis_type'; + +describe('metric_vis - createMetricVisTypeDefinition', () => { let setup = null; let vis; @@ -33,7 +35,12 @@ describe('metric vis', () => { beforeEach(ngMock.inject((Private) => { setup = () => { const Vis = Private(VisProvider); - const metricVisType = Private(MetricVisProvider); + const metricVisType = createMetricVisTypeDefinition(); + + visualizations.types.VisTypesRegistryProvider.register(() => + metricVisType + ); + const indexPattern = Private(LogstashIndexPatternStubProvider); indexPattern.stubSetFieldFormat('ip', 'url', { diff --git a/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js b/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js index 82ef6f2de50be..8dd2b093c6f91 100644 --- a/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js +++ b/src/legacy/core_plugins/metric_vis/public/__tests__/metric_vis_controller.js @@ -18,9 +18,9 @@ */ import expect from '@kbn/expect'; -import { MetricVisComponent } from '../metric_vis_controller'; +import { MetricVisComponent } from '../components/metric_vis_controller'; -describe('metric vis controller', function () { +describe('metric_vis - controller', function () { const vis = { params: { diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis_controller.js b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_controller.js similarity index 95% rename from src/legacy/core_plugins/metric_vis/public/metric_vis_controller.js rename to src/legacy/core_plugins/metric_vis/public/components/metric_vis_controller.js index ecbb9d917874c..a13df1f7e52ef 100644 --- a/src/legacy/core_plugins/metric_vis/public/metric_vis_controller.js +++ b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_controller.js @@ -17,13 +17,13 @@ * under the License. */ -import _ from 'lodash'; +import { last, findIndex, isNaN } from 'lodash'; import React, { Component } from 'react'; +import { isColorDark } from '@elastic/eui'; import { getHeatmapColors } from 'ui/vislib/components/color/heatmap_color'; import { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; -import { isColorDark } from '@elastic/eui'; -import { MetricVisValue } from './components/metric_vis_value'; +import { MetricVisValue } from './metric_vis_value'; export class MetricVisComponent extends Component { @@ -31,7 +31,7 @@ export class MetricVisComponent extends Component { const config = this.props.visParams.metric; const isPercentageMode = config.percentageMode; const colorsRange = config.colorsRange; - const max = _.last(colorsRange).to; + const max = last(colorsRange).to; const labels = []; colorsRange.forEach(range => { const from = isPercentageMode ? Math.round(100 * range.from / max) : range.from; @@ -59,7 +59,7 @@ export class MetricVisComponent extends Component { _getBucket(val) { const config = this.props.visParams.metric; - let bucket = _.findIndex(config.colorsRange, range => { + let bucket = findIndex(config.colorsRange, range => { return range.from <= val && range.to > val; }); @@ -86,7 +86,7 @@ export class MetricVisComponent extends Component { } _getFormattedValue = (fieldFormatter, value, format = 'text') => { - if (_.isNaN(value)) return '-'; + if (isNaN(value)) return '-'; return fieldFormatter.convert(value, format); }; @@ -95,7 +95,7 @@ export class MetricVisComponent extends Component { const dimensions = this.props.visParams.dimensions; const isPercentageMode = config.percentageMode; const min = config.colorsRange[0].from; - const max = _.last(config.colorsRange).to; + const max = last(config.colorsRange).to; const colors = this._getColors(); const labels = this._getLabels(); const metrics = []; diff --git a/test/api_integration/services/index.js b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/index.js similarity index 86% rename from test/api_integration/services/index.js rename to src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/index.js index 2d363d1ac552b..3db93663b625d 100644 --- a/test/api_integration/services/index.js +++ b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/index.js @@ -17,5 +17,4 @@ * under the License. */ -export { KibanaSupertestProvider, ElasticsearchSupertestProvider } from './supertest'; -export { ChanceProvider } from './chance'; +export { MetricVisParams } from './metric_vis_params'; diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis_params.html b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/metric_vis_params.html similarity index 100% rename from src/legacy/core_plugins/metric_vis/public/metric_vis_params.html rename to src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/metric_vis_params.html diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis_params.js b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/metric_vis_params.js similarity index 94% rename from src/legacy/core_plugins/metric_vis/public/metric_vis_params.js rename to src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/metric_vis_params.js index b913d4afaebba..26c674961dd0b 100644 --- a/src/legacy/core_plugins/metric_vis/public/metric_vis_params.js +++ b/src/legacy/core_plugins/metric_vis/public/components/metric_vis_params/metric_vis_params.js @@ -17,14 +17,11 @@ * under the License. */ -import { uiModules } from 'ui/modules'; +import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import 'ui/directives/inequality'; import metricVisParamsTemplate from './metric_vis_params.html'; -import _ from 'lodash'; -const module = uiModules.get('kibana'); -module.directive('metricVisParams', function () { +export function MetricVisParams() { return { restrict: 'E', template: metricVisParamsTemplate, @@ -84,6 +81,6 @@ module.directive('metricVisParams', function () { $scope.editorState.requiredDescription = i18n.translate( 'metricVis.params.ranges.warning.requiredDescription', { defaultMessage: 'Required:' }); - } + }, }; -}); +} diff --git a/src/legacy/core_plugins/metric_vis/public/index.ts b/src/legacy/core_plugins/metric_vis/public/index.ts new file mode 100644 index 0000000000000..7499babef58a7 --- /dev/null +++ b/src/legacy/core_plugins/metric_vis/public/index.ts @@ -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. + */ + +import { PluginInitializerContext } from '../../../../core/public'; +import { MetricVisPlugin as Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} diff --git a/src/legacy/ui/public/inspector/ui/inspector_view.tsx b/src/legacy/core_plugins/metric_vis/public/legacy.ts similarity index 51% rename from src/legacy/ui/public/inspector/ui/inspector_view.tsx rename to src/legacy/core_plugins/metric_vis/public/legacy.ts index 45d7c95d0d7d9..4ab399977d7b1 100644 --- a/src/legacy/ui/public/inspector/ui/inspector_view.tsx +++ b/src/legacy/core_plugins/metric_vis/public/legacy.ts @@ -17,29 +17,24 @@ * under the License. */ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; +import { PluginInitializerContext } from 'kibana/public'; +import { npSetup, npStart } from 'ui/new_platform'; -import { EuiFlyoutBody } from '@elastic/eui'; +import { visualizations } from '../../visualizations/public'; +import { MetricVisPluginSetupDependencies } from './plugin'; +import { LegacyDependenciesPlugin } from './shim'; +import { plugin } from '.'; -/** - * The InspectorView component should be the top most element in every implemented - * inspector view. It makes sure, that the appropriate stylings are applied to the - * view. - */ -const InspectorView: React.SFC<{ useFlex?: boolean }> = ({ useFlex, children }) => { - const classes = classNames({ - 'kbnInspectorView--flex': Boolean(useFlex), - }); - return {children}; -}; +const plugins: Readonly = { + visualizations, + data: npSetup.plugins.data, -InspectorView.propTypes = { - /** - * Set to true if the element should have display: flex set. - */ - useFlex: PropTypes.bool, + // Temporary solution + // It will be removed when all dependent services are migrated to the new platform. + __LEGACY: new LegacyDependenciesPlugin(), }; -export { InspectorView }; +const pluginInstance = plugin({} as PluginInitializerContext); + +export const setup = pluginInstance.setup(npSetup.core, plugins); +export const start = pluginInstance.start(npStart.core); diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.js b/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.ts similarity index 91% rename from src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.js rename to src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.ts index f1705d644c9cb..5fe2ac7b7fdf0 100644 --- a/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.js +++ b/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.test.ts @@ -17,13 +17,15 @@ * under the License. */ +import { createMetricVisFn } from './metric_vis_fn'; + +// @ts-ignore import { functionWrapper } from '../../interpreter/test_helpers'; -import { metric } from './metric_vis_fn'; jest.mock('ui/new_platform'); describe('interpreter/functions#metric', () => { - const fn = functionWrapper(metric); + const fn = functionWrapper(createMetricVisFn); const context = { type: 'kibana_datatable', rows: [{ 'col-0-1': 0 }], @@ -38,7 +40,7 @@ describe('interpreter/functions#metric', () => { { from: 0, to: 10000, - } + }, ], labels: { show: true, @@ -56,16 +58,17 @@ describe('interpreter/functions#metric', () => { { accessor: 0, format: { - id: 'number' + id: 'number', }, params: {}, aggType: 'count', - } - ] + }, + ], }; it('returns an object with the correct structure', () => { const actual = fn(context, args); + expect(actual).toMatchSnapshot(); }); }); diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.js b/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.ts similarity index 59% rename from src/legacy/core_plugins/metric_vis/public/metric_vis_fn.js rename to src/legacy/core_plugins/metric_vis/public/metric_vis_fn.ts index 1ba28866d4e4b..9fabb5b3a49a5 100644 --- a/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.js +++ b/src/legacy/core_plugins/metric_vis/public/metric_vis_fn.ts @@ -17,85 +17,149 @@ * under the License. */ -import { functionsRegistry } from 'plugins/interpreter/registries'; import { i18n } from '@kbn/i18n'; + +// @ts-ignore import { vislibColorMaps } from 'ui/vislib/components/color/colormaps'; +import { ExpressionFunction, KibanaDatatable, Render, Range, Style } from '../../interpreter/types'; + +type Context = KibanaDatatable; + +const name = 'metricVis'; + +interface Arguments { + percentage: boolean; + colorScheme: string; + colorMode: string; + useRanges: boolean; + invertColors: boolean; + showLabels: boolean; + bgFill: string; + subText: string; + colorRange: Range[]; + font: Style; + metric: any[]; // these aren't typed yet + bucket: any; // these aren't typed yet +} + +interface VisParams { + dimensions: DimensionsVisParam; + metric: MetricVisParam; +} + +interface DimensionsVisParam { + metrics: any; + bucket?: any; +} -export const metric = () => ({ - name: 'metricVis', +interface MetricVisParam { + percentageMode: Arguments['percentage']; + useRanges: Arguments['useRanges']; + colorSchema: Arguments['colorScheme']; + metricColorMode: Arguments['colorMode']; + colorsRange: Arguments['colorRange']; + labels: { + show: Arguments['showLabels']; + }; + invertColors: Arguments['invertColors']; + style: { + bgFill: Arguments['bgFill']; + bgColor: boolean; + labelColor: boolean; + subText: Arguments['subText']; + fontSize: number; + }; +} + +interface RenderValue { + visType: 'metric'; + visData: Context; + visConfig: VisParams; + params: any; +} + +type Return = Render; + +export const createMetricVisFn = (): ExpressionFunction< + typeof name, + Context, + Arguments, + Return +> => ({ + name, type: 'render', context: { - types: [ - 'kibana_datatable' - ], + types: ['kibana_datatable'], }, help: i18n.translate('metricVis.function.help', { - defaultMessage: 'Metric visualization' + defaultMessage: 'Metric visualization', }), args: { percentage: { types: ['boolean'], default: false, help: i18n.translate('metricVis.function.percentage.help', { - defaultMessage: 'Shows metric in percentage mode. Requires colorRange to be set.' - }) + defaultMessage: 'Shows metric in percentage mode. Requires colorRange to be set.', + }), }, colorScheme: { types: ['string'], default: '"Green to Red"', - options: Object.values(vislibColorMaps).map(value => value.id), + options: Object.values(vislibColorMaps).map((value: any) => value.id), help: i18n.translate('metricVis.function.colorScheme.help', { - defaultMessage: 'Color scheme to use' - }) + defaultMessage: 'Color scheme to use', + }), }, colorMode: { types: ['string'], default: '"None"', options: ['None', 'Label', 'Background'], help: i18n.translate('metricVis.function.colorMode.help', { - defaultMessage: 'Which part of metric to color' - }) + defaultMessage: 'Which part of metric to color', + }), }, colorRange: { types: ['range'], multi: true, help: i18n.translate('metricVis.function.colorRange.help', { - defaultMessage: 'A range object specifying groups of values to which different colors should be applied.' - }) + defaultMessage: + 'A range object specifying groups of values to which different colors should be applied.', + }), }, useRanges: { types: ['boolean'], default: false, help: i18n.translate('metricVis.function.useRanges.help', { - defaultMessage: 'Enabled color ranges.' - }) + defaultMessage: 'Enabled color ranges.', + }), }, invertColors: { types: ['boolean'], default: false, help: i18n.translate('metricVis.function.invertColors.help', { - defaultMessage: 'Inverts the color ranges' - }) + defaultMessage: 'Inverts the color ranges', + }), }, showLabels: { types: ['boolean'], default: true, help: i18n.translate('metricVis.function.showLabels.help', { - defaultMessage: 'Shows labels under the metric values.' - }) + defaultMessage: 'Shows labels under the metric values.', + }), }, bgFill: { types: ['string'], default: '"#000"', aliases: ['backgroundFill', 'bgColor', 'backgroundColor'], help: i18n.translate('metricVis.function.bgFill.help', { - defaultMessage: 'Color as html hex code (#123456), html color (red, blue) or rgba value (rgba(255,255,255,1)).' - }) + defaultMessage: + 'Color as html hex code (#123456), html color (red, blue) or rgba value (rgba(255,255,255,1)).', + }), }, font: { types: ['style'], help: i18n.translate('metricVis.function.font.help', { - defaultMessage: 'Font settings.' + defaultMessage: 'Font settings.', }), default: '{font size=60}', }, @@ -104,13 +168,13 @@ export const metric = () => ({ aliases: ['label', 'text', 'description'], default: '""', help: i18n.translate('metricVis.function.subText.help', { - defaultMessage: 'Custom text to show under the metric' - }) + defaultMessage: 'Custom text to show under the metric', + }), }, metric: { types: ['vis_dimension'], help: i18n.translate('metricVis.function.metric.help', { - defaultMessage: 'metric dimension configuration' + defaultMessage: 'metric dimension configuration', }), required: true, multi: true, @@ -118,13 +182,12 @@ export const metric = () => ({ bucket: { types: ['vis_dimension'], help: i18n.translate('metricVis.function.bucket.help', { - defaultMessage: 'bucket dimension configuration' + defaultMessage: 'bucket dimension configuration', }), }, }, - fn(context, args) { - - const dimensions = { + fn(context: Context, args: Arguments) { + const dimensions: DimensionsVisParam = { metrics: args.metric, }; @@ -133,10 +196,10 @@ export const metric = () => ({ } if (args.percentage && (!args.colorRange || args.colorRange.length === 0)) { - throw new Error ('colorRange must be provided when using percentage'); + throw new Error('colorRange must be provided when using percentage'); } - const fontSize = parseInt(args.font.spec.fontSize); + const fontSize = Number.parseInt(args.font.spec.fontSize, 10); return { type: 'render', @@ -161,16 +224,14 @@ export const metric = () => ({ labelColor: args.colorMode === 'Labels', subText: args.subText, fontSize, - } + }, }, dimensions, }, params: { listenOnChange: true, - } + }, }, }; }, }); - -functionsRegistry.register(metric); diff --git a/src/legacy/core_plugins/metric_vis/public/metric_vis.js b/src/legacy/core_plugins/metric_vis/public/metric_vis_type.ts similarity index 64% rename from src/legacy/core_plugins/metric_vis/public/metric_vis.js rename to src/legacy/core_plugins/metric_vis/public/metric_vis_type.ts index 70ccab3af97d4..02123f616e59f 100644 --- a/src/legacy/core_plugins/metric_vis/public/metric_vis.js +++ b/src/legacy/core_plugins/metric_vis/public/metric_vis_type.ts @@ -17,30 +17,25 @@ * under the License. */ -import './metric_vis_params'; import { i18n } from '@kbn/i18n'; -import { VisFactoryProvider } from 'ui/vis/vis_factory'; + +// @ts-ignore import { Schemas } from 'ui/vis/editors/default/schemas'; -import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; +// @ts-ignore import { vislibColorMaps } from 'ui/vislib/components/color/colormaps'; -import { MetricVisComponent } from './metric_vis_controller'; -// we need to load the css ourselves - -// we also need to load the controller and used by the template +// @ts-ignore +import { MetricVisComponent } from './components/metric_vis_controller'; -// register the provider with the visTypes registry -VisTypesRegistryProvider.register(MetricVisProvider); +import { visFactory } from '../../visualizations/public'; -function MetricVisProvider(Private) { - const VisFactory = Private(VisFactoryProvider); - - // return the visType object, which kibana will use to display and configure new - // Vis object of this type. - return VisFactory.createReactVisualization({ +export const createMetricVisTypeDefinition = () => { + return visFactory.createReactVisualization({ name: 'metric', title: i18n.translate('metricVis.metricTitle', { defaultMessage: 'Metric' }), icon: 'visMetric', - description: i18n.translate('metricVis.metricDescription', { defaultMessage: 'Display a calculation as a single number' }), + description: i18n.translate('metricVis.metricDescription', { + defaultMessage: 'Display a calculation as a single number', + }), visConfig: { component: MetricVisComponent, defaults: { @@ -52,11 +47,9 @@ function MetricVisProvider(Private) { useRanges: false, colorSchema: 'Green to Red', metricColorMode: 'None', - colorsRange: [ - { from: 0, to: 10000 } - ], + colorsRange: [{ from: 0, to: 10000 }], labels: { - show: true + show: true, }, invertColors: false, style: { @@ -65,27 +58,36 @@ function MetricVisProvider(Private) { labelColor: false, subText: '', fontSize: 60, - } - } - } + }, + }, + }, }, editorConfig: { collections: { metricColorMode: [ { id: 'None', - label: i18n.translate('metricVis.colorModes.noneOptionLabel', { defaultMessage: 'None' }) + label: i18n.translate('metricVis.colorModes.noneOptionLabel', { + defaultMessage: 'None', + }), }, { id: 'Labels', - label: i18n.translate('metricVis.colorModes.labelsOptionLabel', { defaultMessage: 'Labels' }) + label: i18n.translate('metricVis.colorModes.labelsOptionLabel', { + defaultMessage: 'Labels', + }), }, { id: 'Background', - label: i18n.translate('metricVis.colorModes.backgroundOptionLabel', { defaultMessage: 'Background' }) - } + label: i18n.translate('metricVis.colorModes.backgroundOptionLabel', { + defaultMessage: 'Background', + }), + }, ], - colorSchemas: Object.values(vislibColorMaps).map(value => ({ id: value.id, label: value.label })), + colorSchemas: Object.values(vislibColorMaps).map((value: any) => ({ + id: value.id, + label: value.label, + })), }, optionsTemplate: '', schemas: new Schemas([ @@ -95,28 +97,37 @@ function MetricVisProvider(Private) { title: i18n.translate('metricVis.schemas.metricTitle', { defaultMessage: 'Metric' }), min: 1, aggFilter: [ - '!std_dev', '!geo_centroid', - '!derivative', '!serial_diff', '!moving_avg', '!cumulative_sum', '!geo_bounds'], + '!std_dev', + '!geo_centroid', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + ], aggSettings: { top_hits: { - allowStrings: true + allowStrings: true, }, }, defaults: [ - { type: 'count', schema: 'metric' } - ] - }, { + { + type: 'count', + schema: 'metric', + }, + ], + }, + { group: 'buckets', name: 'group', - title: i18n.translate('metricVis.schemas.splitGroupTitle', { defaultMessage: 'Split group' }), + title: i18n.translate('metricVis.schemas.splitGroupTitle', { + defaultMessage: 'Split group', + }), min: 0, max: 1, - aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'] - } - ]) - } + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + }, + ]), + }, }); -} - -// export the provider so that the visType can be required with Private() -export default MetricVisProvider; +}; diff --git a/src/legacy/core_plugins/metric_vis/public/plugin.ts b/src/legacy/core_plugins/metric_vis/public/plugin.ts new file mode 100644 index 0000000000000..d99df03fcc560 --- /dev/null +++ b/src/legacy/core_plugins/metric_vis/public/plugin.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../../core/public'; +import { LegacyDependenciesPlugin } from './shim'; +import { Plugin as DataPublicPlugin } from '../../../../plugins/data/public'; +import { VisualizationsSetup } from '../../visualizations/public'; + +import { createMetricVisFn } from './metric_vis_fn'; +// @ts-ignore +import { createMetricVisTypeDefinition } from './metric_vis_type'; + +/** @internal */ +export interface MetricVisPluginSetupDependencies { + data: ReturnType; + visualizations: VisualizationsSetup; + __LEGACY: LegacyDependenciesPlugin; +} + +/** @internal */ +export class MetricVisPlugin implements Plugin { + initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this.initializerContext = initializerContext; + } + + public setup( + core: CoreSetup, + { data, visualizations, __LEGACY }: MetricVisPluginSetupDependencies + ) { + __LEGACY.setup(); + + data.expressions.registerFunction(createMetricVisFn); + visualizations.types.VisTypesRegistryProvider.register(createMetricVisTypeDefinition); + } + + public start(core: CoreStart) { + // nothing to do here yet + } +} diff --git a/src/legacy/core_plugins/table_vis/public/shim/index.ts b/src/legacy/core_plugins/metric_vis/public/shim/index.ts similarity index 100% rename from src/legacy/core_plugins/table_vis/public/shim/index.ts rename to src/legacy/core_plugins/metric_vis/public/shim/index.ts diff --git a/src/legacy/core_plugins/metric_vis/public/shim/legacy_dependencies_plugin.ts b/src/legacy/core_plugins/metric_vis/public/shim/legacy_dependencies_plugin.ts new file mode 100644 index 0000000000000..9d5f49882ab8c --- /dev/null +++ b/src/legacy/core_plugins/metric_vis/public/shim/legacy_dependencies_plugin.ts @@ -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 { CoreStart, Plugin } from 'kibana/public'; +import { initMetricVisLegacyModule } from './metric_vis_legacy_module'; + +export class LegacyDependenciesPlugin implements Plugin { + public setup() { + // Init kibana/metric_vis AngularJS module. + initMetricVisLegacyModule(); + } + + public start(core: CoreStart) { + // nothing to do here yet + } +} diff --git a/src/legacy/core_plugins/data/public/filter/filter_bar/directive.js b/src/legacy/core_plugins/metric_vis/public/shim/metric_vis_legacy_module.ts similarity index 70% rename from src/legacy/core_plugins/data/public/filter/filter_bar/directive.js rename to src/legacy/core_plugins/metric_vis/public/shim/metric_vis_legacy_module.ts index 50559cca3ffaf..25942b13a4103 100644 --- a/src/legacy/core_plugins/data/public/filter/filter_bar/directive.js +++ b/src/legacy/core_plugins/metric_vis/public/shim/metric_vis_legacy_module.ts @@ -16,16 +16,16 @@ * specific language governing permissions and limitations * under the License. */ +import { once } from 'lodash'; -import 'ngreact'; -import { wrapInI18nContext } from 'ui/i18n'; +// @ts-ignore import { uiModules } from 'ui/modules'; -import { FilterBar } from './filter_bar'; +// @ts-ignore +import 'ui/directives/inequality'; +// @ts-ignore +import { MetricVisParams } from '../components/metric_vis_params'; -const app = uiModules.get('app/kibana', ['react']); - -export function setupDirective() { - app.directive('filterBar', reactDirective => { - return reactDirective(wrapInI18nContext(FilterBar)); - }); -} +/** @internal */ +export const initMetricVisLegacyModule = once((): void => { + uiModules.get('kibana/metric_vis', ['kibana']).directive('metricVisParams', MetricVisParams); +}); diff --git a/src/legacy/core_plugins/metrics/index.ts b/src/legacy/core_plugins/metrics/index.ts new file mode 100644 index 0000000000000..128f8d6a72944 --- /dev/null +++ b/src/legacy/core_plugins/metrics/index.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; +import { Legacy } from 'kibana'; +import { PluginInitializerContext } from 'src/core/server'; +import { CoreSetup } from 'src/core/server'; + +import { plugin } from './server/'; +import { CustomCoreSetup } from './server/plugin'; + +import { LegacyPluginApi, LegacyPluginInitializer } from '../../../../src/legacy/types'; + +const metricsPluginInitializer: LegacyPluginInitializer = ({ Plugin }: LegacyPluginApi) => + new Plugin({ + id: 'metrics', + require: ['kibana', 'elasticsearch', 'visualizations', 'interpreter', 'data'], + publicDir: resolve(__dirname, 'public'), + uiExports: { + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + hacks: [resolve(__dirname, 'public/legacy')], + injectDefaultVars: server => ({}), + }, + init: (server: Legacy.Server) => { + const initializerContext = {} as PluginInitializerContext; + const core = { http: { server } } as CoreSetup & CustomCoreSetup; + + plugin(initializerContext).setup(core); + }, + config(Joi: any) { + return Joi.object({ + enabled: Joi.boolean().default(true), + chartResolution: Joi.number().default(150), + minimumBucketSize: Joi.number().default(10), + }).default(); + }, + } as Legacy.PluginSpecOptions); + +// eslint-disable-next-line import/no-default-export +export default metricsPluginInitializer; diff --git a/src/legacy/core_plugins/metrics/public/components/annotations_editor.js b/src/legacy/core_plugins/metrics/public/components/annotations_editor.js index ce70b6d32df6a..7236a778703a3 100644 --- a/src/legacy/core_plugins/metrics/public/components/annotations_editor.js +++ b/src/legacy/core_plugins/metrics/public/components/annotations_editor.js @@ -29,8 +29,7 @@ import uuid from 'uuid'; import { IconSelect } from './icon_select'; import { YesNo } from './yes_no'; import { Storage } from 'ui/storage'; -import { data } from 'plugins/data/setup'; -const { QueryBarInput } = data.query.ui; +import { QueryBarInput } from 'plugins/data'; import { getDefaultQueryLanguage } from './lib/get_default_query_language'; import { diff --git a/src/legacy/core_plugins/metrics/public/components/color_picker.js b/src/legacy/core_plugins/metrics/public/components/color_picker.js index a2f50dafbbac6..06868a73c6c82 100644 --- a/src/legacy/core_plugins/metrics/public/components/color_picker.js +++ b/src/legacy/core_plugins/metrics/public/components/color_picker.js @@ -24,49 +24,43 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { EuiIconTip } from '@elastic/eui'; import { CustomColorPicker } from './custom_color_picker'; -import { injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; -class ColorPickerUI extends Component { +export class ColorPicker extends Component { constructor(props) { super(props); this.state = { displayPicker: false, color: {}, }; - - this.handleClick = this.handleClick.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleClear = this.handleClear.bind(this); - this.handleClose = this.handleClose.bind(this); } - handleChange(color) { + handleChange = color => { const { rgb } = color; const part = {}; part[this.props.name] = `rgba(${rgb.r},${rgb.g},${rgb.b},${rgb.a})`; if (this.props.onChange) this.props.onChange(part); - } + }; - handleClick() { + handleClick = () => { this.setState({ displayPicker: !this.state.displayColorPicker }); - } + }; - handleClose() { + handleClose = () => { this.setState({ displayPicker: false }); - } + }; - handleClear() { + handleClear = () => { const part = {}; part[this.props.name] = null; this.props.onChange(part); - } + }; renderSwatch() { if (!this.props.value) { return ( ')(scope); - } - - function createKeydownEvent(keyCode) { - const e = angular.element.Event('keydown'); // eslint-disable-line new-cap - e.which = keyCode; - return e; - } - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject((_$rootScope_, _$compile_) => { - $compile = _$compile_; - $rootScope = _$rootScope_; - })); - - it('should call the callback when pressing up', () => { - const spy = sinon.spy(); - const button = createTestButton(spy); - button.trigger(createKeydownEvent(keyCodes.UP)); - expect(spy.calledWith(Direction.up)).to.be(true); - }); - - it('should call the callback when pressing down', () => { - const spy = sinon.spy(); - const button = createTestButton(spy); - button.trigger(createKeydownEvent(keyCodes.DOWN)); - expect(spy.calledWith(Direction.down)).to.be(true); - }); -}); diff --git a/src/legacy/ui/public/vis/editors/default/_agg.scss b/src/legacy/ui/public/vis/editors/default/_agg.scss index e5bcc31c5b534..801999c35c00e 100644 --- a/src/legacy/ui/public/vis/editors/default/_agg.scss +++ b/src/legacy/ui/public/vis/editors/default/_agg.scss @@ -25,13 +25,6 @@ } } -/** - * 1. Hack to split child elements evenly. - */ -.visEditorAgg__formRow--split { - flex: 1 1 0 !important; /* 1 */ -} - .visEditorAgg__sliderValue { @include euiFontSize; align-self: center; diff --git a/src/legacy/ui/public/vis/editors/default/_agg_select.scss b/src/legacy/ui/public/vis/editors/default/_agg_select.scss deleted file mode 100644 index 0ecbccade6044..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/_agg_select.scss +++ /dev/null @@ -1,7 +0,0 @@ -.visEditorAggSelect__helpLink { - @include euiFontSizeXS; -} - -.visEditorAggSelect__formRow { - margin-bottom: $euiSizeS; -} diff --git a/src/legacy/ui/public/vis/editors/default/_index.scss b/src/legacy/ui/public/vis/editors/default/_index.scss index 3f04e89846f47..4d578086e6113 100644 --- a/src/legacy/ui/public/vis/editors/default/_index.scss +++ b/src/legacy/ui/public/vis/editors/default/_index.scss @@ -10,4 +10,3 @@ $vis-editor-resizer-width: $euiSizeM; // Components @import './agg'; @import './agg_params'; -@import './agg_select'; diff --git a/src/legacy/ui/public/vis/editors/default/_sidebar.scss b/src/legacy/ui/public/vis/editors/default/_sidebar.scss index db9c3337beed4..4ad13f4417692 100644 --- a/src/legacy/ui/public/vis/editors/default/_sidebar.scss +++ b/src/legacy/ui/public/vis/editors/default/_sidebar.scss @@ -119,7 +119,7 @@ // Collapsible section .visEditorSidebar__collapsible { - background-color: transparentize($euiColorLightShade, .85); + background-color: lightOrDarkTheme($euiPageBackgroundColor, $euiColorLightestShade); } .visEditorSidebar__collapsible--margin { @@ -170,12 +170,6 @@ @include euiTextTruncate; } -.visEditorSidebar__collapsibleTitleDescription--danger { - color: $euiColorDanger; - font-weight: $euiFontWeightBold; -} - - // // FORMS // @@ -225,3 +219,11 @@ margin-top: $euiSizeS; margin-bottom: $euiSizeS; } + +.visEditorSidebar__aggGroupAccordionButtonContent { + font-size: $euiFontSizeS; + + span { + color: $euiColorDarkShade; + } +} diff --git a/src/legacy/ui/public/vis/editors/default/agg.html b/src/legacy/ui/public/vis/editors/default/agg.html deleted file mode 100644 index 4c010d575f194..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/agg.html +++ /dev/null @@ -1,143 +0,0 @@ -
- -
- - - - - - - {{ describe() }} - - - - - - - -
- - - - - - - - - - - - -
- -
- - - - - - -
- - - diff --git a/src/legacy/ui/public/vis/editors/default/agg.js b/src/legacy/ui/public/vis/editors/default/agg.js deleted file mode 100644 index f3f835043c864..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/agg.js +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@kbn/i18n'; -import './agg_params'; -import './agg_add'; -import './controls/agg_controls'; -import { Direction } from './keyboard_move'; -import _ from 'lodash'; -import './fancy_forms'; -import { uiModules } from '../../../modules'; -import aggTemplate from './agg.html'; -import { move } from '../../../utils/collection'; - -uiModules - .get('app/visualize') - .directive('visEditorAgg', () => { - return { - restrict: 'A', - template: aggTemplate, - require: ['^form', '^ngModel'], - link: function ($scope, $el, attrs, [kbnForm, ngModelCtrl]) { - $scope.editorOpen = !!$scope.agg.brandNew; - $scope.aggIsTooLow = false; - - $scope.$watch('editorOpen', function (open) { - // make sure that all of the form inputs are "touched" - // so that their errors propagate - if (!open) kbnForm.$setTouched(); - }); - - $scope.$watchMulti([ - '$index', - 'group.length' - ], function () { - $scope.aggIsTooLow = calcAggIsTooLow(); - }); - - if ($scope.groupName === 'buckets') { - $scope.$watchMulti([ - '$last', - 'lastParentPipelineAggTitle', - 'agg.type' - ], function ([isLastBucket, lastParentPipelineAggTitle, aggType]) { - $scope.error = null; - $scope.disabledParams = []; - - if (!lastParentPipelineAggTitle || !isLastBucket || !aggType) { - return; - } - - if (['date_histogram', 'histogram'].includes(aggType.name)) { - $scope.onAggParamsChange( - $scope.agg.params, - 'min_doc_count', - // "histogram" agg has an editor for "min_doc_count" param, which accepts boolean - // "date_histogram" agg doesn't have an editor for "min_doc_count" param, it should be set as a numeric value - aggType.name === 'histogram' ? true : 0); - $scope.disabledParams = ['min_doc_count']; - } else { - $scope.error = i18n.translate('common.ui.aggTypes.metrics.wrongLastBucketTypeErrorMessage', { - defaultMessage: 'Last bucket aggregation must be "Date Histogram" or "Histogram" when using "{type}" metric aggregation.', - values: { type: lastParentPipelineAggTitle }, - description: 'Date Histogram and Histogram should not be translated', - }); - } - }); - } - - /** - * Describe the aggregation, for display in the collapsed agg header - * @return {[type]} [description] - */ - $scope.describe = function () { - if (!$scope.agg.type || !$scope.agg.type.makeLabel) return ''; - const label = $scope.agg.type.makeLabel($scope.agg); - return label ? label : ''; - }; - - $scope.$on('drag-start', () => { - $scope.editorWasOpen = $scope.editorOpen; - $scope.editorOpen = false; - $scope.$emit('agg-drag-start', $scope.agg); - }); - - $scope.$on('drag-end', () => { - $scope.editorOpen = $scope.editorWasOpen; - $scope.$emit('agg-drag-end', $scope.agg); - }); - - /** - * Move aggregations down/up in the priority list by pressing arrow keys. - */ - $scope.onPriorityReorder = function (direction) { - const positionOffset = direction === Direction.down ? 1 : -1; - - const currentPosition = $scope.group.indexOf($scope.agg); - const newPosition = Math.max(0, Math.min(currentPosition + positionOffset, $scope.group.length - 1)); - move($scope.group, currentPosition, newPosition); - $scope.$emit('agg-reorder'); - }; - - $scope.remove = function (agg) { - const aggs = $scope.state.aggs; - const index = aggs.indexOf(agg); - - if (index === -1) { - return; - } - - aggs.splice(index, 1); - }; - - $scope.canRemove = function (aggregation) { - const metricCount = _.reduce($scope.group, function (count, agg) { - return (agg.schema.name === aggregation.schema.name) ? ++count : count; - }, 0); - - // make sure the the number of these aggs is above the min - return metricCount > aggregation.schema.min; - }; - - function calcAggIsTooLow() { - if (!$scope.agg.schema.mustBeFirst) { - return false; - } - - const firstDifferentSchema = _.findIndex($scope.group, function (agg) { - return agg.schema !== $scope.agg.schema; - }); - - if (firstDifferentSchema === -1) { - return false; - } - - return $scope.$index > firstDifferentSchema; - } - - // The model can become touched either onBlur event or when the form is submitted. - // We watch $touched to identify when the form is submitted. - $scope.$watch(() => { - return ngModelCtrl.$touched; - }, (value) => { - $scope.formIsTouched = value; - }, true); - - $scope.onAggTypeChange = (agg, value) => { - if (agg.type !== value) { - agg.type = value; - } - }; - - $scope.onAggParamsChange = (params, paramName, value) => { - if (params[paramName] !== value) { - params[paramName] = value; - } - }; - - $scope.setValidity = (isValid) => { - ngModelCtrl.$setValidity(`aggParams${$scope.agg.id}`, isValid); - }; - - $scope.setTouched = (isTouched) => { - if (isTouched) { - ngModelCtrl.$setTouched(); - } else { - ngModelCtrl.$setUntouched(); - } - }; - } - }; - }); diff --git a/src/legacy/ui/public/vis/editors/default/agg_group.html b/src/legacy/ui/public/vis/editors/default/agg_group.html deleted file mode 100644 index b703d23b4f149..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/agg_group.html +++ /dev/null @@ -1,25 +0,0 @@ -
-
- {{ groupNameLabel }} -
- -
-
- - -
-
-
- - - -
-
- -
diff --git a/src/legacy/ui/public/vis/editors/default/agg_group.js b/src/legacy/ui/public/vis/editors/default/agg_group.js index 054f4e086b912..e357da8156b9f 100644 --- a/src/legacy/ui/public/vis/editors/default/agg_group.js +++ b/src/legacy/ui/public/vis/editors/default/agg_group.js @@ -17,79 +17,80 @@ * under the License. */ -import _ from 'lodash'; -import './agg'; -import './agg_add'; - +import 'ngreact'; +import { wrapInI18nContext } from 'ui/i18n'; import { uiModules } from '../../../modules'; -import aggGroupTemplate from './agg_group.html'; -import { move } from '../../../utils/collection'; -import { aggGroupNameMaps } from './agg_group_names'; -import { AggConfig } from '../../agg_config'; - -import '../../draggable/draggable_container'; -import '../../draggable/draggable_item'; -import '../../draggable/draggable_handle'; +import { DefaultEditorAggGroup } from './components/default_editor_agg_group'; uiModules .get('app/visualize') + .directive('visEditorAggGroupWrapper', reactDirective => + reactDirective(wrapInI18nContext(DefaultEditorAggGroup), [ + ['metricAggs', { watchDepth: 'reference' }], // we watch reference to identify each aggs change in useEffects + ['schemas', { watchDepth: 'collection' }], + ['state', { watchDepth: 'reference' }], + ['addSchema', { watchDepth: 'reference' }], + ['onAggParamsChange', { watchDepth: 'reference' }], + ['onAggTypeChange', { watchDepth: 'reference' }], + ['onToggleEnableAgg', { watchDepth: 'reference' }], + ['removeAgg', { watchDepth: 'reference' }], + ['reorderAggs', { watchDepth: 'reference' }], + ['setTouched', { watchDepth: 'reference' }], + ['setValidity', { watchDepth: 'reference' }], + 'groupName', + 'formIsTouched', + 'lastParentPipelineAggTitle', + ]) + ) .directive('visEditorAggGroup', function () { - return { restrict: 'E', - template: aggGroupTemplate, scope: true, - link: function ($scope, $el, attr) { + require: '?^ngModel', + template: function () { + return ``; + }, + link: function ($scope, $el, attr, ngModelCtrl) { $scope.groupName = attr.groupName; - $scope.groupNameLabel = aggGroupNameMaps()[$scope.groupName]; - $scope.$bind('group', 'state.aggs.bySchemaGroup["' + $scope.groupName + '"]'); - $scope.$bind('schemas', 'vis.type.schemas["' + $scope.groupName + '"]'); - - $scope.$watchMulti([ - 'schemas', - '[]group' - ], function () { - const stats = $scope.stats = { - min: 0, - max: 0, - count: $scope.group ? $scope.group.length : 0 - }; - - if (!$scope.schemas) return; + $scope.$bind('schemas', attr.schemas); + // The model can become touched either onBlur event or when the form is submitted. + // We also watch $touched to identify when the form is submitted. + $scope.$watch( + () => { + return ngModelCtrl.$touched; + }, + value => { + $scope.formIsTouched = value; + } + ); - $scope.schemas.forEach(function (schema) { - stats.min += schema.min; - stats.max += schema.max; - stats.deprecate = schema.deprecate; - }); - }); - - function reorderFinished() { - //the aggs have been reordered in [group] and we need - //to apply that ordering to [vis.aggs] - const indexOffset = $scope.state.aggs.indexOf($scope.group[0]); - _.forEach($scope.group, (agg, index) => { - move($scope.state.aggs, agg, indexOffset + index); - }); - } - - $scope.$on('agg-reorder', reorderFinished); - $scope.$on('agg-drag-start', () => $scope.dragging = true); - $scope.$on('agg-drag-end', () => { - $scope.dragging = false; - reorderFinished(); - }); - - $scope.addSchema = function (schema) { - const aggConfig = new AggConfig($scope.state.aggs, { - schema, - id: AggConfig.nextId($scope.state.aggs), - }); - aggConfig.brandNew = true; + $scope.setValidity = isValid => { + ngModelCtrl.$setValidity(`aggGroup${$scope.groupName}`, isValid); + }; - $scope.state.aggs.push(aggConfig); + $scope.setTouched = isTouched => { + if (isTouched) { + ngModelCtrl.$setTouched(); + } else { + ngModelCtrl.$setUntouched(); + } }; - } + }, }; - }); diff --git a/src/legacy/ui/public/vis/editors/default/agg_groups.ts b/src/legacy/ui/public/vis/editors/default/agg_groups.ts index 9bfa99f8d4c94..f55e6ecd79155 100644 --- a/src/legacy/ui/public/vis/editors/default/agg_groups.ts +++ b/src/legacy/ui/public/vis/editors/default/agg_groups.ts @@ -17,7 +17,18 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; + export enum AggGroupNames { Buckets = 'buckets', Metrics = 'metrics', } + +export const aggGroupNamesMap = () => ({ + [AggGroupNames.Metrics]: i18n.translate('common.ui.vis.editors.aggGroups.metricsText', { + defaultMessage: 'Metrics', + }), + [AggGroupNames.Buckets]: i18n.translate('common.ui.vis.editors.aggGroups.bucketsText', { + defaultMessage: 'Buckets', + }), +}); diff --git a/src/legacy/ui/public/vis/editors/default/agg_params.js b/src/legacy/ui/public/vis/editors/default/agg_params.js deleted file mode 100644 index fe942d9eb2272..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/agg_params.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import 'ngreact'; -import { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from '../../../modules'; -import { DefaultEditorAggParams } from './components/default_editor_agg_params'; - -uiModules - .get('app/visualize') - .directive('visEditorAggParams', reactDirective => reactDirective(wrapInI18nContext(DefaultEditorAggParams), [ - ['agg', { watchDepth: 'reference' }], - ['aggParams', { watchDepth: 'collection' }], - ['indexPattern', { watchDepth: 'reference' }], - ['metricAggs', { watchDepth: 'reference' }], // we watch reference to identify each aggs change in useEffects - ['state', { watchDepth: 'reference' }], - ['onAggTypeChange', { watchDepth: 'reference' }], - ['onAggParamsChange', { watchDepth: 'reference' }], - ['setTouched', { watchDepth: 'reference' }], - ['setValidity', { watchDepth: 'reference' }], - 'aggError', - 'aggIndex', - 'disabledParams', - 'groupName', - 'aggIsTooLow', - 'formIsTouched', - ])); diff --git a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg.test.tsx.snap new file mode 100644 index 0000000000000..5790e0d4e872f --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultEditorAgg component should init with the default set of props 1`] = ` + + Schema name + + + } + buttonContentClassName="visEditorSidebar__aggGroupAccordionButtonContent eui-textTruncate" + className="visEditorSidebar__section visEditorSidebar__collapsible visEditorSidebar__collapsible--marginBottom" + data-test-subj="visEditorAggAccordion1" + extraAction={ +
+ + + +
+ } + id="visEditorAggAccordion1" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" +> + + +
+`; diff --git a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_group.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_group.test.tsx.snap new file mode 100644 index 0000000000000..813b7978d2667 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_group.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DefaultEditorAgg component should init with the default set of props 1`] = ` + + + +
+ Metrics +
+
+ + + + + + + + + +
+
+`; diff --git a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_params.test.tsx.snap b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_params.test.tsx.snap index b4d796443b554..018fe0b7dbd3c 100644 --- a/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_params.test.tsx.snap +++ b/src/legacy/ui/public/vis/editors/default/components/__snapshots__/default_editor_agg_params.test.tsx.snap @@ -46,39 +46,43 @@ exports[`DefaultEditorAggParams component should init with the default set of pa } } /> - - - + + - - + /> + + `; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.test.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.test.tsx new file mode 100644 index 0000000000000..ce12b18cf777a --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.test.tsx @@ -0,0 +1,279 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import { VisState } from 'ui/vis'; +import { AggGroupNames } from '../agg_groups'; +import { DefaultEditorAgg, DefaultEditorAggProps } from './default_editor_agg'; +import { act } from 'react-dom/test-utils'; +import { DefaultEditorAggParams } from './default_editor_agg_params'; + +jest.mock('./default_editor_agg_params', () => ({ + DefaultEditorAggParams: () => null, +})); + +describe('DefaultEditorAgg component', () => { + let defaultProps: DefaultEditorAggProps; + let onAggParamsChange: jest.Mock; + let setTouched: jest.Mock; + let onToggleEnableAgg: jest.Mock; + let removeAgg: jest.Mock; + let setValidity: jest.Mock; + + beforeEach(() => { + onAggParamsChange = jest.fn(); + setTouched = jest.fn(); + onToggleEnableAgg = jest.fn(); + removeAgg = jest.fn(); + setValidity = jest.fn(); + + defaultProps = { + agg: { + id: 1, + brandNew: true, + getIndexPattern: () => ({}), + schema: { title: 'Schema name' }, + title: 'Metrics', + params: {}, + }, + aggIndex: 0, + aggIsTooLow: false, + dragHandleProps: null, + formIsTouched: false, + groupName: AggGroupNames.Metrics, + isDraggable: false, + isLastBucket: false, + isRemovable: false, + metricAggs: [], + state: {} as VisState, + onAggParamsChange, + onAggTypeChange: () => {}, + setValidity, + setTouched, + onToggleEnableAgg, + removeAgg, + }; + }); + + it('should init with the default set of props', () => { + const comp = shallow(); + + expect(comp).toMatchSnapshot(); + }); + + it('should open accordion initially', () => { + const comp = shallow(); + + expect(comp.props()).toHaveProperty('initialIsOpen', true); + }); + + it('should not show description when agg is invalid', () => { + defaultProps.agg.brandNew = false; + const comp = mount(); + + act(() => { + comp + .find(DefaultEditorAggParams) + .props() + .setValidity(false); + }); + comp.update(); + expect(setValidity).toBeCalledWith(false); + + expect( + comp.find('.visEditorSidebar__aggGroupAccordionButtonContent span').exists() + ).toBeFalsy(); + }); + + it('should show description when agg is valid', () => { + defaultProps.agg.brandNew = false; + defaultProps.agg.type = { + makeLabel: () => 'Agg description', + }; + const comp = mount(); + + act(() => { + comp + .find(DefaultEditorAggParams) + .props() + .setValidity(true); + }); + comp.update(); + expect(setValidity).toBeCalledWith(true); + + expect(comp.find('.visEditorSidebar__aggGroupAccordionButtonContent span').text()).toBe( + 'Agg description' + ); + }); + + it('should call setTouched when accordion is collapsed', () => { + const comp = mount(); + expect(defaultProps.setTouched).toBeCalledTimes(0); + + comp.find('.euiAccordion__button').simulate('click'); + // make sure that the accordion is collapsed + expect(comp.find('.euiAccordion-isOpen').exists()).toBeFalsy(); + + expect(defaultProps.setTouched).toBeCalledWith(true); + }); + + it('should call setValidity inside onSetValidity', () => { + const comp = mount(); + + act(() => { + comp + .find(DefaultEditorAggParams) + .props() + .setValidity(false); + }); + + expect(setValidity).toBeCalledWith(false); + + expect( + comp.find('.visEditorSidebar__aggGroupAccordionButtonContent span').exists() + ).toBeFalsy(); + }); + + it('should add schema component', () => { + defaultProps.agg.schema = { + editorComponent: () =>
, + }; + const comp = mount(); + + expect(comp.find('.schemaComponent').exists()).toBeTruthy(); + }); + + describe('agg actions', () => { + beforeEach(() => { + defaultProps.agg.enabled = true; + }); + + it('should not have actions', () => { + const comp = shallow(); + const actions = shallow(comp.prop('extraAction')); + + expect(actions.children().exists()).toBeFalsy(); + }); + + it('should have disable and remove actions', () => { + defaultProps.isRemovable = true; + const comp = mount(); + + expect( + comp.find('[data-test-subj="toggleDisableAggregationBtn disable"] button').exists() + ).toBeTruthy(); + expect(comp.find('[data-test-subj="removeDimensionBtn"] button').exists()).toBeTruthy(); + }); + + it('should have draggable action', () => { + defaultProps.isDraggable = true; + const comp = mount(); + + expect(comp.find('[data-test-subj="dragHandleBtn"]').exists()).toBeTruthy(); + }); + + it('should disable agg', () => { + defaultProps.isRemovable = true; + const comp = mount(); + comp.find('[data-test-subj="toggleDisableAggregationBtn disable"] button').simulate('click'); + + expect(defaultProps.onToggleEnableAgg).toBeCalledWith(defaultProps.agg, false); + }); + + it('should enable agg', () => { + defaultProps.agg.enabled = false; + const comp = mount(); + comp.find('[data-test-subj="toggleDisableAggregationBtn enable"] button').simulate('click'); + + expect(defaultProps.onToggleEnableAgg).toBeCalledWith(defaultProps.agg, true); + }); + + it('should call removeAgg', () => { + defaultProps.isRemovable = true; + const comp = mount(); + comp.find('[data-test-subj="removeDimensionBtn"] button').simulate('click'); + + expect(defaultProps.removeAgg).toBeCalledWith(defaultProps.agg); + }); + }); + + describe('last bucket', () => { + beforeEach(() => { + defaultProps.isLastBucket = true; + defaultProps.lastParentPipelineAggTitle = 'ParentPipelineAgg'; + }); + + it('should disable min_doc_count when agg is histogram or date_histogram', () => { + defaultProps.agg.type = { + name: 'histogram', + }; + const compHistogram = shallow(); + defaultProps.agg.type = { + name: 'date_histogram', + }; + const compDateHistogram = shallow(); + + expect(compHistogram.find(DefaultEditorAggParams).props()).toHaveProperty('disabledParams', [ + 'min_doc_count', + ]); + expect(compDateHistogram.find(DefaultEditorAggParams).props()).toHaveProperty( + 'disabledParams', + ['min_doc_count'] + ); + }); + + it('should set error when agg is not histogram or date_histogram', () => { + defaultProps.agg.type = { + name: 'aggType', + }; + const comp = shallow(); + + expect(comp.find(DefaultEditorAggParams).prop('aggError')).toBeDefined(); + }); + + it('should set min_doc_count to true when agg type was changed to histogram', () => { + defaultProps.agg.type = { + name: 'aggType', + }; + const comp = mount(); + comp.setProps({ agg: { ...defaultProps.agg, type: { name: 'histogram' } } }); + + expect(defaultProps.onAggParamsChange).toHaveBeenCalledWith( + defaultProps.agg.params, + 'min_doc_count', + true + ); + }); + + it('should set min_doc_count to 0 when agg type was changed to date_histogram', () => { + defaultProps.agg.type = { + name: 'aggType', + }; + const comp = mount(); + comp.setProps({ agg: { ...defaultProps.agg, type: { name: 'date_histogram' } } }); + + expect(defaultProps.onAggParamsChange).toHaveBeenCalledWith( + defaultProps.agg.params, + 'min_doc_count', + 0 + ); + }); + }); +}); diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.tsx new file mode 100644 index 0000000000000..40fb8a7b63a29 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg.tsx @@ -0,0 +1,263 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { + EuiAccordion, + EuiToolTip, + EuiButtonIcon, + EuiSpacer, + EuiIconTip, + Color, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AggConfig } from '../../../'; +import { DefaultEditorAggParams } from './default_editor_agg_params'; +import { DefaultEditorAggCommonProps } from './default_editor_agg_common_props'; + +export interface DefaultEditorAggProps extends DefaultEditorAggCommonProps { + agg: AggConfig; + aggIndex: number; + aggIsTooLow: boolean; + dragHandleProps: {} | null; + isDraggable: boolean; + isLastBucket: boolean; + isRemovable: boolean; +} + +function DefaultEditorAgg({ + agg, + aggIndex, + aggIsTooLow, + dragHandleProps, + formIsTouched, + groupName, + isDraggable, + isLastBucket, + isRemovable, + metricAggs, + lastParentPipelineAggTitle, + state, + onAggParamsChange, + onAggTypeChange, + onToggleEnableAgg, + removeAgg, + setTouched, + setValidity, +}: DefaultEditorAggProps) { + const [isEditorOpen, setIsEditorOpen] = useState(agg.brandNew); + const [validState, setValidState] = useState(true); + const showDescription = !isEditorOpen && validState; + const showError = !isEditorOpen && !validState; + let disabledParams; + let aggError; + // When a Parent Pipeline agg is selected and this agg is the last bucket. + const isLastBucketAgg = isLastBucket && lastParentPipelineAggTitle && agg.type; + + const SchemaComponent = agg.schema.editorComponent; + + if (isLastBucketAgg) { + if (['date_histogram', 'histogram'].includes(agg.type.name)) { + disabledParams = ['min_doc_count']; + } else { + aggError = i18n.translate('common.ui.aggTypes.metrics.wrongLastBucketTypeErrorMessage', { + defaultMessage: + 'Last bucket aggregation must be "Date Histogram" or "Histogram" when using "{type}" metric aggregation.', + values: { type: lastParentPipelineAggTitle }, + description: 'Date Histogram and Histogram should not be translated', + }); + } + } + + useEffect(() => { + if (isLastBucketAgg && ['date_histogram', 'histogram'].includes(agg.type.name)) { + onAggParamsChange( + agg.params, + 'min_doc_count', + // "histogram" agg has an editor for "min_doc_count" param, which accepts boolean + // "date_histogram" agg doesn't have an editor for "min_doc_count" param, it should be set as a numeric value + agg.type.name === 'histogram' ? true : 0 + ); + } + }, [lastParentPipelineAggTitle, isLastBucket, agg.type]); + + // A description of the aggregation, for displaying in the collapsed agg header + const aggDescription = agg.type && agg.type.makeLabel ? agg.type.makeLabel(agg) : ''; + + const onToggle = (isOpen: boolean) => { + setIsEditorOpen(isOpen); + if (!isOpen) { + setTouched(true); + } + }; + + const onSetValidity = (isValid: boolean) => { + setValidity(isValid); + setValidState(isValid); + }; + + const renderAggButtons = () => { + const actionIcons = []; + + if (showError) { + actionIcons.push({ + id: 'hasErrors', + color: 'danger', + type: 'alert', + tooltip: i18n.translate('common.ui.vis.editors.agg.errorsAriaLabel', { + defaultMessage: 'Aggregation has errors', + }), + dataTestSubj: 'hasErrorsAggregationIcon', + }); + } + + if (agg.enabled && isRemovable) { + actionIcons.push({ + id: 'disableAggregation', + color: 'text', + type: 'eye', + onClick: () => onToggleEnableAgg(agg, false), + tooltip: i18n.translate('common.ui.vis.editors.agg.disableAggButtonTooltip', { + defaultMessage: 'Disable aggregation', + }), + dataTestSubj: 'toggleDisableAggregationBtn disable', + }); + } + if (!agg.enabled) { + actionIcons.push({ + id: 'enableAggregation', + color: 'text', + type: 'eyeClosed', + onClick: () => onToggleEnableAgg(agg, true), + tooltip: i18n.translate('common.ui.vis.editors.agg.enableAggButtonTooltip', { + defaultMessage: 'Enable aggregation', + }), + dataTestSubj: 'toggleDisableAggregationBtn enable', + }); + } + if (isDraggable) { + actionIcons.push({ + id: 'dragHandle', + type: 'grab', + tooltip: i18n.translate('common.ui.vis.editors.agg.modifyPriorityButtonTooltip', { + defaultMessage: 'Modify priority by dragging', + }), + dataTestSubj: 'dragHandleBtn', + }); + } + if (isRemovable) { + actionIcons.push({ + id: 'removeDimension', + color: 'danger', + type: 'cross', + onClick: () => removeAgg(agg), + tooltip: i18n.translate('common.ui.vis.editors.agg.removeDimensionButtonTooltip', { + defaultMessage: 'Remove dimension', + }), + dataTestSubj: 'removeDimensionBtn', + }); + } + return ( +
+ {actionIcons.map(icon => { + if (icon.id === 'dragHandle') { + return ( + + ); + } + + return ( + + + + ); + })} +
+ ); + }; + + const buttonContent = ( + <> + {agg.schema.title} {showDescription && {aggDescription}} + + ); + + return ( + + <> + + {SchemaComponent && ( + + )} + + + + ); +} + +export { DefaultEditorAgg }; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_add.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_add.tsx index 1d80d330e893e..f07b363d355b2 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_add.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_add.tsx @@ -39,9 +39,7 @@ interface DefaultEditorAggAddProps { schemas: Schema[]; stats: { max: number; - min: number; count: number; - deprecate: boolean; }; addSchema(schema: Schema): void; } @@ -80,7 +78,7 @@ function DefaultEditorAggAdd({ return count >= schema.max; }; - return stats.max > stats.count ? ( + return ( setIsPopoverOpen(false)} > - {(groupName !== AggGroupNames.Buckets || (!stats.count && !stats.deprecate)) && ( + {(groupName !== AggGroupNames.Buckets || !stats.count) && ( )} - {groupName === AggGroupNames.Buckets && stats.count > 0 && !stats.deprecate && ( + {groupName === AggGroupNames.Buckets && stats.count > 0 && ( - !schema.deprecate && ( - onSelectSchema(schema)} - > - {schema.title} - - ) - )} + items={schemas.map(schema => ( + onSelectSchema(schema)} + > + {schema.title} + + ))} /> - ) : null; + ); } export { DefaultEditorAggAdd }; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_common_props.ts b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_common_props.ts new file mode 100644 index 0000000000000..14ea0a8083e5e --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_common_props.ts @@ -0,0 +1,42 @@ +/* + * 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 { AggType } from 'ui/agg_types'; +import { AggConfig, VisState, AggParams, VisParams } from '../../../'; +import { AggGroupNames } from '../agg_groups'; + +export type OnAggParamsChange = ( + params: AggParams | VisParams, + paramName: string, + value: unknown +) => void; + +export interface DefaultEditorAggCommonProps { + formIsTouched: boolean; + groupName: AggGroupNames; + lastParentPipelineAggTitle?: string; + metricAggs: AggConfig[]; + state: VisState; + onAggParamsChange: OnAggParamsChange; + onAggTypeChange: (agg: AggConfig, aggType: AggType) => void; + onToggleEnableAgg: (agg: AggConfig, isEnable: boolean) => void; + removeAgg: (agg: AggConfig) => void; + setTouched: (isTouched: boolean) => void; + setValidity: (isValid: boolean) => void; +} diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.test.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.test.tsx new file mode 100644 index 0000000000000..17be226e32db6 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.test.tsx @@ -0,0 +1,221 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { VisState } from '../../../'; +import { Schema } from '../schemas'; +import { AggGroupNames } from '../agg_groups'; +import { AggConfig } from '../../../agg_config'; +import { AggConfigs } from '../../../agg_configs'; +import { DefaultEditorAggGroup, DefaultEditorAggGroupProps } from './default_editor_agg_group'; +import { DefaultEditorAgg } from './default_editor_agg'; +import { DefaultEditorAggAdd } from './default_editor_agg_add'; + +jest.mock('@elastic/eui', () => ({ + EuiTitle: 'eui-title', + EuiDragDropContext: 'eui-drag-drop-context', + EuiDroppable: 'eui-droppable', + EuiDraggable: (props: any) => props.children({ dragHandleProps: {} }), + EuiSpacer: 'eui-spacer', + EuiPanel: 'eui-panel', +})); + +jest.mock('./default_editor_agg', () => ({ + DefaultEditorAgg: () =>
, +})); + +jest.mock('./default_editor_agg_add', () => ({ + DefaultEditorAggAdd: () =>
, +})); + +describe('DefaultEditorAgg component', () => { + let defaultProps: DefaultEditorAggGroupProps; + let aggs: AggConfigs; + let setTouched: jest.Mock; + let setValidity: jest.Mock; + let reorderAggs: jest.Mock; + + beforeEach(() => { + setTouched = jest.fn(); + setValidity = jest.fn(); + reorderAggs = jest.fn(); + + aggs = [ + { + id: 1, + title: 'Metrics', + params: { + field: { + type: 'number', + }, + }, + group: 'metrics', + schema: {}, + }, + { + id: 3, + title: 'Agg', + params: { + field: { + type: 'string', + }, + }, + group: 'metrics', + schema: {}, + }, + { + id: 2, + title: 'Buckets', + params: { + field: { + type: 'number', + }, + }, + group: 'buckets', + schema: {}, + }, + ] as AggConfigs; + + Object.defineProperty(aggs, 'bySchemaGroup', { + get: () => + aggs.reduce((acc: { [key: string]: AggConfig }, option: AggConfig) => { + if (acc[option.group]) { + acc[option.group].push(option); + } else { + acc[option.group] = [option]; + } + + return acc; + }, {}), + }); + + defaultProps = { + formIsTouched: false, + metricAggs: [], + groupName: AggGroupNames.Metrics, + state: { + aggs, + } as VisState, + schemas: [ + { + max: 1, + } as Schema, + { + max: 1, + } as Schema, + ], + setTouched, + setValidity, + reorderAggs, + addSchema: () => {}, + removeAgg: () => {}, + onAggParamsChange: () => {}, + onAggTypeChange: () => {}, + onToggleEnableAgg: () => {}, + }; + }); + + it('should init with the default set of props', () => { + const comp = shallow(); + + expect(comp).toMatchSnapshot(); + }); + + it('should call setTouched with false', () => { + mount(); + + expect(setTouched).toBeCalledWith(false); + }); + + it('should mark group as touched when all invalid aggs are touched', () => { + defaultProps.groupName = AggGroupNames.Buckets; + const comp = mount(); + act(() => { + const aggProps = comp.find(DefaultEditorAgg).props(); + aggProps.setValidity(false); + aggProps.setTouched(true); + }); + + expect(setTouched).toBeCalledWith(true); + }); + + it('should mark group as touched when the form applied', () => { + const comp = mount(); + act(() => { + comp + .find(DefaultEditorAgg) + .first() + .props() + .setValidity(false); + }); + expect(setTouched).toBeCalledWith(false); + comp.setProps({ formIsTouched: true }); + + expect(setTouched).toBeCalledWith(true); + }); + + it('should mark group as invalid when at least one agg is invalid', () => { + const comp = mount(); + act(() => { + comp + .find(DefaultEditorAgg) + .first() + .props() + .setValidity(false); + }); + + expect(setValidity).toBeCalledWith(false); + }); + + it('should last bucket has truthy isLastBucket prop', () => { + defaultProps.groupName = AggGroupNames.Buckets; + const comp = mount(); + const lastAgg = comp.find(DefaultEditorAgg).last(); + + expect(lastAgg.props()).toHaveProperty('isLastBucket', true); + }); + + it('should call reorderAggs when dragging ended', () => { + const comp = shallow(); + act(() => { + // simulate dragging ending + comp.props().onDragEnd({ source: { index: 0 }, destination: { index: 1 } }); + }); + + expect(reorderAggs).toHaveBeenCalledWith([ + defaultProps.state.aggs[1], + defaultProps.state.aggs[0], + ]); + }); + + it('should show add button when schemas count is less than max', () => { + defaultProps.groupName = AggGroupNames.Buckets; + const comp = shallow(); + + expect(comp.find(DefaultEditorAggAdd).exists()).toBeTruthy(); + }); + + it('should not show add button when schemas count is not less than max', () => { + const comp = shallow(); + + expect(comp.find(DefaultEditorAggAdd).exists()).toBeFalsy(); + }); +}); diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.tsx new file mode 100644 index 0000000000000..d645efa818c14 --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group.tsx @@ -0,0 +1,193 @@ +/* + * 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, { useEffect, useReducer } from 'react'; +import { + EuiTitle, + EuiDragDropContext, + EuiDroppable, + EuiDraggable, + EuiSpacer, + EuiPanel, +} from '@elastic/eui'; + +import { AggConfig } from '../../../agg_config'; +import { aggGroupNamesMap, AggGroupNames } from '../agg_groups'; +import { DefaultEditorAgg } from './default_editor_agg'; +import { DefaultEditorAggAdd } from './default_editor_agg_add'; +import { DefaultEditorAggCommonProps } from './default_editor_agg_common_props'; +import { + isInvalidAggsTouched, + isAggRemovable, + calcAggIsTooLow, +} from './default_editor_agg_group_helper'; +import { aggGroupReducer, initAggsState, AGGS_ACTION_KEYS } from './default_editor_agg_group_state'; +import { Schema } from '../schemas'; + +export interface DefaultEditorAggGroupProps extends DefaultEditorAggCommonProps { + schemas: Schema[]; + addSchema: (schems: Schema) => void; + reorderAggs: (group: AggConfig[]) => void; +} + +function DefaultEditorAggGroup({ + formIsTouched, + groupName, + lastParentPipelineAggTitle, + metricAggs, + state, + schemas = [], + addSchema, + onAggParamsChange, + onAggTypeChange, + onToggleEnableAgg, + removeAgg, + reorderAggs, + setTouched, + setValidity, +}: DefaultEditorAggGroupProps) { + const groupNameLabel = aggGroupNamesMap()[groupName]; + // e.g. buckets can have no aggs + const group: AggConfig[] = state.aggs.bySchemaGroup[groupName] || []; + + const stats = { + max: 0, + count: group.length, + }; + + schemas.forEach((schema: Schema) => { + stats.max += schema.max; + }); + + const [aggsState, setAggsState] = useReducer(aggGroupReducer, group, initAggsState); + + const isGroupValid = Object.values(aggsState).every(item => item.valid); + const isAllAggsTouched = isInvalidAggsTouched(aggsState); + + useEffect(() => { + // when isAllAggsTouched is true, it means that all invalid aggs are touched and we will set ngModel's touched to true + // which indicates that Apply button can be changed to Error button (when all invalid ngModels are touched) + setTouched(isAllAggsTouched); + }, [isAllAggsTouched]); + + useEffect(() => { + // when not all invalid aggs are touched and formIsTouched becomes true, it means that Apply button was clicked. + // and in such case we set touched state to true for all aggs + if (formIsTouched && !isAllAggsTouched) { + Object.keys(aggsState).map(([aggId]) => { + setAggsState({ + type: AGGS_ACTION_KEYS.TOUCHED, + payload: true, + aggId: Number(aggId), + }); + }); + } + }, [formIsTouched]); + + useEffect(() => { + setValidity(isGroupValid); + }, [isGroupValid]); + + interface DragDropResultProps { + source: { index: number }; + destination?: { index: number } | null; + } + const onDragEnd = ({ source, destination }: DragDropResultProps) => { + if (source && destination) { + const orderedGroup = Array.from(group); + const [removed] = orderedGroup.splice(source.index, 1); + orderedGroup.splice(destination.index, 0, removed); + + reorderAggs(orderedGroup); + } + }; + + const setTouchedHandler = (aggId: number, touched: boolean) => { + setAggsState({ + type: AGGS_ACTION_KEYS.TOUCHED, + payload: touched, + aggId, + }); + }; + + const setValidityHandler = (aggId: number, valid: boolean) => { + setAggsState({ + type: AGGS_ACTION_KEYS.VALID, + payload: valid, + aggId, + }); + }; + + return ( + + + +
{groupNameLabel}
+
+ + + <> + {group.map((agg: AggConfig, index: number) => ( + + {provided => ( + 1} + isLastBucket={groupName === AggGroupNames.Buckets && index === group.length - 1} + isRemovable={isAggRemovable(agg, group)} + lastParentPipelineAggTitle={lastParentPipelineAggTitle} + metricAggs={metricAggs} + state={state} + onAggParamsChange={onAggParamsChange} + onAggTypeChange={onAggTypeChange} + onToggleEnableAgg={onToggleEnableAgg} + removeAgg={removeAgg} + setTouched={isTouched => setTouchedHandler(agg.id, isTouched)} + setValidity={isValid => setValidityHandler(agg.id, isValid)} + /> + )} + + ))} + + + {stats.max > stats.count && ( + + )} +
+
+ ); +} + +export { DefaultEditorAggGroup }; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.test.ts b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.test.ts new file mode 100644 index 0000000000000..36b5ad82ae29f --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { AggConfig } from '../../../agg_config'; +import { + isAggRemovable, + calcAggIsTooLow, + isInvalidAggsTouched, +} from './default_editor_agg_group_helper'; +import { AggsState } from './default_editor_agg_group_state'; + +describe('DefaultEditorGroup helpers', () => { + let group: AggConfig; + + beforeEach(() => { + group = [ + { + id: 1, + title: 'Test1', + params: { + field: { + type: 'number', + }, + }, + group: 'metrics', + schema: { name: 'metric', min: 1, mustBeFirst: true }, + }, + { + id: 2, + title: 'Test2', + params: { + field: { + type: 'string', + }, + }, + group: 'metrics', + schema: { name: 'metric', min: 2 }, + }, + ]; + }); + describe('isAggRemovable', () => { + it('should return true when the number of aggs with the same schema is above the min', () => { + const isRemovable = isAggRemovable(group[0], group); + + expect(isRemovable).toBeTruthy(); + }); + + it('should return false when the number of aggs with the same schema is not above the min', () => { + const isRemovable = isAggRemovable(group[1], group); + + expect(isRemovable).toBeFalsy(); + }); + }); + + describe('calcAggIsTooLow', () => { + it('should return false when agg.schema.mustBeFirst has falsy value', () => { + const isRemovable = calcAggIsTooLow(group[1], 0, group); + + expect(isRemovable).toBeFalsy(); + }); + + it('should return false when there is no different schema', () => { + group[1].schema = group[0].schema; + const isRemovable = calcAggIsTooLow(group[0], 0, group); + + expect(isRemovable).toBeFalsy(); + }); + + it('should return false when different schema is not less than agg index', () => { + const isRemovable = calcAggIsTooLow(group[0], 0, group); + + expect(isRemovable).toBeFalsy(); + }); + + it('should return true when agg index is greater than different schema index', () => { + const isRemovable = calcAggIsTooLow(group[0], 2, group); + + expect(isRemovable).toBeTruthy(); + }); + }); + + describe('isInvalidAggsTouched', () => { + let aggsState: AggsState; + + beforeEach(() => { + aggsState = { + 1: { + valid: true, + touched: false, + }, + 2: { + valid: true, + touched: false, + }, + 3: { + valid: true, + touched: false, + }, + }; + }); + + it('should return false when there are no invalid aggs', () => { + const isAllInvalidAggsTouched = isInvalidAggsTouched(aggsState); + + expect(isAllInvalidAggsTouched).toBeFalsy(); + }); + + it('should return false when not all invalid aggs are touched', () => { + aggsState[1].valid = false; + const isAllInvalidAggsTouched = isInvalidAggsTouched(aggsState); + + expect(isAllInvalidAggsTouched).toBeFalsy(); + }); + + it('should return true when all invalid aggs are touched', () => { + aggsState[1].valid = false; + aggsState[1].touched = true; + const isAllInvalidAggsTouched = isInvalidAggsTouched(aggsState); + + expect(isAllInvalidAggsTouched).toBeTruthy(); + }); + }); +}); diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.ts b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.ts new file mode 100644 index 0000000000000..f4f3964e4927f --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_helper.ts @@ -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 { findIndex, reduce, isEmpty } from 'lodash'; +import { AggConfig } from '../../../agg_config'; +import { AggsState } from './default_editor_agg_group_state'; + +const isAggRemovable = (agg: AggConfig, group: AggConfig[]) => { + const metricCount = reduce( + group, + (count, aggregation: AggConfig) => { + return aggregation.schema.name === agg.schema.name ? ++count : count; + }, + 0 + ); + // make sure the the number of these aggs is above the min + return metricCount > agg.schema.min; +}; + +const calcAggIsTooLow = (agg: AggConfig, aggIndex: number, group: AggConfig[]) => { + if (!agg.schema.mustBeFirst) { + return false; + } + + const firstDifferentSchema = findIndex(group, (aggr: AggConfig) => { + return aggr.schema !== agg.schema; + }); + + if (firstDifferentSchema === -1) { + return false; + } + + return aggIndex > firstDifferentSchema; +}; + +function isInvalidAggsTouched(aggsState: AggsState) { + const invalidAggs = Object.values(aggsState).filter(agg => !agg.valid); + + if (isEmpty(invalidAggs)) { + return false; + } + + return invalidAggs.every(agg => agg.touched); +} + +export { isAggRemovable, calcAggIsTooLow, isInvalidAggsTouched }; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_state.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_state.tsx new file mode 100644 index 0000000000000..cba7f09a2be0f --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_group_state.tsx @@ -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 { AggConfig } from '../../../agg_config'; + +export enum AGGS_ACTION_KEYS { + TOUCHED = 'aggsTouched', + VALID = 'aggsValid', +} + +interface AggsItem { + touched: boolean; + valid: boolean; +} + +export interface AggsState { + [aggId: number]: AggsItem; +} + +interface AggsAction { + type: AGGS_ACTION_KEYS; + payload: boolean; + aggId: number; + newState?: AggsState; +} + +function aggGroupReducer(state: AggsState, action: AggsAction): AggsState { + const aggState = state[action.aggId] || { touched: false, valid: true }; + switch (action.type) { + case AGGS_ACTION_KEYS.TOUCHED: + return { ...state, [action.aggId]: { ...aggState, touched: action.payload } }; + case AGGS_ACTION_KEYS.VALID: + return { ...state, [action.aggId]: { ...aggState, valid: action.payload } }; + default: + throw new Error(); + } +} + +function initAggsState(group: AggConfig[]): AggsState { + return group.reduce((state, agg) => { + state[agg.id] = { touched: false, valid: true }; + return state; + }, {}); +} + +export { aggGroupReducer, initAggsState }; diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_param.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_param.tsx index e2f2e5b90acbb..0e7fdf2cc37c8 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_param.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_param.tsx @@ -19,12 +19,12 @@ import React, { useEffect } from 'react'; -import { AggParams } from '../agg_params'; import { AggParamEditorProps, AggParamCommonProps } from './default_editor_agg_param_props'; +import { OnAggParamsChange } from './default_editor_agg_common_props'; interface DefaultEditorAggParamProps extends AggParamCommonProps { paramEditor: React.ComponentType>; - onChange(aggParams: AggParams, paramName: string, value?: T): void; + onChange: OnAggParamsChange; } function DefaultEditorAggParam(props: DefaultEditorAggParamProps) { diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params.tsx index a5055170e411c..ecd6c82f26d6b 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params.tsx @@ -18,11 +18,11 @@ */ import React, { useReducer, useEffect } from 'react'; -import { EuiForm, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { EuiForm, EuiAccordion, EuiSpacer, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { aggTypes, AggType, AggParam } from 'ui/agg_types'; -import { AggConfig, VisState, AggParams } from 'ui/vis'; +import { AggConfig, VisState } from 'ui/vis'; import { IndexPattern } from 'ui/index_patterns'; import { DefaultEditorAggSelect } from './default_editor_agg_select'; import { DefaultEditorAggParam } from './default_editor_agg_param'; @@ -47,6 +47,7 @@ import { FixedParam, TimeIntervalParam, EditorParamConfig } from '../../config/t // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { useUnmount } from '../../../../../../../plugins/kibana_react/public/util/use_unmount'; import { AggGroupNames } from '../agg_groups'; +import { OnAggParamsChange } from './default_editor_agg_common_props'; const FIXED_VALUE_PROP = 'fixedValue'; const DEFAULT_PROP = 'default'; @@ -55,12 +56,12 @@ type EditorParamConfigType = EditorParamConfig & { }; export interface SubAggParamsProp { formIsTouched: boolean; - onAggParamsChange: (agg: AggParams, paramName: string, value: unknown) => void; + onAggParamsChange: OnAggParamsChange; onAggTypeChange: (agg: AggConfig, aggType: AggType) => void; } export interface DefaultEditorAggParamsProps extends SubAggParamsProp { agg: AggConfig; - aggError?: string | null; + aggError?: string; aggIndex?: number; aggIsTooLow?: boolean; className?: string; @@ -227,7 +228,7 @@ function DefaultEditorAggParams({ })} {params.advanced.length ? ( - <> + - + {params.advanced.map((param: ParamInstance) => { const model = paramsState[param.aggParam.name] || { touched: false, @@ -247,8 +247,7 @@ function DefaultEditorAggParams({ return renderParam(param, model); })} - - + ) : null} ); diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.test.ts b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.test.ts index 786ec688bd2bd..285a694f55b7c 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.test.ts +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.test.ts @@ -172,12 +172,6 @@ describe('DefaultEditorAggParams helpers', () => { expect(errors).toEqual(['"Split series" aggs must run before all other buckets!']); }); - - it('should push an error if a schema is deprecated', () => { - const errors = getError({ schema: { title: 'Split series', deprecate: true } }, false); - - expect(errors).toEqual(['"Split series" has been deprecated.']); - }); }); describe('getAggTypeOptions', () => { diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.ts b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.ts index 5c8acac4e56b3..1e09bdf694fa0 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.ts +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_params_helper.ts @@ -108,16 +108,6 @@ function getError(agg: AggConfig, aggIsTooLow: boolean) { }) ); } - if (agg.schema.deprecate) { - errors.push( - agg.schema.deprecateMessage - ? agg.schema.deprecateMessage - : i18n.translate('common.ui.vis.editors.aggParams.errors.schemaIsDeprecatedErrorMessage', { - defaultMessage: '"{schema}" has been deprecated.', - values: { schema: agg.schema.title }, - }) - ); - } return errors; } diff --git a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx index 536bcdb7891ef..a853f8fa15773 100644 --- a/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx +++ b/src/legacy/ui/public/vis/editors/default/components/default_editor_agg_select.tsx @@ -19,7 +19,7 @@ import { get, has } from 'lodash'; import React, { useEffect } from 'react'; -import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow, EuiLink } from '@elastic/eui'; +import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow, EuiLink, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AggType } from 'ui/agg_types'; @@ -28,7 +28,7 @@ import { documentationLinks } from '../../../../documentation_links/documentatio import { ComboBoxGroupedOption } from '../default_editor_utils'; interface DefaultEditorAggSelectProps { - aggError?: string | null; + aggError?: string; aggTypeOptions: AggType[]; id: string; indexPattern: IndexPattern; @@ -72,17 +72,14 @@ function DefaultEditorAggSelect({ } const helpLink = value && aggHelpLink && ( - - + + + + ); diff --git a/src/legacy/ui/public/vis/editors/default/controls/agg_controls.js b/src/legacy/ui/public/vis/editors/default/controls/agg_controls.js deleted file mode 100644 index 860c33bc99400..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/controls/agg_controls.js +++ /dev/null @@ -1,31 +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 { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from '../../../../modules'; -import { AggControlReactWrapper } from './agg_control_react_wrapper'; - -uiModules - .get('app/visualize') - .directive('visAggControlReactWrapper', reactDirective => reactDirective(wrapInI18nContext(AggControlReactWrapper), [ - ['aggParams', { watchDepth: 'collection' }], - ['editorStateParams', { watchDepth: 'collection' }], - ['component', { wrapApply: false }], - 'setValue' - ])); diff --git a/src/legacy/ui/public/vis/editors/default/default.js b/src/legacy/ui/public/vis/editors/default/default.js index 6fdbbd1d150ab..da8ab50b7805c 100644 --- a/src/legacy/ui/public/vis/editors/default/default.js +++ b/src/legacy/ui/public/vis/editors/default/default.js @@ -18,6 +18,7 @@ */ import 'ui/angular-bootstrap'; +import './fancy_forms'; import './sidebar'; import { i18n } from '@kbn/i18n'; import './vis_options'; diff --git a/src/legacy/ui/public/vis/editors/default/__tests__/default_editor_utils.test.tsx b/src/legacy/ui/public/vis/editors/default/default_editor_utils.test.tsx similarity index 97% rename from src/legacy/ui/public/vis/editors/default/__tests__/default_editor_utils.test.tsx rename to src/legacy/ui/public/vis/editors/default/default_editor_utils.test.tsx index a028ea9701e49..3c0a2873c7484 100644 --- a/src/legacy/ui/public/vis/editors/default/__tests__/default_editor_utils.test.tsx +++ b/src/legacy/ui/public/vis/editors/default/default_editor_utils.test.tsx @@ -17,8 +17,8 @@ * under the License. */ -import { groupAggregationsBy } from '../default_editor_utils'; -import { AggGroupNames } from '../agg_groups'; +import { groupAggregationsBy } from './default_editor_utils'; +import { AggGroupNames } from './agg_groups'; const aggs = [ { diff --git a/src/legacy/ui/public/vis/editors/default/index.ts b/src/legacy/ui/public/vis/editors/default/index.ts index 590249667b74f..d208bba4fd0a4 100644 --- a/src/legacy/ui/public/vis/editors/default/index.ts +++ b/src/legacy/ui/public/vis/editors/default/index.ts @@ -19,3 +19,4 @@ export { AggParamEditorProps } from './components/default_editor_agg_param_props'; export { DefaultEditorAggParams } from './components/default_editor_agg_params'; +export * from './vis_options_props'; diff --git a/src/legacy/ui/public/vis/editors/default/keyboard_move.js b/src/legacy/ui/public/vis/editors/default/keyboard_move.js deleted file mode 100644 index 9f5b1eefa0ceb..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/keyboard_move.js +++ /dev/null @@ -1,75 +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. - */ - -/** - * The keyboardMove directive can be attached to elements, that can receive keydown events. - * It will call the passed callback function and pass the direction in which an - * arrow key was pressed to the callback (as the argument with the name `direction`). - * The passed value will be one of `Direction.up` or `Direction.down`, which can be - * imported to compare against those values. The directive will also make sure, that - * the pressed button will get the focus back (e.g. if it was lost due to a ng-repeat - * reordering). - * - * Usage example: - * - * - * - * import { Direction } from './keyboard_move'; - * function onMoved(dir) { - * if (dir === Direction.up) { - * // moved up - * } else if (dir === Direction.down) { - * // moved down - * } - * } - */ -import { uiModules } from '../../../modules'; -import { keyCodes } from '@elastic/eui'; - -export const Direction = { - up: 'up', - down: 'down' -}; - -const directionMapping = { - [keyCodes.UP]: Direction.up, - [keyCodes.DOWN]: Direction.down -}; - -uiModules.get('kibana') - .directive('keyboardMove', ($parse, $timeout) => ({ - restrict: 'A', - link(scope, el, attr) { - const callbackFn = $parse(attr.keyboardMove); - el.keydown((ev) => { - if (ev.which in directionMapping) { - ev.preventDefault(); - const direction = directionMapping[ev.which]; - scope.$apply(() => callbackFn(scope, { direction })); - // Keep focus on that element, even though it might be attached somewhere - // else in the DOM (e.g. because it has a new position in an ng-repeat). - $timeout(() => el.focus()); - } - }); - - scope.$on('$destroy', () => { - el.off('keydown'); - }); - } - })); diff --git a/src/legacy/ui/public/vis/editors/default/schemas.d.ts b/src/legacy/ui/public/vis/editors/default/schemas.d.ts index ae4aae9fe1061..faf6120cfb893 100644 --- a/src/legacy/ui/public/vis/editors/default/schemas.d.ts +++ b/src/legacy/ui/public/vis/editors/default/schemas.d.ts @@ -22,7 +22,6 @@ import { AggGroupNames } from './agg_groups'; export interface Schema { aggFilter: string | string[]; - deprecate: boolean; editor: boolean | string; group: AggGroupNames; max: number; diff --git a/src/legacy/ui/public/vis/editors/default/schemas.js b/src/legacy/ui/public/vis/editors/default/schemas.js index 4c3da3bb336ca..313fdfd19a284 100644 --- a/src/legacy/ui/public/vis/editors/default/schemas.js +++ b/src/legacy/ui/public/vis/editors/default/schemas.js @@ -51,7 +51,6 @@ class Schemas { aggFilter: '*', editor: false, params: [], - deprecate: false }); // convert the params into a params registry diff --git a/src/legacy/ui/public/vis/editors/default/sidebar.html b/src/legacy/ui/public/vis/editors/default/sidebar.html index 77a930c94a462..14e9bd46c376d 100644 --- a/src/legacy/ui/public/vis/editors/default/sidebar.html +++ b/src/legacy/ui/public/vis/editors/default/sidebar.html @@ -151,10 +151,24 @@
- - + +
- +
@@ -166,6 +180,7 @@ ui-state="uiState" visualize-editor="visualizeEditor" editor="tab.editor" + on-agg-params-change="onAggParamsChange" >
diff --git a/src/legacy/ui/public/vis/editors/default/sidebar.js b/src/legacy/ui/public/vis/editors/default/sidebar.js index a335880bcf917..30e42d1957698 100644 --- a/src/legacy/ui/public/vis/editors/default/sidebar.js +++ b/src/legacy/ui/public/vis/editors/default/sidebar.js @@ -23,30 +23,79 @@ import './vis_options'; import 'ui/directives/css_truncate'; import { uiModules } from '../../../modules'; import sidebarTemplate from './sidebar.html'; +import { move } from '../../../utils/collection'; +import { AggConfig } from '../../agg_config'; -uiModules - .get('app/visualize') - .directive('visEditorSidebar', function () { - return { - restrict: 'E', - template: sidebarTemplate, - scope: true, - controllerAs: 'sidebar', - controller: function ($scope) { - $scope.$watch('vis.type', (visType) => { - if (visType) { - this.showData = visType.schemas.buckets || visType.schemas.metrics; - if (_.has(visType, 'editorConfig.optionTabs')) { - const activeTabs = visType.editorConfig.optionTabs.filter((tab) => { - return _.get(tab, 'active', false); - }); - if (activeTabs.length > 0) { - this.section = activeTabs[0].name; - } +uiModules.get('app/visualize').directive('visEditorSidebar', function () { + return { + restrict: 'E', + template: sidebarTemplate, + scope: true, + require: '?^ngModel', + controllerAs: 'sidebar', + controller: function ($scope) { + $scope.$watch('vis.type', visType => { + if (visType) { + this.showData = visType.schemas.buckets || visType.schemas.metrics; + if (_.has(visType, 'editorConfig.optionTabs')) { + const activeTabs = visType.editorConfig.optionTabs.filter(tab => { + return _.get(tab, 'active', false); + }); + if (activeTabs.length > 0) { + this.section = activeTabs[0].name; } - this.section = this.section || (this.showData ? 'data' : _.get(visType, 'editorConfig.optionTabs[0].name')); } + this.section = + this.section || + (this.showData ? 'data' : _.get(visType, 'editorConfig.optionTabs[0].name')); + } + }); + + $scope.onAggTypeChange = (agg, value) => { + if (agg.type !== value) { + agg.type = value; + } + }; + + $scope.onAggParamsChange = (params, paramName, value) => { + if (params[paramName] !== value) { + params[paramName] = value; + } + }; + + $scope.addSchema = function (schema) { + const aggConfig = new AggConfig($scope.state.aggs, { + schema, + id: AggConfig.nextId($scope.state.aggs), + }); + aggConfig.brandNew = true; + + $scope.state.aggs.push(aggConfig); + }; + + $scope.removeAgg = function (agg) { + const aggs = $scope.state.aggs; + const index = aggs.indexOf(agg); + + if (index === -1) { + return; + } + + aggs.splice(index, 1); + }; + + $scope.onToggleEnableAgg = (agg, isEnable) => { + agg.enabled = isEnable; + }; + + $scope.reorderAggs = (group) => { + //the aggs have been reordered in [group] and we need + //to apply that ordering to [vis.aggs] + const indexOffset = $scope.state.aggs.indexOf(group[0]); + _.forEach(group, (agg, index) => { + move($scope.state.aggs, agg, indexOffset + index); }); - } - }; - }); + }; + }, + }; +}); diff --git a/src/legacy/ui/public/vis/editors/default/vis_options.html b/src/legacy/ui/public/vis/editors/default/vis_options.html deleted file mode 100644 index 51a4c296b5b79..0000000000000 --- a/src/legacy/ui/public/vis/editors/default/vis_options.html +++ /dev/null @@ -1,4 +0,0 @@ - -
diff --git a/src/legacy/ui/public/vis/editors/default/vis_options.js b/src/legacy/ui/public/vis/editors/default/vis_options.js index fe1bc3a0f61ea..0f4548e115565 100644 --- a/src/legacy/ui/public/vis/editors/default/vis_options.js +++ b/src/legacy/ui/public/vis/editors/default/vis_options.js @@ -17,12 +17,9 @@ * under the License. */ -import _ from 'lodash'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; +import { wrapInI18nContext } from 'ui/i18n'; import { uiModules } from '../../../modules'; -import visOptionsTemplate from './vis_options.html'; -import { I18nContext } from 'ui/i18n'; +import { VisOptionsReactWrapper } from './vis_options_react_wrapper'; /** * This directive sort of "transcludes" in whatever template you pass in via the `editor` attribute. @@ -32,10 +29,15 @@ import { I18nContext } from 'ui/i18n'; uiModules .get('app/visualize') + .directive('visOptionsReactWrapper', reactDirective => reactDirective(wrapInI18nContext(VisOptionsReactWrapper), [ + ['component', { wrapApply: false }], + ['stateParams', { watchDepth: 'collection' }], + ['vis', { watchDepth: 'collection' }], + ['setValue', { watchDepth: 'reference' }], + ])) .directive('visEditorVisOptions', function ($compile) { return { restrict: 'E', - template: visOptionsTemplate, scope: { vis: '=', visData: '=', @@ -43,45 +45,22 @@ uiModules editor: '=', visualizeEditor: '=', editorState: '=', + onAggParamsChange: '=', }, link: function ($scope, $el) { - const $optionContainer = $el.find('[data-visualization-options]'); + $scope.setValue = (paramName, value) => + $scope.onAggParamsChange($scope.editorState.params, paramName, value); - const reactOptionsComponent = typeof $scope.editor !== 'string'; - const stageEditorParams = (params) => { - $scope.editorState.params = _.cloneDeep(params); - $scope.$apply(); - }; - const renderReactComponent = () => { - const Component = $scope.editor; - render( - - - , $el[0]); - }; - // Bind the `editor` template with the scope. - if (reactOptionsComponent) { - renderReactComponent(); - } else { - const $editor = $compile($scope.editor)($scope); - $optionContainer.append($editor); - } - - $scope.$watchGroup(['visData', 'visualizeEditor', 'editorState.params'], () => { - if (reactOptionsComponent) { - renderReactComponent(); - } - }); - - $scope.$watch('vis.type.schemas.all.length', function (len) { - $scope.alwaysShowOptions = len === 0; - }); - - $el.on('$destroy', () => { - if (reactOptionsComponent) { - unmountComponentAtNode($el[0]); - } - }); + const comp = typeof $scope.editor === 'string' ? + $scope.editor : + ` + `; + const $editor = $compile(comp)($scope); + $el.append($editor); } }; }); diff --git a/src/legacy/ui/public/vis/editors/default/vis_options_props.tsx b/src/legacy/ui/public/vis/editors/default/vis_options_props.tsx new file mode 100644 index 0000000000000..40fd7d986093e --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/vis_options_props.tsx @@ -0,0 +1,27 @@ +/* + * 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 { Vis, VisParams } from 'ui/vis'; + +export type VisOptionsSetValue = (paramName: string, value: unknown) => void; +export interface VisOptionsProps { + stateParams: VisParams; + vis: Vis; + setValue: VisOptionsSetValue; +} diff --git a/src/legacy/ui/public/vis/editors/default/vis_options_react_wrapper.tsx b/src/legacy/ui/public/vis/editors/default/vis_options_react_wrapper.tsx new file mode 100644 index 0000000000000..d214abecb9c0c --- /dev/null +++ b/src/legacy/ui/public/vis/editors/default/vis_options_react_wrapper.tsx @@ -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 { VisOptionsProps } from './vis_options_props'; + +interface VisOptionsReactWrapperProps extends VisOptionsProps { + component: React.ComponentType; +} + +function VisOptionsReactWrapper({ component: Component, ...rest }: VisOptionsReactWrapperProps) { + return ; +} + +export { VisOptionsReactWrapper }; diff --git a/src/legacy/ui/public/vis/request_handlers/courier.js b/src/legacy/ui/public/vis/request_handlers/courier.js index 80f8425340197..5cf5bf005d0b2 100644 --- a/src/legacy/ui/public/vis/request_handlers/courier.js +++ b/src/legacy/ui/public/vis/request_handlers/courier.js @@ -42,7 +42,6 @@ const CourierRequestHandlerProvider = function () { inspectorAdapters, queryFilter }) { - // Create a new search source that inherits the original search source // but has the appropriate timeRange applied via a filter. // This is a temporary solution until we properly pass down all required diff --git a/src/legacy/ui/public/vis/request_handlers/request_handlers.d.ts b/src/legacy/ui/public/vis/request_handlers/request_handlers.d.ts index f2475065a869c..f6768007503c5 100644 --- a/src/legacy/ui/public/vis/request_handlers/request_handlers.d.ts +++ b/src/legacy/ui/public/vis/request_handlers/request_handlers.d.ts @@ -24,12 +24,12 @@ import { SearchSource } from '../../courier'; import { QueryFilter } from '../../filter_manager/query_filter'; import { Adapters } from '../../inspector/types'; import { PersistedState } from '../../persisted_state'; -import { AggConfigs } from '../agg_configs'; +import { AggConfig } from '../agg_config'; import { Vis } from '../vis'; export interface RequestHandlerParams { searchSource: SearchSource; - aggs: AggConfigs; + aggs: AggConfig[]; timeRange?: TimeRange; query?: Query; filters?: Filter[]; diff --git a/src/legacy/ui/public/vis/vis.d.ts b/src/legacy/ui/public/vis/vis.d.ts index 9e6107ed7594f..22881a6fda18a 100644 --- a/src/legacy/ui/public/vis/vis.d.ts +++ b/src/legacy/ui/public/vis/vis.d.ts @@ -18,6 +18,7 @@ */ import { VisType } from './vis_types/vis_type'; +import { AggConfigs } from './agg_configs'; export interface Vis { type: VisType; @@ -39,5 +40,5 @@ export interface VisState { title: string; type: VisType; params: VisParams; - aggs: any[]; + aggs: AggConfigs; } diff --git a/src/legacy/ui/public/vis/vis.js b/src/legacy/ui/public/vis/vis.js index dc537cd000d33..7b7b311043551 100644 --- a/src/legacy/ui/public/vis/vis.js +++ b/src/legacy/ui/public/vis/vis.js @@ -38,7 +38,7 @@ import { SearchSourceProvider } from '../courier/search_source'; import { SavedObjectsClientProvider } from '../saved_objects'; import { timefilter } from '../timefilter'; -import '../bind'; +import '../directives/bind'; export function VisProvider(Private, indexPatterns, getAppState) { const visTypes = Private(VisTypesRegistryProvider); diff --git a/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js b/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js index f48e2014385b5..aedb2f016b50d 100644 --- a/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js +++ b/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js @@ -36,8 +36,7 @@ import { dispatchRenderComplete } from '../../../render_complete'; import { PipelineDataLoader } from '../pipeline_data_loader'; import { VisualizeDataLoader } from '../visualize_data_loader'; import { PersistedState } from '../../../persisted_state'; -import { DataAdapter } from '../../../inspector/adapters/data'; -import { RequestAdapter } from '../../../inspector/adapters/request'; +import { DataAdapter, RequestAdapter } from '../../../inspector/adapters'; describe('visualize loader', () => { diff --git a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts b/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts index b56a63c4c0928..4e41dd54855ea 100644 --- a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts +++ b/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts @@ -20,7 +20,7 @@ import { mockDataLoaderFetch, timefilter } from './embedded_visualize_handler.te // @ts-ignore import MockState from '../../../../../fixtures/mock_state'; -import { RequestHandlerParams, Vis } from '../../vis'; +import { RequestHandlerParams, Vis, AggConfig } from '../../vis'; import { VisResponseData } from './types'; import { Inspector } from '../../inspector'; @@ -49,7 +49,7 @@ describe('EmbeddedVisualizeHandler', () => { jest.clearAllMocks(); dataLoaderParams = { - aggs: [], + aggs: [] as AggConfig[], filters: undefined, forceFetch: false, inspectorAdapters: {}, diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.test.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.test.ts index d98872cb3402f..d98feac9a0221 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.test.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.test.ts @@ -51,6 +51,11 @@ describe('visualize loader pipeline helpers: build pipeline', () => { const actual = prepareJson('foo', { well: `hello 'hi'`, there: { friend: true } }); expect(actual).toBe(expected); }); + + it('returns empty string if data is undefined', () => { + const actual = prepareJson('foo', undefined); + expect(actual).toBe(''); + }); }); describe('prepareString', () => { @@ -65,6 +70,11 @@ describe('visualize loader pipeline helpers: build pipeline', () => { const actual = prepareString('foo', `'bar'`); expect(actual).toBe(expected); }); + + it('returns empty string if data is undefined', () => { + const actual = prepareString('foo', undefined); + expect(actual).toBe(''); + }); }); describe('buildPipelineVisFunction', () => { diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts index 38a53b7078d4c..83bb25f26e7d5 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/build_pipeline.ts @@ -188,7 +188,7 @@ export const getSchemas = (vis: Vis, timeRange?: any): Schemas => { return schemas; }; -export const prepareJson = (variable: string, data: object): string => { +export const prepareJson = (variable: string, data?: object): string => { if (data === undefined) { return ''; } diff --git a/src/plugins/data/common/expressions/create_error.ts b/src/plugins/data/common/expressions/create_error.ts index 78a12cb07c663..cee288e5e1b35 100644 --- a/src/plugins/data/common/expressions/create_error.ts +++ b/src/plugins/data/common/expressions/create_error.ts @@ -22,5 +22,6 @@ export const createError = (err: any) => ({ error: { stack: process.env.NODE_ENV === 'production' ? undefined : err.stack, message: typeof err === 'string' ? err : err.message, + name: (err && err.name) || 'Error', }, }); diff --git a/src/plugins/data/common/expressions/interpreter_provider.ts b/src/plugins/data/common/expressions/interpreter_provider.ts index ed26701de5d87..cb025e22131c5 100644 --- a/src/plugins/data/common/expressions/interpreter_provider.ts +++ b/src/plugins/data/common/expressions/interpreter_provider.ts @@ -71,8 +71,16 @@ export function interpreterProvider(config: any) { // if something failed, just return the failure if (getType(newContext) === 'error') return newContext; + // if execution was aborted return error + if (handlers.abortSignal && handlers.abortSignal.aborted) { + return createError({ + message: 'The expression was aborted.', + name: 'AbortError', + }); + } + // Continue re-invoking chain until it's empty - return await invokeChain(chain, newContext); + return invokeChain(chain, newContext); } catch (e) { // Everything that throws from a function will hit this // The interpreter should *never* fail. It should always return a `{type: error}` on failure diff --git a/src/legacy/ui/public/index_patterns/index.d.ts b/src/plugins/es_ui_shared/public/request/index.ts similarity index 83% rename from src/legacy/ui/public/index_patterns/index.d.ts rename to src/plugins/es_ui_shared/public/request/index.ts index e2d7ddd8c5254..a19005c0191a2 100644 --- a/src/legacy/ui/public/index_patterns/index.d.ts +++ b/src/plugins/es_ui_shared/public/request/index.ts @@ -18,8 +18,9 @@ */ export { - IndexPattern, - StaticIndexPattern, - StaticIndexPatternField, -} from 'ui/index_patterns/_index_pattern'; -export { Field } from 'ui/index_patterns/_field'; + SendRequestConfig, + SendRequestResponse, + UseRequestConfig, + sendRequest, + useRequest, +} from './request'; diff --git a/src/plugins/es_ui_shared/public/request/request.test.js b/src/plugins/es_ui_shared/public/request/request.test.js new file mode 100644 index 0000000000000..03bed758b1209 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/request.test.js @@ -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 sinon from 'sinon'; +import { + sendRequest as sendRequestUnbound, + useRequest as useRequestUnbound, +} from './request'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; + +const TestHook = ({ callback }) => { + callback(); + return null; +}; + +let element; + +const testHook = (callback) => { + element = mount(); +}; + +const wait = async wait => + new Promise(resolve => setTimeout(resolve, wait || 1)); + +describe('request lib', () => { + const successRequest = { path: '/success', method: 'post', body: {} }; + const errorRequest = { path: '/error', method: 'post', body: {} }; + const successResponse = { statusCode: 200, data: { message: 'Success message' } }; + const errorResponse = { statusCode: 400, statusText: 'Error message' }; + + let sendPost; + let sendRequest; + let useRequest; + + beforeEach(() => { + sendPost = sinon.stub(); + sendPost.withArgs(successRequest.path, successRequest.body).returns(successResponse); + sendPost.withArgs(errorRequest.path, errorRequest.body).throws(errorResponse); + + const httpClient = { + post: (...args) => { + return sendPost(...args); + }, + }; + + sendRequest = sendRequestUnbound.bind(null, httpClient); + useRequest = useRequestUnbound.bind(null, httpClient); + }); + + describe('sendRequest function', () => { + it('uses the provided path, method, and body to send the request', async () => { + const response = await sendRequest({ ...successRequest }); + sinon.assert.calledOnce(sendPost); + expect(response).toEqual({ data: successResponse.data }); + }); + + it('surfaces errors', async () => { + try { + await sendRequest({ ...errorRequest }); + } catch(e) { + sinon.assert.calledOnce(sendPost); + expect(e).toBe(errorResponse.error); + } + }); + }); + + describe('useRequest hook', () => { + let hook; + + function initUseRequest(config) { + act(() => { + testHook(() => { + hook = useRequest(config); + }); + }); + } + + describe('parameters', () => { + describe('path, method, body', () => { + it('is used to send the request', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.data).toBe(successResponse.data); + }); + }); + + // FLAKY: https://github.com/elastic/kibana/issues/42561 + describe.skip('pollIntervalMs', () => { + it('sends another request after the specified time has elapsed', async () => { + initUseRequest({ ...successRequest, pollIntervalMs: 30 }); + await wait(5); + sinon.assert.calledOnce(sendPost); + + await wait(40); + sinon.assert.calledTwice(sendPost); + + // We have to manually clean up or else the interval will continue to fire requests, + // interfering with other tests. + element.unmount(); + }); + }); + + describe('initialData', () => { + it('sets the initial data value', () => { + initUseRequest({ ...successRequest, initialData: 'initialData' }); + expect(hook.data).toBe('initialData'); + }); + }); + + describe('deserializer', () => { + it('is called once the request resolves', async () => { + const deserializer = sinon.stub(); + initUseRequest({ ...successRequest, deserializer }); + sinon.assert.notCalled(deserializer); + + await wait(5); + sinon.assert.calledOnce(deserializer); + sinon.assert.calledWith(deserializer, successResponse.data); + }); + + it('processes data', async () => { + initUseRequest({ ...successRequest, deserializer: () => 'intercepted' }); + await wait(5); + expect(hook.data).toBe('intercepted'); + }); + }); + }); + + describe('state', () => { + describe('isInitialRequest', () => { + it('is true for the first request and false for subsequent requests', async () => { + initUseRequest({ ...successRequest }); + expect(hook.isInitialRequest).toBe(true); + + hook.sendRequest(); + await wait(5); + expect(hook.isInitialRequest).toBe(false); + }); + }); + + describe('isLoading', () => { + it('represents in-flight request status', async () => { + initUseRequest({ ...successRequest }); + expect(hook.isLoading).toBe(true); + + await wait(5); + expect(hook.isLoading).toBe(false); + }); + }); + + describe('error', () => { + it('surfaces errors from requests', async () => { + initUseRequest({ ...errorRequest }); + await wait(10); + expect(hook.error).toBe(errorResponse); + }); + + // FLAKY: https://github.com/elastic/kibana/issues/42563 + it.skip('persists while a request is in-flight', async () => { + initUseRequest({ ...errorRequest }); + await wait(5); + hook.sendRequest(); + expect(hook.isLoading).toBe(true); + expect(hook.error).toBe(errorResponse); + }); + + it('is undefined when the request is successful', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.isLoading).toBe(false); + expect(hook.error).toBeUndefined(); + }); + }); + + describe('data', () => { + it('surfaces payloads from requests', async () => { + initUseRequest({ ...successRequest }); + await wait(10); + expect(hook.data).toBe(successResponse.data); + }); + + it('persists while a request is in-flight', async () => { + initUseRequest({ ...successRequest }); + await wait(5); + hook.sendRequest(); + expect(hook.isLoading).toBe(true); + expect(hook.data).toBe(successResponse.data); + }); + + // FLAKY: https://github.com/elastic/kibana/issues/42562 + it.skip('is undefined when the request fails', async () => { + initUseRequest({ ...errorRequest }); + await wait(10); + expect(hook.isLoading).toBe(false); + expect(hook.data).toBeUndefined(); + }); + }); + }); + + // FLAKY: https://github.com/elastic/kibana/issues/42225 + describe.skip('callbacks', () => { + describe('sendRequest', () => { + it('sends the request', () => { + initUseRequest({ ...successRequest }); + sinon.assert.calledOnce(sendPost); + hook.sendRequest(); + sinon.assert.calledTwice(sendPost); + }); + + it('resets the pollIntervalMs', async () => { + initUseRequest({ ...successRequest, pollIntervalMs: 30 }); + await wait(5); + sinon.assert.calledOnce(sendPost); + + await wait(20); + hook.sendRequest(); + + // If the request didn't reset the interval, there would have been three requests sent by now. + await wait(20); + sinon.assert.calledTwice(sendPost); + + await wait(20); + sinon.assert.calledThrice(sendPost); + + // We have to manually clean up or else the interval will continue to fire requests, + // interfering with other tests. + element.unmount(); + }); + }); + }); + }); +}); diff --git a/src/plugins/es_ui_shared/public/request/request.ts b/src/plugins/es_ui_shared/public/request/request.ts new file mode 100644 index 0000000000000..168ad8e2f3780 --- /dev/null +++ b/src/plugins/es_ui_shared/public/request/request.ts @@ -0,0 +1,154 @@ +/* + * 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 { useEffect, useState, useRef } from 'react'; + +export interface SendRequestConfig { + path: string; + method: string; + body?: any; +} + +export interface SendRequestResponse { + data: any; + error: Error; +} + +export interface UseRequestConfig extends SendRequestConfig { + pollIntervalMs?: number; + initialData?: any; + deserializer?: (data: any) => any; +} + +export const sendRequest = async ( + httpClient: ng.IHttpService, + { path, method, body }: SendRequestConfig +): Promise> => { + try { + const response = await (httpClient as any)[method](path, body); + + if (typeof response.data === 'undefined') { + throw new Error(response.statusText); + } + + return { data: response.data }; + } catch (e) { + return { + error: e.response ? e.response : e, + }; + } +}; + +export const useRequest = ( + httpClient: ng.IHttpService, + { + path, + method, + body, + pollIntervalMs, + initialData, + deserializer = (data: any): any => data, + }: UseRequestConfig +) => { + // Main states for tracking request status and data + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState(initialData); + + // Consumers can use isInitialRequest to implement a polling UX. + const [isInitialRequest, setIsInitialRequest] = useState(true); + const pollInterval = useRef(null); + const pollIntervalId = useRef(null); + + // We always want to use the most recently-set interval in scheduleRequest. + pollInterval.current = pollIntervalMs; + + // Tied to every render and bound to each request. + let isOutdatedRequest = false; + + const scheduleRequest = () => { + // Clear current interval + if (pollIntervalId.current) { + clearTimeout(pollIntervalId.current); + } + + // Set new interval + if (pollInterval.current) { + pollIntervalId.current = setTimeout(_sendRequest, pollInterval.current); + } + }; + + const _sendRequest = async () => { + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + const requestBody = { + path, + method, + body, + }; + + const response = await sendRequest(httpClient, requestBody); + const { data: serializedResponseData, error: responseError } = response; + const responseData = deserializer(serializedResponseData); + + // If an outdated request has resolved, DON'T update state, but DO allow the processData handler + // to execute side effects like update telemetry. + if (isOutdatedRequest) { + return; + } + + setError(responseError); + setData(responseData); + setIsLoading(false); + setIsInitialRequest(false); + + // If we're on an interval, we need to schedule the next request. This also allows us to reset + // the interval if the user has manually requested the data, to avoid doubled-up requests. + scheduleRequest(); + }; + + useEffect(() => { + _sendRequest(); + // To be functionally correct we'd send a new request if the method, path, or body changes. + // But it doesn't seem likely that the method will change and body is likely to be a new + // object even if its shape hasn't changed, so for now we're just watching the path. + }, [path]); + + useEffect(() => { + scheduleRequest(); + + // Clean up intervals and inflight requests and corresponding state changes + return () => { + isOutdatedRequest = true; + if (pollIntervalId.current) { + clearTimeout(pollIntervalId.current); + } + }; + }, [pollIntervalMs]); + + return { + isInitialRequest, + isLoading, + error, + data, + sendRequest: _sendRequest, // Gives the user the ability to manually request data + }; +}; diff --git a/src/plugins/inspector/README.md b/src/plugins/inspector/README.md new file mode 100644 index 0000000000000..e56db65cb90cb --- /dev/null +++ b/src/plugins/inspector/README.md @@ -0,0 +1,122 @@ +# Inspector + +The inspector is a contextual tool to gain insights into different elements +in Kibana, e.g. visualizations. It has the form of a flyout panel. + +## Inspector Views + +The "Inspector Panel" can have multiple so called "Inspector Views" inside of it. +These views are used to gain different information into the element you are inspecting. +There is a request inspector view to gain information in the requests done for this +element or a data inspector view to inspect the underlying data. Whether or not +a specific view is available depends on the used adapters. + +## Inspector Adapters + +Since the Inspector panel itself is not tied to a specific type of elements (visualizations, +saved searches, etc.), everything you need to open the inspector is a collection +of so called inspector adapters. A single adapter can be any type of JavaScript class. + +Most likely an adapter offers some kind of logging capabilities for the element, that +uses it e.g. the request adapter allows element (like visualizations) to log requests +they make. + +The corresponding inspector view will then use the information inside the adapter +to present the data in the panel. That concept allows different types of elements +to use the Inspector panel, while they can use completely or partial different adapters +and inspector views than other elements. + +For example a visualization could provide the request and data adapter while a saved +search could only provide the request adapter and a Vega visualization could additionally +provide a Vega adapter. + +There is no 1 to 1 relationship between adapters and views. An adapter could be used +by multiple views and a view can use data from multiple adapters. It's up to the +view to decide whether or not it wants to be shown for a given adapters list. + +## Develop custom inspectors + +You can extend the inspector panel by adding custom inspector views and inspector +adapters via a plugin. + +### Develop inspector views + +To develop custom inspector views you can define your +inspector view as follows: + +```js +import React from 'react'; +import { viewRegistry } from 'ui/inspector'; + +function MyInspectorComponent(props) { + // props.adapters is the object of all adapters and may vary depending + // on who and where this inspector was opened. You should check for all + // adapters you need, in the below shouldShow method, before accessing + // them here. + return ( + <> + My custom view.... + + ); +} + +const MyLittleInspectorView = { + // Title shown to select this view + title: 'Display Name', + // An icon id from the EUI icon list + icon: 'iconName', + // An order to sort the views (lower means first) + order: 10, + // An additional helptext, that wil + help: `And additional help text, that will be shown in the inspector help.`, + shouldShow(adapters) { + // Only show if `someAdapter` is available. Make sure to check for + // all adapters that you want to access in your view later on and + // any additional condition you want to be true to be shown. + return adapters.someAdapter; + }, + // A React component, that will be used for rendering + component: MyInspectorComponent +}; +``` + +Then register your view in *setup* life-cycle with `inspector` plugin. + +```ts +class MyPlugin extends Plugin { + setup(core, { inspector }) { + inspector.registerView(MyLittleInspectorView); + } +} +``` + +### Develop custom adapters + +An inspector adapter is just a plain JavaScript class, that can e.g. be attached +to custom visualization types, so an inspector view can show additional information for this +visualization. + +To add additional adapters to your visualization type, use the `inspectorAdapters.custom` +object when defining the visualization type: + +```js +class MyCustomInspectorAdapter { + // .... +} + +// inside your visualization type description (usually passed to VisFactory.create...Type) +{ + // ... + inspectorAdapters: { + custom: { + someAdapter: MyCustomInspectorAdapter + } + } +} +``` + +An instance of MyCustomInspectorAdapter will now be available on each visualization +of that type and can be accessed via `vis.API.inspectorAdapters.someInspector`. + +Custom inspector views can now check for the presence of `adapters.someAdapter` +in their `shouldShow` method and use this adapter in their component. diff --git a/src/plugins/inspector/kibana.json b/src/plugins/inspector/kibana.json new file mode 100644 index 0000000000000..39d3ff65eed53 --- /dev/null +++ b/src/plugins/inspector/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "inspector", + "version": "kibana", + "server": false, + "ui": true +} diff --git a/src/legacy/ui/public/inspector/adapters/data/data_adapter.ts b/src/plugins/inspector/public/adapters/data/data_adapter.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/data/data_adapter.ts rename to src/plugins/inspector/public/adapters/data/data_adapter.ts diff --git a/src/legacy/ui/public/inspector/adapters/data/data_adapters.test.ts b/src/plugins/inspector/public/adapters/data/data_adapters.test.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/data/data_adapters.test.ts rename to src/plugins/inspector/public/adapters/data/data_adapters.test.ts diff --git a/src/legacy/ui/public/inspector/adapters/data/formatted_data.ts b/src/plugins/inspector/public/adapters/data/formatted_data.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/data/formatted_data.ts rename to src/plugins/inspector/public/adapters/data/formatted_data.ts diff --git a/src/legacy/ui/public/inspector/adapters/data/index.ts b/src/plugins/inspector/public/adapters/data/index.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/data/index.ts rename to src/plugins/inspector/public/adapters/data/index.ts diff --git a/src/plugins/inspector/public/adapters/index.ts b/src/plugins/inspector/public/adapters/index.ts new file mode 100644 index 0000000000000..8e1979ab33275 --- /dev/null +++ b/src/plugins/inspector/public/adapters/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 { DataAdapter, FormattedData } from './data'; +export { RequestAdapter, RequestStatus } from './request'; diff --git a/src/legacy/ui/public/inspector/adapters/request/index.ts b/src/plugins/inspector/public/adapters/request/index.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/request/index.ts rename to src/plugins/inspector/public/adapters/request/index.ts diff --git a/src/legacy/ui/public/inspector/adapters/request/request_adapter.test.ts b/src/plugins/inspector/public/adapters/request/request_adapter.test.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/request/request_adapter.test.ts rename to src/plugins/inspector/public/adapters/request/request_adapter.test.ts diff --git a/src/legacy/ui/public/inspector/adapters/request/request_adapter.ts b/src/plugins/inspector/public/adapters/request/request_adapter.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/request/request_adapter.ts rename to src/plugins/inspector/public/adapters/request/request_adapter.ts diff --git a/src/legacy/ui/public/inspector/adapters/request/request_responder.ts b/src/plugins/inspector/public/adapters/request/request_responder.ts similarity index 93% rename from src/legacy/ui/public/inspector/adapters/request/request_responder.ts rename to src/plugins/inspector/public/adapters/request/request_responder.ts index 31aa56030d874..36ae6a147b999 100644 --- a/src/legacy/ui/public/inspector/adapters/request/request_responder.ts +++ b/src/plugins/inspector/public/adapters/request/request_responder.ts @@ -48,11 +48,11 @@ export class RequestResponder { const startDate = new Date(this.request.startTime); this.request.stats.requestTimestamp = { - label: i18n.translate('common.ui.inspector.reqTimestampKey', { + label: i18n.translate('inspector.reqTimestampKey', { defaultMessage: 'Request timestamp', }), value: startDate.toISOString(), - description: i18n.translate('common.ui.inspector.reqTimestampDescription', { + description: i18n.translate('inspector.reqTimestampDescription', { defaultMessage: 'Time when the start of the request has been logged', }), }; diff --git a/src/legacy/ui/public/inspector/adapters/request/types.ts b/src/plugins/inspector/public/adapters/request/types.ts similarity index 100% rename from src/legacy/ui/public/inspector/adapters/request/types.ts rename to src/plugins/inspector/public/adapters/request/types.ts diff --git a/src/legacy/core_plugins/region_map/index.js b/src/plugins/inspector/public/index.ts similarity index 70% rename from src/legacy/core_plugins/region_map/index.js rename to src/plugins/inspector/public/index.ts index 9f2f3904f75d3..ad0c9b77e915e 100644 --- a/src/legacy/core_plugins/region_map/index.js +++ b/src/plugins/inspector/public/index.ts @@ -17,16 +17,12 @@ * under the License. */ -import { resolve } from 'path'; - -export default function (kibana) { - - return new kibana.Plugin({ - uiExports: { - visTypes: ['plugins/region_map/region_map_vis'], - interpreter: ['plugins/region_map/region_map_fn'], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - } - }); +import { PluginInitializerContext } from '../../../core/public'; +import { InspectorPublicPlugin } from './plugin'; +export function plugin(initializerContext: PluginInitializerContext) { + return new InspectorPublicPlugin(initializerContext); } + +export { InspectorPublicPlugin as Plugin, Setup, Start } from './plugin'; +export * from './types'; diff --git a/src/plugins/inspector/public/mocks.ts b/src/plugins/inspector/public/mocks.ts new file mode 100644 index 0000000000000..0e605b1dd3069 --- /dev/null +++ b/src/plugins/inspector/public/mocks.ts @@ -0,0 +1,78 @@ +/* + * 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 { Setup as PluginSetup, Start as PluginStart } from '.'; +import { InspectorViewRegistry } from './view_registry'; +import { plugin as pluginInitializer } from '.'; +// eslint-disable-next-line +import { coreMock } from '../../../core/public/mocks'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const views = new InspectorViewRegistry(); + + const setupContract: Setup = { + registerView: jest.fn(views.register.bind(views)), + + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { + views, + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + isAvailable: jest.fn(), + open: jest.fn(), + }; + + const openResult = { + onClose: Promise.resolve(undefined), + close: jest.fn(() => Promise.resolve(undefined)), + } as ReturnType; + startContract.open.mockImplementation(() => openResult); + + return startContract; +}; + +const createPlugin = async () => { + const pluginInitializerContext = coreMock.createPluginInitializerContext(); + const coreSetup = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + const plugin = pluginInitializer(pluginInitializerContext); + const setup = await plugin.setup(coreSetup); + + return { + pluginInitializerContext, + coreSetup, + coreStart, + plugin, + setup, + doStart: async () => await plugin.start(coreStart), + }; +}; + +export const inspectorPluginMock = { + createSetupContract, + createStartContract, + createPlugin, +}; diff --git a/src/plugins/inspector/public/plugin.tsx b/src/plugins/inspector/public/plugin.tsx new file mode 100644 index 0000000000000..53a7adde9b3a6 --- /dev/null +++ b/src/plugins/inspector/public/plugin.tsx @@ -0,0 +1,112 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import * as React from 'react'; +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { InspectorViewRegistry } from './view_registry'; +import { Adapters, InspectorOptions, InspectorSession } from './types'; +import { InspectorPanel } from './ui/inspector_panel'; + +export interface Setup { + registerView: InspectorViewRegistry['register']; + + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { + views: InspectorViewRegistry; + }; +} + +export interface Start { + /** + * Checks if a inspector panel could be shown based on the passed adapters. + * + * @param {object} adapters - An object of adapters. This should be the same + * you would pass into `open`. + * @returns {boolean} True, if a call to `open` with the same adapters + * would have shown the inspector panel, false otherwise. + */ + isAvailable: (adapters?: Adapters) => boolean; + + /** + * Opens the inspector panel for the given adapters and close any previously opened + * inspector panel. The previously panel will be closed also if no new panel will be + * opened (e.g. because of the passed adapters no view is available). You can use + * {@link InspectorSession#close} on the return value to close that opened panel again. + * + * @param {object} adapters - An object of adapters for which you want to show + * the inspector panel. + * @param {InspectorOptions} options - Options that configure the inspector. See InspectorOptions type. + * @return {InspectorSession} The session instance for the opened inspector. + * @throws {Error} + */ + open: (adapters: Adapters, options?: InspectorOptions) => InspectorSession; +} + +export class InspectorPublicPlugin implements Plugin { + views: InspectorViewRegistry | undefined; + + constructor(initializerContext: PluginInitializerContext) {} + + public async setup(core: CoreSetup) { + this.views = new InspectorViewRegistry(); + + return { + registerView: this.views!.register.bind(this.views), + + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { + views: this.views, + }, + }; + } + + public start(core: CoreStart) { + const isAvailable: Start['isAvailable'] = adapters => + this.views!.getVisible(adapters).length > 0; + + const closeButtonLabel = i18n.translate('inspector.closeButton', { + defaultMessage: 'Close Inspector', + }); + + const open: Start['open'] = (adapters, options = {}) => { + const views = this.views!.getVisible(adapters); + + // Don't open inspector if there are no views available for the passed adapters + if (!views || views.length === 0) { + throw new Error(`Tried to open an inspector without views being available. + Make sure to call Inspector.isAvailable() with the same adapters before to check + if an inspector can be shown.`); + } + + return core.overlays.openFlyout( + , + { + 'data-test-subj': 'inspectorPanel', + closeButtonAriaLabel: closeButtonLabel, + } + ); + }; + + return { + isAvailable, + open, + }; + } + + public stop() {} +} diff --git a/src/plugins/inspector/public/test/is_available.test.ts b/src/plugins/inspector/public/test/is_available.test.ts new file mode 100644 index 0000000000000..1aeffd68a9f3d --- /dev/null +++ b/src/plugins/inspector/public/test/is_available.test.ts @@ -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 { inspectorPluginMock } from '../mocks'; +import { DataAdapter } from '../adapters/data/data_adapter'; +import { RequestAdapter } from '../adapters/request/request_adapter'; + +const adapter1 = new DataAdapter(); +const adapter2 = new RequestAdapter(); + +describe('inspector', () => { + describe('isAvailable()', () => { + it('should return false if no view would be available', async () => { + const { doStart } = await inspectorPluginMock.createPlugin(); + const start = await doStart(); + expect(start.isAvailable({ adapter1 })).toBe(false); + }); + + it('should return true if views would be available, false otherwise', async () => { + const { setup, doStart } = await inspectorPluginMock.createPlugin(); + + setup.registerView({ + title: 'title', + help: 'help', + shouldShow(adapters: any) { + return 'adapter1' in adapters; + }, + } as any); + + const start = await doStart(); + + expect(start.isAvailable({ adapter1 })).toBe(true); + expect(start.isAvailable({ adapter2 })).toBe(false); + }); + }); +}); diff --git a/src/legacy/core_plugins/metric_vis/index.js b/src/plugins/inspector/public/test/open.test.ts similarity index 70% rename from src/legacy/core_plugins/metric_vis/index.js rename to src/plugins/inspector/public/test/open.test.ts index 3738baccb5c51..94cf161bb11c2 100644 --- a/src/legacy/core_plugins/metric_vis/index.js +++ b/src/plugins/inspector/public/test/open.test.ts @@ -17,20 +17,14 @@ * under the License. */ -import { resolve } from 'path'; - -export default function (kibana) { - - return new kibana.Plugin({ - - uiExports: { - visTypes: [ - 'plugins/metric_vis/metric_vis' - ], - interpreter: ['plugins/metric_vis/metric_vis_fn'], - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - } +import { inspectorPluginMock } from '../mocks'; +describe('inspector', () => { + describe('open()', () => { + it('should throw an error if no views available', async () => { + const { doStart } = await inspectorPluginMock.createPlugin(); + const start = await doStart(); + expect(() => start.open({})).toThrow(); + }); }); - -} +}); diff --git a/src/plugins/inspector/public/types.ts b/src/plugins/inspector/public/types.ts new file mode 100644 index 0000000000000..5c3fd770c28d3 --- /dev/null +++ b/src/plugins/inspector/public/types.ts @@ -0,0 +1,75 @@ +/* + * 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 { OverlayRef } from '../../../core/public'; + +/** + * The interface that the adapters used to open an inspector have to fullfill. + */ +export interface Adapters { + [key: string]: any; +} + +/** + * The props interface that a custom inspector view component, that will be passed + * to {@link InspectorViewDescription#component}, must use. + */ +export interface InspectorViewProps { + /** + * Adapters used to open the inspector. + */ + adapters: Adapters; + /** + * The title that the inspector is currently using e.g. a visualization name. + */ + title: string; +} + +/** + * An object describing an inspector view. + * @typedef {object} InspectorViewDescription + * @property {string} title - The title that will be used to present that view. + * @property {string} icon - An icon name to present this view. Must match an EUI icon. + * @property {React.ComponentType} component - The actual React component to render that view. + * @property {number} [order=9000] - An order for this view. Views are ordered from lower + * order values to higher order values in the UI. + * @property {string} [help=''] - An help text for this view, that gives a brief description + * of this view. + * @property {viewShouldShowFunc} [shouldShow] - A function, that determines whether + * this view should be visible for a given collection of adapters. If not specified + * the view will always be visible. + */ +export interface InspectorViewDescription { + component: React.ComponentType; + help?: string; + order?: number; + shouldShow?: (adapters: Adapters) => boolean; + title: string; +} + +/** + * Options that can be specified when opening the inspector. + * @property {string} title - An optional title, that will be shown in the header + * of the inspector. Can be used to give more context about what is being inspected. + */ +export interface InspectorOptions { + title?: string; +} + +export type InspectorSession = OverlayRef; diff --git a/src/legacy/ui/public/inspector/ui/__snapshots__/inspector_panel.test.js.snap b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap similarity index 94% rename from src/legacy/ui/public/inspector/ui/__snapshots__/inspector_panel.test.js.snap rename to src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap index 6506774cca887..843fd78b24be9 100644 --- a/src/legacy/ui/public/inspector/ui/__snapshots__/inspector_panel.test.js.snap +++ b/src/plugins/inspector/public/ui/__snapshots__/inspector_panel.test.tsx.snap @@ -112,7 +112,6 @@ exports[`InspectorPanel should render as expected 1`] = ` "timeZone": null, } } - onClose={[Function]} title="Inspector" views={ Array [ @@ -217,7 +216,7 @@ exports[`InspectorPanel should render as expected 1`] = ` >
- -

- View 1 -

-
+ +
+
+ +

+ View 1 +

+
+
+
+
`; diff --git a/src/legacy/ui/public/inspector/ui/inspector_panel.test.js b/src/plugins/inspector/public/ui/inspector_panel.test.tsx similarity index 71% rename from src/legacy/ui/public/inspector/ui/inspector_panel.test.js rename to src/plugins/inspector/public/ui/inspector_panel.test.tsx index d4c72ba0132e4..c482b6fa8033b 100644 --- a/src/legacy/ui/public/inspector/ui/inspector_panel.test.js +++ b/src/plugins/inspector/public/ui/inspector_panel.test.tsx @@ -18,65 +18,55 @@ */ import React from 'react'; -import { InspectorPanel } from './inspector_panel'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { InspectorPanel } from './inspector_panel'; +import { Adapters, InspectorViewDescription } from '../types'; describe('InspectorPanel', () => { - - let adapters; - let views; + let adapters: Adapters; + let views: InspectorViewDescription[]; beforeEach(() => { adapters = { foodapter: { - foo() { return 42; } + foo() { + return 42; + }, }, - bardapter: { - - } + bardapter: {}, }; views = [ { title: 'View 1', order: 200, - component: () => (

View 1

), - }, { + component: () =>

View 1

, + }, + { title: 'Foo View', order: 100, - component: () => (

Foo view

), - shouldShow(adapters) { - return adapters.foodapter; - } - }, { + component: () =>

Foo view

, + shouldShow(adapters2: Adapters) { + return adapters2.foodapter; + }, + }, + { title: 'Never', order: 200, component: () => null, shouldShow() { return false; - } - } + }, + }, ]; }); it('should render as expected', () => { - const component = mountWithIntl( - true} - views={views} - /> - ); + const component = mountWithIntl(); expect(component).toMatchSnapshot(); }); it('should not allow updating adapters', () => { - const component = mountWithIntl( - true} - views={views} - /> - ); + const component = mountWithIntl(); adapters.notAllowed = {}; expect(() => component.setProps({ adapters })).toThrow(); }); diff --git a/src/legacy/ui/public/inspector/ui/inspector_panel.js b/src/plugins/inspector/public/ui/inspector_panel.tsx similarity index 52% rename from src/legacy/ui/public/inspector/ui/inspector_panel.js rename to src/plugins/inspector/public/ui/inspector_panel.tsx index 26f100b89c172..953bf7e1f073b 100644 --- a/src/legacy/ui/public/inspector/ui/inspector_panel.js +++ b/src/plugins/inspector/public/ui/inspector_panel.tsx @@ -20,52 +20,73 @@ import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFlyoutHeader, - EuiTitle, -} from '@elastic/eui'; - +import { EuiFlexGroup, EuiFlexItem, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; +import { Adapters, InspectorViewDescription } from '../types'; import { InspectorViewChooser } from './inspector_view_chooser'; -function hasAdaptersChanged(oldAdapters, newAdapters) { - return Object.keys(oldAdapters).length !== Object.keys(newAdapters).length - || Object.keys(oldAdapters).some(key => oldAdapters[key] !== newAdapters[key]); +function hasAdaptersChanged(oldAdapters: Adapters, newAdapters: Adapters) { + return ( + Object.keys(oldAdapters).length !== Object.keys(newAdapters).length || + Object.keys(oldAdapters).some(key => oldAdapters[key] !== newAdapters[key]) + ); } -const inspectorTitle = i18n.translate('common.ui.inspector.title', { +const inspectorTitle = i18n.translate('inspector.title', { defaultMessage: 'Inspector', }); -class InspectorPanel extends Component { +interface InspectorPanelProps { + adapters: Adapters; + title?: string; + views: InspectorViewDescription[]; +} - constructor(props) { - super(props); - this.state = { - selectedView: props.views[0], - views: props.views, - // Clone adapters array so we can validate that this prop never change - adapters: { ...props.adapters }, - }; - } +interface InspectorPanelState { + selectedView: InspectorViewDescription; + views: InspectorViewDescription[]; + adapters: Adapters; +} + +export class InspectorPanel extends Component { + static defaultProps = { + title: inspectorTitle, + }; - static getDerivedStateFromProps(nextProps, prevState) { + static propTypes = { + adapters: PropTypes.object.isRequired, + views: (props: InspectorPanelProps, propName: string, componentName: string) => { + if (!Array.isArray(props.views) || props.views.length < 1) { + throw new Error( + `${propName} prop must be an array of at least one element in ${componentName}.` + ); + } + }, + title: PropTypes.string, + }; + + state: InspectorPanelState = { + selectedView: this.props.views[0], + views: this.props.views, + // Clone adapters array so we can validate that this prop never change + adapters: { ...this.props.adapters }, + }; + + static getDerivedStateFromProps(nextProps: InspectorPanelProps, prevState: InspectorPanelState) { if (hasAdaptersChanged(prevState.adapters, nextProps.adapters)) { throw new Error('Adapters are not allowed to be changed on an open InspectorPanel.'); } - const selectedViewMustChange = nextProps.views !== prevState.views - && !nextProps.views.includes(prevState.selectedView); + const selectedViewMustChange = + nextProps.views !== prevState.views && !nextProps.views.includes(prevState.selectedView); return { views: nextProps.views, selectedView: selectedViewMustChange ? nextProps.views[0] : prevState.selectedView, }; } - onViewSelected = (view) => { + onViewSelected = (view: InspectorViewDescription) => { if (view !== this.state.selectedView) { this.setState({ - selectedView: view + selectedView: view, }); } }; @@ -74,7 +95,7 @@ class InspectorPanel extends Component { return ( ); } @@ -86,13 +107,10 @@ class InspectorPanel extends Component { return ( - + -

{ title }

+

{title}

@@ -104,26 +122,8 @@ class InspectorPanel extends Component {
- { this.renderSelectedPanel() } + {this.renderSelectedPanel()}
); } } - -InspectorPanel.defaultProps = { - title: inspectorTitle, -}; - -InspectorPanel.propTypes = { - adapters: PropTypes.object.isRequired, - views: (props, propName, componentName) => { - if (!Array.isArray(props[propName]) || props[propName].length < 1) { - throw new Error( - `${propName} prop must be an array of at least one element in ${componentName}.` - ); - } - }, - title: PropTypes.string, -}; - -export { InspectorPanel }; diff --git a/src/legacy/ui/public/inspector/ui/inspector_view_chooser.js b/src/plugins/inspector/public/ui/inspector_view_chooser.tsx similarity index 74% rename from src/legacy/ui/public/inspector/ui/inspector_view_chooser.js rename to src/plugins/inspector/public/ui/inspector_view_chooser.tsx index 2ab4c57ce8bb8..ce6027ad383cf 100644 --- a/src/legacy/ui/public/inspector/ui/inspector_view_chooser.js +++ b/src/plugins/inspector/public/ui/inspector_view_chooser.tsx @@ -20,7 +20,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; - import { EuiButtonEmpty, EuiContextMenuItem, @@ -28,26 +27,42 @@ import { EuiPopover, EuiToolTip, } from '@elastic/eui'; +import { InspectorViewDescription } from '../types'; + +interface Props { + views: InspectorViewDescription[]; + onViewSelected: (view: InspectorViewDescription) => void; + selectedView: InspectorViewDescription; +} -class InspectorViewChooser extends Component { +interface State { + isSelectorOpen: boolean; +} + +export class InspectorViewChooser extends Component { + static propTypes = { + views: PropTypes.array.isRequired, + onViewSelected: PropTypes.func.isRequired, + selectedView: PropTypes.object.isRequired, + }; - state = { - isSelectorOpen: false + state: State = { + isSelectorOpen: false, }; toggleSelector = () => { - this.setState((prev) => ({ - isSelectorOpen: !prev.isSelectorOpen + this.setState(prev => ({ + isSelectorOpen: !prev.isSelectorOpen, })); }; closeSelector = () => { this.setState({ - isSelectorOpen: false + isSelectorOpen: false, }); }; - renderView = (view, index) => { + renderView = (view: InspectorViewDescription, index: number) => { return ( ); - } + }; renderViewButton() { return ( @@ -74,7 +89,7 @@ class InspectorViewChooser extends Component { data-test-subj="inspectorViewChooser" > @@ -84,12 +99,9 @@ class InspectorViewChooser extends Component { renderSingleView() { return ( - + @@ -117,18 +129,8 @@ class InspectorViewChooser extends Component { anchorPosition="downRight" repositionOnScroll > - + ); } } - -InspectorViewChooser.propTypes = { - views: PropTypes.array.isRequired, - onViewSelected: PropTypes.func.isRequired, - selectedView: PropTypes.object.isRequired, -}; - -export { InspectorViewChooser }; diff --git a/src/legacy/ui/public/inspector/view_registry.test.ts b/src/plugins/inspector/public/view_registry.test.ts similarity index 96% rename from src/legacy/ui/public/inspector/view_registry.test.ts rename to src/plugins/inspector/public/view_registry.test.ts index e073c6431892f..830ee107213fb 100644 --- a/src/legacy/ui/public/inspector/view_registry.test.ts +++ b/src/plugins/inspector/public/view_registry.test.ts @@ -17,7 +17,8 @@ * under the License. */ -import { InspectorViewDescription, InspectorViewRegistry } from './view_registry'; +import { InspectorViewRegistry } from './view_registry'; +import { InspectorViewDescription } from './types'; import { Adapters } from './types'; diff --git a/src/plugins/inspector/public/view_registry.ts b/src/plugins/inspector/public/view_registry.ts new file mode 100644 index 0000000000000..4a35baf1f3ef4 --- /dev/null +++ b/src/plugins/inspector/public/view_registry.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 { EventEmitter } from 'events'; +import { Adapters, InspectorViewDescription } from './types'; + +/** + * @callback viewShouldShowFunc + * @param {object} adapters - A list of adapters to check whether or not this view + * should be shown for. + * @returns {boolean} true - if this view should be shown for the given adapters. + */ + +/** + * A registry that will hold inspector views. + */ +export class InspectorViewRegistry extends EventEmitter { + private views: InspectorViewDescription[] = []; + + /** + * Register a new inspector view to the registry. Check the README.md in the + * inspector directory for more information of the object format to register + * here. This will also emit a 'change' event on the registry itself. + * + * @param {InspectorViewDescription} view - The view description to add to the registry. + */ + public register(view: InspectorViewDescription): void { + if (!view) { + return; + } + this.views.push(view); + // Keep registry sorted by the order property + this.views.sort((a, b) => (a.order || Number.MAX_VALUE) - (b.order || Number.MAX_VALUE)); + this.emit('change'); + } + + /** + * Retrieve all views currently registered with the registry. + * @returns {InspectorViewDescription[]} A by `order` sorted list of all registered + * inspector views. + */ + public getAll(): InspectorViewDescription[] { + return this.views; + } + + /** + * Retrieve all registered views, that want to be visible for the specified adapters. + * @param {object} adapters - an adapter configuration + * @returns {InspectorViewDescription[]} All inespector view descriptions visible + * for the specific adapters. + */ + public getVisible(adapters?: Adapters): InspectorViewDescription[] { + if (!adapters) { + return []; + } + return this.views.filter(view => !view.shouldShow || view.shouldShow(adapters)); + } +} diff --git a/src/test_utils/public/stub_index_pattern.js b/src/test_utils/public/stub_index_pattern.js index e7afef2a94a4c..e68dc5b43e324 100644 --- a/src/test_utils/public/stub_index_pattern.js +++ b/src/test_utils/public/stub_index_pattern.js @@ -21,7 +21,6 @@ import sinon from 'sinon'; import { IndexPattern } from 'ui/index_patterns/_index_pattern'; import { getRoutes } from 'ui/index_patterns/get_routes'; import { formatHitProvider } from 'ui/index_patterns/_format_hit'; -import { getComputedFields } from 'ui/index_patterns/_get_computed_fields'; import { fieldFormats } from 'ui/registry/field_formats'; import { flattenHitWrapper } from 'ui/index_patterns/_flatten_hit'; import { FieldList } from 'ui/index_patterns/_field_list'; @@ -36,12 +35,13 @@ export default function () { this.isTimeBased = () => Boolean(this.timeFieldName); this.getNonScriptedFields = sinon.spy(IndexPattern.prototype.getNonScriptedFields); this.getScriptedFields = sinon.spy(IndexPattern.prototype.getScriptedFields); + this.getFieldByName = sinon.spy(IndexPattern.prototype.getFieldByName); this.getSourceFiltering = sinon.stub(); this.metaFields = ['_id', '_type', '_source']; this.fieldFormatMap = {}; this.routes = getRoutes(); - this.getComputedFields = getComputedFields.bind(this); + this.getComputedFields = IndexPattern.prototype.getComputedFields.bind(this); this.flattenHit = flattenHitWrapper(this, this.metaFields); this.formatHit = formatHitProvider(this, fieldFormats.getDefaultInstance('string')); this.fieldsFetcher = { apiClient: { baseUrl: '' } }; diff --git a/tasks/config/karma.js b/tasks/config/karma.js index a59692f1f4051..2e1fc114d9511 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -28,7 +28,7 @@ module.exports = function (grunt) { if (grunt.option('browser')) { return grunt.option('browser'); } - if (process.env.TEST_BROWSER_HEADLESS) { + if (process.env.TEST_BROWSER_HEADLESS === '1') { return 'Chrome_Headless'; } return 'Chrome'; diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index 3058f497a4427..5444299215082 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -139,7 +139,7 @@ export default function ({ getService }) { statusCode: 400, error: 'Bad Request', message: 'child "type" fails because ["type" at position 0 fails because ' + - '["0" must be one of [config, index-pattern, visualization, search, dashboard, url]]]', + '["0" must be one of [config, dashboard, index-pattern, search, url, visualization]]]', validation: { source: 'payload', keys: ['type.0'], diff --git a/test/api_integration/config.js b/test/api_integration/config.js index 443dc2ab7a68b..d14630f932bf6 100644 --- a/test/api_integration/config.js +++ b/test/api_integration/config.js @@ -17,11 +17,7 @@ * under the License. */ -import { - KibanaSupertestProvider, - ElasticsearchSupertestProvider, - ChanceProvider, -} from './services'; +import { services } from './services'; export default async function ({ readConfigFile }) { const commonConfig = await readConfigFile(require.resolve('../common/config')); @@ -31,14 +27,7 @@ export default async function ({ readConfigFile }) { testFiles: [ require.resolve('./apis'), ], - services: { - es: commonConfig.get('services.es'), - esArchiver: commonConfig.get('services.esArchiver'), - retry: commonConfig.get('services.retry'), - supertest: KibanaSupertestProvider, - esSupertest: ElasticsearchSupertestProvider, - chance: ChanceProvider, - }, + services, servers: commonConfig.get('servers'), junit: { reportName: 'API Integration Tests' diff --git a/test/api_integration/services/index.ts b/test/api_integration/services/index.ts new file mode 100644 index 0000000000000..d0fcd94a6a204 --- /dev/null +++ b/test/api_integration/services/index.ts @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { services as commonServices } from '../../common/services'; + +// @ts-ignore not TS yet +import { KibanaSupertestProvider, ElasticsearchSupertestProvider } from './supertest'; + +// @ts-ignore not TS yet +import { ChanceProvider } from './chance'; + +export const services = { + es: commonServices.es, + esArchiver: commonServices.esArchiver, + retry: commonServices.retry, + supertest: KibanaSupertestProvider, + esSupertest: ElasticsearchSupertestProvider, + chance: ChanceProvider, +}; diff --git a/test/functional/README.md b/test/functional/README.md new file mode 100644 index 0000000000000..6da3ccfa2c6ee --- /dev/null +++ b/test/functional/README.md @@ -0,0 +1,3 @@ +# Kibana Functional Testing + +See our [Functional Testing Guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html) diff --git a/test/functional/apps/context/_date_nanos.js b/test/functional/apps/context/_date_nanos.js index 2c85996b31c81..26882ec8a52a9 100644 --- a/test/functional/apps/context/_date_nanos.js +++ b/test/functional/apps/context/_date_nanos.js @@ -46,13 +46,11 @@ export default function ({ getService, getPageObjects }) { it('displays predessors - anchor - successors in right order ', async function () { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, 'AU_x3-TaGFA8no6Qj999Z'); - const table = await docTable.getTable(); - const rows = await docTable.getBodyRows(table); - const actualRowsText = await Promise.all(rows.map(row => row.getVisibleText())); + const actualRowsText = await docTable.getRowsText(); const expectedRowsText = [ - 'Sep 18, 2019 @ 06:50:13.000000000\n-2', - 'Sep 18, 2019 @ 06:50:12.999999999\n-3', - 'Sep 19, 2015 @ 06:50:13.000100001\n1' + 'Sep 18, 2019 @ 06:50:13.000000000-2', + 'Sep 18, 2019 @ 06:50:12.999999999-3', + 'Sep 19, 2015 @ 06:50:13.0001000011' ]; expect(actualRowsText).to.eql(expectedRowsText); }); @@ -61,19 +59,17 @@ export default function ({ getService, getPageObjects }) { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, 'AU_x3-TaGFA8no6Qjisd'); await PageObjects.context.clickPredecessorLoadMoreButton(); await PageObjects.context.clickSuccessorLoadMoreButton(); - const table = await docTable.getTable(); - const rows = await docTable.getBodyRows(table); - const actualRowsText = await Promise.all(rows.map(row => row.getVisibleText())); + const actualRowsText = await docTable.getRowsText(); const expectedRowsText = [ - 'Sep 22, 2019 @ 23:50:13.253123345\n5', - 'Sep 18, 2019 @ 06:50:13.000000104\n4', - 'Sep 18, 2019 @ 06:50:13.000000103\n2', - 'Sep 18, 2019 @ 06:50:13.000000102\n1', - 'Sep 18, 2019 @ 06:50:13.000000101\n0', - 'Sep 18, 2019 @ 06:50:13.000000001\n-1', - 'Sep 18, 2019 @ 06:50:13.000000000\n-2', - 'Sep 18, 2019 @ 06:50:12.999999999\n-3', - 'Sep 19, 2015 @ 06:50:13.000100001\n1' + 'Sep 22, 2019 @ 23:50:13.2531233455', + 'Sep 18, 2019 @ 06:50:13.0000001044', + 'Sep 18, 2019 @ 06:50:13.0000001032', + 'Sep 18, 2019 @ 06:50:13.0000001021', + 'Sep 18, 2019 @ 06:50:13.0000001010', + 'Sep 18, 2019 @ 06:50:13.000000001-1', + 'Sep 18, 2019 @ 06:50:13.000000000-2', + 'Sep 18, 2019 @ 06:50:12.999999999-3', + 'Sep 19, 2015 @ 06:50:13.0001000011' ]; expect(actualRowsText).to.eql(expectedRowsText); diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index 344d773214dc6..147d0e74d1c98 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -45,40 +45,26 @@ export default function ({ getService, getPageObjects }) { }); it('should open the context view with the selected document as anchor', async function () { - const discoverDocTable = await docTable.getTable(); - const firstRow = (await docTable.getBodyRows(discoverDocTable))[0]; - // get the timestamp of the first row - const firstTimestamp = await (await docTable.getFields(firstRow))[0] - .getVisibleText(); + const firstTimestamp = (await docTable.getFields())[0][0]; // navigate to the context view - await (await docTable.getRowExpandToggle(firstRow)).click(); - const firstDetailsRow = (await docTable.getDetailsRows(discoverDocTable))[0]; - await (await docTable.getRowActions(firstDetailsRow))[0].click(); + await docTable.clickRowToggle({ rowIndex: 0 }); + await (await docTable.getRowActions({ rowIndex: 0 }))[0].click(); // check the anchor timestamp in the context view await retry.try(async () => { - const contextDocTable = await docTable.getTable(); - const anchorRow = await docTable.getAnchorRow(contextDocTable); - const anchorTimestamp = await (await docTable.getFields(anchorRow))[0] - .getVisibleText(); + const anchorTimestamp = (await docTable.getFields({ isAnchorRow: true }))[0][0]; expect(anchorTimestamp).to.equal(firstTimestamp); }); }); it('should open the context view with the same columns', async function () { - const table = await docTable.getTable(); - await retry.try(async () => { - const headerFields = await docTable.getHeaderFields(table); - const columnNames = await Promise.all(headerFields.map((headerField) => ( - headerField.getVisibleText() - ))); - expect(columnNames).to.eql([ - 'Time', - ...TEST_COLUMN_NAMES, - ]); - }); + const columnNames = await docTable.getHeaderFields(); + expect(columnNames).to.eql([ + 'Time', + ...TEST_COLUMN_NAMES, + ]); }); it('should open the context view with the filters disabled', async function () { diff --git a/test/functional/apps/context/_filters.js b/test/functional/apps/context/_filters.js index 101588e23396a..32332ecc4c5fa 100644 --- a/test/functional/apps/context/_filters.js +++ b/test/functional/apps/context/_filters.js @@ -29,6 +29,8 @@ const TEST_COLUMN_NAMES = ['extension', 'geo.src']; export default function ({ getService, getPageObjects }) { const docTable = getService('docTable'); const filterBar = getService('filterBar'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'context']); describe('context filters', function contextSize() { @@ -38,47 +40,40 @@ export default function ({ getService, getPageObjects }) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/39927 - it.skip('should be addable via expanded doc table rows', async function () { - const table = await docTable.getTable(); - const anchorRow = await docTable.getAnchorRow(table); - - await docTable.toggleRowExpanded(anchorRow); + it('should be addable via expanded doc table rows', async function () { + await docTable.toggleRowExpanded({ isAnchorRow: true }); - const anchorDetailsRow = await docTable.getAnchorDetailsRow(table); + const anchorDetailsRow = await docTable.getAnchorDetailsRow(); await docTable.addInclusiveFilter(anchorDetailsRow, TEST_ANCHOR_FILTER_FIELD); await PageObjects.context.waitUntilContextLoadingHasFinished(); - await docTable.toggleRowExpanded(anchorRow); - - expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, true)).to.be(true); + await docTable.toggleRowExpanded({ isAnchorRow: true }); - const rows = await docTable.getBodyRows(table); - const hasOnlyFilteredRows = ( - await Promise.all(rows.map( - async (row) => await (await docTable.getFields(row))[2].getVisibleText() - )) - ).every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); - expect(hasOnlyFilteredRows).to.be(true); + await retry.try(async () => { + expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, true)).to.be(true); + const fields = await docTable.getFields(); + const hasOnlyFilteredRows = fields + .map(row => row[2]) + .every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); + expect(hasOnlyFilteredRows).to.be(true); + }); }); it('should be toggleable via the filter bar', async function () { - const table = await docTable.getTable(); await filterBar.addFilter(TEST_ANCHOR_FILTER_FIELD, 'IS', TEST_ANCHOR_FILTER_VALUE); await PageObjects.context.waitUntilContextLoadingHasFinished(); // disable filter await filterBar.toggleFilterEnabled(TEST_ANCHOR_FILTER_FIELD); await PageObjects.context.waitUntilContextLoadingHasFinished(); - expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, false)).to.be(true); - - const rows = await docTable.getBodyRows(table); - const hasOnlyFilteredRows = ( - await Promise.all(rows.map( - async (row) => await (await docTable.getFields(row))[2].getVisibleText() - )) - ).every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); - expect(hasOnlyFilteredRows).to.be(false); + retry.try(async () => { + expect(await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, false)).to.be(true); + const fields = await docTable.getFields(); + const hasOnlyFilteredRows = fields + .map(row => row[2]) + .every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); + expect(hasOnlyFilteredRows).to.be(false); + }); }); }); } diff --git a/test/functional/apps/context/_size.js b/test/functional/apps/context/_size.js index 08f2b1e9e519c..9b693d2cda892 100644 --- a/test/functional/apps/context/_size.js +++ b/test/functional/apps/context/_size.js @@ -42,9 +42,8 @@ export default function ({ getService, getPageObjects }) { it('should default to the `context:defaultSize` setting', async function () { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID); - const table = await docTable.getTable(); await retry.try(async function () { - expect(await docTable.getBodyRows(table)).to.have.length(2 * TEST_DEFAULT_CONTEXT_SIZE + 1); + expect(await docTable.getRowsText()).to.have.length(2 * TEST_DEFAULT_CONTEXT_SIZE + 1); }); await retry.try(async function () { const predecessorCountPicker = await PageObjects.context.getPredecessorCountPicker(); @@ -58,12 +57,10 @@ export default function ({ getService, getPageObjects }) { it('should increase according to the `context:step` setting when clicking the `load newer` button', async function () { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID); - - const table = await docTable.getTable(); await PageObjects.context.clickPredecessorLoadMoreButton(); await retry.try(async function () { - expect(await docTable.getBodyRows(table)).to.have.length( + expect(await docTable.getRowsText()).to.have.length( 2 * TEST_DEFAULT_CONTEXT_SIZE + TEST_STEP_SIZE + 1 ); }); @@ -71,12 +68,10 @@ export default function ({ getService, getPageObjects }) { it('should increase according to the `context:step` setting when clicking the `load older` button', async function () { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_TYPE, TEST_ANCHOR_ID); - - const table = await docTable.getTable(); await PageObjects.context.clickSuccessorLoadMoreButton(); await retry.try(async function () { - expect(await docTable.getBodyRows(table)).to.have.length( + expect(await docTable.getRowsText()).to.have.length( 2 * TEST_DEFAULT_CONTEXT_SIZE + TEST_STEP_SIZE + 1 ); }); diff --git a/test/functional/apps/discover/_doc_navigation.js b/test/functional/apps/discover/_doc_navigation.js index 97b5cc6bae58c..e9afa7c9b4c19 100644 --- a/test/functional/apps/discover/_doc_navigation.js +++ b/test/functional/apps/discover/_doc_navigation.js @@ -46,13 +46,9 @@ export default function ({ getService, getPageObjects }) { }); it('should open the doc view of the selected document', async function () { - const discoverDocTable = await docTable.getTable(); - const firstRow = (await docTable.getBodyRows(discoverDocTable))[0]; - // navigate to the doc view - await (await docTable.getRowExpandToggle(firstRow)).click(); - const firstDetailsRow = (await docTable.getDetailsRows(discoverDocTable))[0]; - await (await docTable.getRowActions(firstDetailsRow))[1].click(); + await docTable.clickRowToggle({ rowIndex: 0 }); + await (await docTable.getRowActions({ rowIndex: 0 }))[1].click(); const hasDocHit = await testSubjects.exists('doc-hit'); expect(hasDocHit).to.be(true); diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index 4b85593d1f02c..f0d34207a87a2 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -71,7 +71,7 @@ export default function ({ getService, getPageObjects }) { ':(from:\'2015-09-19T06:31:44.000Z\',to:\'2015-09' + '-23T18:31:44.000Z\'))&_a=(columns:!(_source),index:\'logstash-' + '*\',interval:auto,query:(language:kuery,query:\'\')' + - ',sort:!(\'@timestamp\',desc))'; + ',sort:!(!(\'@timestamp\',desc)))'; const actualUrl = await PageObjects.share.getSharedUrl(); // strip the timestamp out of each URL expect(actualUrl.replace(/_t=\d{13}/, '_t=TIMESTAMP')).to.be( diff --git a/test/functional/apps/visualize/_gauge_chart.js b/test/functional/apps/visualize/_gauge_chart.js index 6b14cabb54f99..a1d5a49f1a602 100644 --- a/test/functional/apps/visualize/_gauge_chart.js +++ b/test/functional/apps/visualize/_gauge_chart.js @@ -57,7 +57,6 @@ export default function ({ getService, getPageObjects }) { }); it('should show Split Gauges', async function () { - await PageObjects.visualize.clickMetricEditor(); log.debug('Bucket = Split Group'); await PageObjects.visualize.clickBucket('Split group'); log.debug('Aggregation = Terms'); @@ -81,7 +80,6 @@ export default function ({ getService, getPageObjects }) { it('should show correct values for fields with fieldFormatters', async function () { const expectedTexts = [ '2,904', 'win 8: Count', '0B', 'win 8: Min bytes' ]; - await PageObjects.visualize.clickMetricEditor(); await PageObjects.visualize.selectAggregation('Terms'); await PageObjects.visualize.selectField('machine.os.raw'); await PageObjects.visualize.setSize('1'); diff --git a/test/functional/apps/visualize/_metric_chart.js b/test/functional/apps/visualize/_metric_chart.js index bb75ef6648d7f..237ee1ef50b1e 100644 --- a/test/functional/apps/visualize/_metric_chart.js +++ b/test/functional/apps/visualize/_metric_chart.js @@ -183,7 +183,6 @@ export default function ({ getService, getPageObjects }) { }); it('should allow filtering with buckets', async function () { - await PageObjects.visualize.clickMetricEditor(); log.debug('Bucket = Split Group'); await PageObjects.visualize.clickBucket('Split group'); log.debug('Aggregation = Terms'); diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index f3f285c03cfc7..0a2400a367a76 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -109,7 +109,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); // FLAKY: https://github.com/elastic/kibana/issues/40458 - it.skip('should show the correct count in the legend with "Human readable" duration formatter', async () => { + it('should show the correct count in the legend with "Human readable" duration formatter', async () => { await visualBuilder.clickSeriesOption(); await visualBuilder.changeDataFormatter('Duration'); await visualBuilder.setDurationFormatterSettings({ to: 'Human readable' }); diff --git a/test/functional/page_objects/dashboard_page.js b/test/functional/page_objects/dashboard_page.js index b246ea1ad08d1..7798ec3999817 100644 --- a/test/functional/page_objects/dashboard_page.js +++ b/test/functional/page_objects/dashboard_page.js @@ -434,6 +434,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) { // Note: this replacement of - to space is to preserve original logic but I'm not sure why or if it's needed. await searchFilter.type(dashName.replace('-', ' ')); await PageObjects.common.pressEnterKey(); + await find.waitForDeletedByCssSelector('.euiBasicTable-loading', 5000); }); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -460,9 +461,12 @@ export function DashboardPageProvider({ getService, getPageObjects }) { await this.gotoDashboardLandingPage(); await this.searchForDashboardWithName(dashName); - await this.selectDashboard(dashName); - await PageObjects.header.waitUntilLoadingHasFinished(); - + await retry.try(async () => { + await this.selectDashboard(dashName); + await PageObjects.header.waitUntilLoadingHasFinished(); + // check Dashboard landing page is not present + await testSubjects.missingOrFail('dashboardLandingPage', { timeout: 10000 }); + }); } async getPanelTitles() { diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 7630aef71dde9..6954bed438478 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -248,7 +248,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { } async expectMissingFieldListItemVisualize(field) { - await testSubjects.missingOrFail(`fieldVisualize-${field}`); + await testSubjects.missingOrFail(`fieldVisualize-${field}`, { allowHidden: true }); } async clickFieldListPlusFilter(field, value) { @@ -288,7 +288,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { const fieldFilterFormExists = await testSubjects.exists('discoverFieldFilter'); if (fieldFilterFormExists) { await testSubjects.click('toggleFieldFilterButton'); - await testSubjects.missingOrFail('discoverFieldFilter'); + await testSubjects.missingOrFail('discoverFieldFilter', { allowHidden: true }); } } diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index c84debcfecbde..11bf93f02e802 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -407,7 +407,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli } async clickMetricEditor() { - await find.clickByCssSelector('button[data-test-subj="toggleEditor"]'); + await find.clickByCssSelector('[group-name="metrics"] .euiAccordion__button'); } async clickMetricByIndex(index) { @@ -450,8 +450,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli async selectAggregation(myString, groupName = 'buckets', childAggregationType = null) { const comboBoxElement = await find.byCssSelector(` [group-name="${groupName}"] - vis-editor-agg-params:not(.ng-hide) - [data-test-subj="visAggEditorParams"] + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen ${childAggregationType ? '.visEditorAgg__subAgg' : ''} [data-test-subj="defaultEditorAggSelect"] `); @@ -479,7 +478,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli async toggleOpenEditor(index, toState = 'true') { // index, see selectYAxisAggregation - const toggle = await find.byCssSelector(`button[aria-controls="visAggEditorParams${index}"]`); + const toggle = await find.byCssSelector(`button[aria-controls="visEditorAggAccordion${index}"]`); const toggleOpen = await toggle.getAttribute('aria-expanded'); log.debug(`toggle ${index} expand = ${toggleOpen}`); if (toggleOpen !== toState) { @@ -497,12 +496,10 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli // select our agg const aggSelect = await find - .byCssSelector(`[data-test-subj="aggregationEditor${index}"] - vis-editor-agg-params:not(.ng-hide) [data-test-subj="defaultEditorAggSelect"]`); + .byCssSelector(`#visEditorAggAccordion${index} [data-test-subj="defaultEditorAggSelect"]`); await comboBox.setElement(aggSelect, agg); - const fieldSelect = await find.byCssSelector(`[data-test-subj="aggregationEditor${index}"] - vis-editor-agg-params:not(.ng-hide) [data-test-subj="visDefaultEditorField"]`); + const fieldSelect = await find.byCssSelector(`#visEditorAggAccordion${index} [data-test-subj="visDefaultEditorField"]`); // select our field await comboBox.setElement(fieldSelect, field); // enter custom label @@ -553,7 +550,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli log.debug(`selectField ${fieldValue}`); const selector = ` [group-name="${groupName}"] - vis-editor-agg-params:not(.ng-hide) + [data-test-subj^="visEditorAggAccordion"].euiAccordion-isOpen [data-test-subj="visAggEditorParams"] ${childAggregationType ? '.visEditorAgg__subAgg' : ''} [data-test-subj="visDefaultEditorField"] @@ -593,26 +590,26 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli } async setSize(newValue, aggId) { - const dataTestSubj = aggId ? `aggregationEditor${aggId} sizeParamEditor` : 'sizeParamEditor'; + const dataTestSubj = aggId ? `visEditorAggAccordion${aggId} sizeParamEditor` : 'sizeParamEditor'; await testSubjects.setValue(dataTestSubj, String(newValue)); } async toggleDisabledAgg(agg) { - await testSubjects.click(`aggregationEditor${agg} disableAggregationBtn`); + await testSubjects.click(`visEditorAggAccordion${agg} toggleDisableAggregationBtn`); await PageObjects.header.waitUntilLoadingHasFinished(); } async toggleAggregationEditor(agg) { - await testSubjects.click(`aggregationEditor${agg} toggleEditor`); + await find.clickByCssSelector(`[data-test-subj="visEditorAggAccordion${agg}"] .euiAccordion__button`); await PageObjects.header.waitUntilLoadingHasFinished(); } async toggleOtherBucket(agg = 2) { - return await testSubjects.click(`aggregationEditor${agg} otherBucketSwitch`); + return await testSubjects.click(`visEditorAggAccordion${agg} otherBucketSwitch`); } async toggleMissingBucket(agg = 2) { - return await testSubjects.click(`aggregationEditor${agg} missingBucketSwitch`); + return await testSubjects.click(`visEditorAggAccordion${agg} missingBucketSwitch`); } async isApplyEnabled() { @@ -1271,7 +1268,7 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli } async removeDimension(agg) { - await testSubjects.click(`aggregationEditor${agg} removeDimensionBtn`); + await testSubjects.click(`visEditorAggAccordion${agg} removeDimensionBtn`); } } diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index 93858cdb44523..a4cd98b2a06ec 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -31,12 +31,9 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { const appMenu = await testSubjects.find('navDrawer'); const $ = await appMenu.parseDomContent(); - const links: Array<{ - text: string; - href: string; - }> = $.findTestSubjects('navDrawerAppsMenuLink') + const links = $.findTestSubjects('navDrawerAppsMenuLink') .toArray() - .map((link: any) => { + .map(link => { return { text: $(link).text(), href: $(link).attr('href'), diff --git a/test/functional/services/doc_table.js b/test/functional/services/doc_table.js deleted file mode 100644 index 5992ee7c5e57d..0000000000000 --- a/test/functional/services/doc_table.js +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function DocTableProvider({ getService, getPageObjects }) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const PageObjects = getPageObjects(['common', 'header']); - - - class DocTable { - async getTable() { - return await testSubjects.find('docTable'); - } - - async getBodyRows(table) { - return await table.findAllByCssSelector('[data-test-subj~="docTableRow"]'); - } - - async getAnchorRow(table) { - return await table.findByCssSelector('[data-test-subj~="docTableAnchorRow"]'); - } - - async getAnchorDetailsRow(table) { - return await table.findByCssSelector('[data-test-subj~="docTableAnchorRow"] + [data-test-subj~="docTableDetailsRow"]'); - } - - async getRowExpandToggle(row) { - return await row.findByCssSelector('[data-test-subj~="docTableExpandToggleColumn"]'); - } - - async getDetailsRows(table) { - return await table.findAllByCssSelector('[data-test-subj~="docTableRow"] + [data-test-subj~="docTableDetailsRow"]'); - } - - async getRowActions(row) { - return await row.findAllByCssSelector('[data-test-subj~="docTableRowAction"]'); - } - - async getFields(row) { - return await row.findAllByCssSelector('[data-test-subj~="docTableField"]'); - } - - async getHeaderFields(table) { - return await table.findAllByCssSelector('[data-test-subj~="docTableHeaderField"]'); - } - - async getTableDocViewRow(detailsRow, fieldName) { - return await detailsRow.findByCssSelector(`[data-test-subj~="tableDocViewRow-${fieldName}"]`); - } - - async getAddInclusiveFilterButton(tableDocViewRow) { - return await tableDocViewRow.findByCssSelector(`[data-test-subj~="addInclusiveFilterButton"]`); - } - - async addInclusiveFilter(detailsRow, fieldName) { - const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); - const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); - await addInclusiveFilterButton.click(); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - } - - async toggleRowExpanded(row) { - const rowExpandToggle = await this.getRowExpandToggle(row); - await rowExpandToggle.click(); - await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); - - const detailsRow = await row.findByXpath('./following-sibling::*[@data-test-subj="docTableDetailsRow"]'); - return await retry.try(async () => { - return detailsRow.findByCssSelector('[data-test-subj~="docViewer"]'); - }); - } - } - - return new DocTable(); -} diff --git a/test/functional/services/doc_table.ts b/test/functional/services/doc_table.ts new file mode 100644 index 0000000000000..e09500317cd32 --- /dev/null +++ b/test/functional/services/doc_table.ts @@ -0,0 +1,165 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; +import { WebElementWrapper } from './lib/web_element_wrapper'; + +export function DocTableProvider({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['common', 'header']); + + interface SelectOptions { + isAnchorRow: boolean; + rowIndex: number; + } + + class DocTable { + public async getTable() { + return await testSubjects.find('docTable'); + } + + public async getRowsText() { + const table = await this.getTable(); + const $ = await table.parseDomContent(); + return $.findTestSubjects('docTableRow') + .toArray() + .map((row: any) => + $(row) + .text() + .trim() + ); + } + + public async getBodyRows(): Promise { + const table = await this.getTable(); + return await table.findAllByCssSelector('[data-test-subj~="docTableRow"]'); + } + + public async getAnchorRow(): Promise { + const table = await this.getTable(); + return await table.findByCssSelector('[data-test-subj~="docTableAnchorRow"]'); + } + + public async getRow(options: SelectOptions): Promise { + return options.isAnchorRow + ? await this.getAnchorRow() + : (await this.getBodyRows())[options.rowIndex]; + } + + public async getAnchorDetailsRow(): Promise { + const table = await this.getTable(); + return await table.findByCssSelector( + '[data-test-subj~="docTableAnchorRow"] + [data-test-subj~="docTableDetailsRow"]' + ); + } + + public async clickRowToggle( + options: SelectOptions = { isAnchorRow: false, rowIndex: 0 } + ): Promise { + const row = await this.getRow(options); + const toggle = await row.findByCssSelector('[data-test-subj~="docTableExpandToggleColumn"]'); + await toggle.click(); + } + + public async getDetailsRows(): Promise { + const table = await this.getTable(); + return await table.findAllByCssSelector( + '[data-test-subj~="docTableRow"] + [data-test-subj~="docTableDetailsRow"]' + ); + } + + public async getRowActions( + options: SelectOptions = { isAnchorRow: false, rowIndex: 0 } + ): Promise { + const detailsRow = options.isAnchorRow + ? await this.getAnchorDetailsRow() + : (await this.getDetailsRows())[options.rowIndex]; + return await detailsRow.findAllByCssSelector('[data-test-subj~="docTableRowAction"]'); + } + + public async getFields(options: { isAnchorRow: boolean } = { isAnchorRow: false }) { + const table = await this.getTable(); + const $ = await table.parseDomContent(); + const rowLocator = options.isAnchorRow ? 'docTableAnchorRow' : 'docTableRow'; + const rows = $.findTestSubjects(rowLocator).toArray(); + const fields = rows.map((row: any) => + $(row) + .find('[data-test-subj~="docTableField"]') + .toArray() + .map((field: any) => $(field).text()) + ); + return fields; + } + + public async getHeaderFields(): Promise { + const table = await this.getTable(); + const $ = await table.parseDomContent(); + return $.findTestSubjects('docTableHeaderField') + .toArray() + .map((field: any) => + $(field) + .text() + .trim() + ); + } + + public async getTableDocViewRow( + detailsRow: WebElementWrapper, + fieldName: WebElementWrapper + ): Promise { + return await detailsRow.findByCssSelector(`[data-test-subj~="tableDocViewRow-${fieldName}"]`); + } + + public async getAddInclusiveFilterButton( + tableDocViewRow: WebElementWrapper + ): Promise { + return await tableDocViewRow.findByCssSelector( + `[data-test-subj~="addInclusiveFilterButton"]` + ); + } + + public async addInclusiveFilter( + detailsRow: WebElementWrapper, + fieldName: WebElementWrapper + ): Promise { + const tableDocViewRow = await this.getTableDocViewRow(detailsRow, fieldName); + const addInclusiveFilterButton = await this.getAddInclusiveFilterButton(tableDocViewRow); + await addInclusiveFilterButton.click(); + await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + } + + public async toggleRowExpanded( + options: SelectOptions = { isAnchorRow: false, rowIndex: 0 } + ): Promise { + await this.clickRowToggle(options); + await PageObjects.header.awaitGlobalLoadingIndicatorHidden(); + return await retry.try(async () => { + const row = options.isAnchorRow + ? await this.getAnchorRow() + : (await this.getBodyRows())[options.rowIndex]; + const detailsRow = await row.findByXpath( + './following-sibling::*[@data-test-subj="docTableDetailsRow"]' + ); + return detailsRow.findByCssSelector('[data-test-subj~="docViewer"]'); + }); + } + } + + return new DocTable(); +} diff --git a/test/functional/services/find.ts b/test/functional/services/find.ts index e94e1975231ce..4fdc26619d8be 100644 --- a/test/functional/services/find.ts +++ b/test/functional/services/find.ts @@ -32,6 +32,7 @@ export async function FindProvider({ getService }: FtrProviderContext) { const browserType = webdriver.browserType; const WAIT_FOR_EXISTS_TIME = config.get('timeouts.waitForExists'); + const POLLING_TIME = 500; const defaultFindTimeout = config.get('timeouts.find'); const fixedHeaderHeight = config.get('layout.fixedHeaderHeight'); @@ -426,7 +427,7 @@ export async function FindProvider({ getService }: FtrProviderContext) { timeout: number = defaultFindTimeout ) { log.debug(`Find.waitForDeletedByCssSelector('${selector}') with timeout=${timeout}`); - await this._withTimeout(1000); + await this._withTimeout(POLLING_TIME); await driver.wait( async () => { const found = await driver.findElements(By.css(selector)); diff --git a/test/functional/services/inspector.js b/test/functional/services/inspector.js index 67d3c1113103b..9c25ebea48b4f 100644 --- a/test/functional/services/inspector.js +++ b/test/functional/services/inspector.js @@ -85,7 +85,7 @@ export function InspectorProvider({ getService }) { // The buttons for setting table page size are in a popover element. This popover // element appears as if it's part of the inspectorPanel but it's really attached // to the body element by a portal. - const tableSizesPopover = await find.byCssSelector('.euiPanel'); + const tableSizesPopover = await find.byCssSelector('.euiPanel .euiContextMenuPanel'); await find.clickByButtonText(`${size} rows`, tableSizesPopover); } diff --git a/test/functional/services/lib/web_element_wrapper/custom_cheerio_api.ts b/test/functional/services/lib/web_element_wrapper/custom_cheerio_api.ts new file mode 100644 index 0000000000000..301eb656ed6f6 --- /dev/null +++ b/test/functional/services/lib/web_element_wrapper/custom_cheerio_api.ts @@ -0,0 +1,246 @@ +/* eslint-disable */ + +/** + * Type interfaces extracted from node_modules/@types/cheerio/index.d.ts + * and customized to include our custom methods + */ + +interface CheerioSelector { + (selector: string): CustomCheerio; + (selector: string, context: string): CustomCheerio; + (selector: string, context: CheerioElement): CustomCheerio; + (selector: string, context: CheerioElement[]): CustomCheerio; + (selector: string, context: Cheerio): CustomCheerio; + (selector: string, context: string, root: string): CustomCheerio; + (selector: string, context: CheerioElement, root: string): CustomCheerio; + (selector: string, context: CheerioElement[], root: string): CustomCheerio; + (selector: string, context: Cheerio, root: string): CustomCheerio; + (selector: any): CustomCheerio; +} + +export interface CustomCheerioStatic extends CheerioSelector { + // Document References + // Cheerio https://github.com/cheeriojs/cheerio + // JQuery http://api.jquery.com + xml(): string; + root(): CustomCheerio; + contains(container: CheerioElement, contained: CheerioElement): boolean; + parseHTML(data: string, context?: Document, keepScripts?: boolean): Document[]; + + html(options?: CheerioOptionsInterface): string; + html(selector: string, options?: CheerioOptionsInterface): string; + html(element: CustomCheerio, options?: CheerioOptionsInterface): string; + html(element: CheerioElement, options?: CheerioOptionsInterface): string; + + // + // CUSTOM METHODS + // + findTestSubjects(selector: string): CustomCheerio; + findTestSubject(selector: string): CustomCheerio; +} + +export interface CustomCheerio { + // Document References + // Cheerio https://github.com/cheeriojs/cheerio + // JQuery http://api.jquery.com + + [index: number]: CheerioElement; + length: number; + + // Attributes + + attr(): { [attr: string]: string }; + attr(name: string): string; + attr(name: string, value: any): CustomCheerio; + + data(): any; + data(name: string): any; + data(name: string, value: any): any; + + val(): string; + val(value: string): CustomCheerio; + + removeAttr(name: string): CustomCheerio; + + has(selector: string): CustomCheerio; + has(element: CheerioElement): CustomCheerio; + + hasClass(className: string): boolean; + addClass(classNames: string): CustomCheerio; + + removeClass(): CustomCheerio; + removeClass(className: string): CustomCheerio; + removeClass(func: (index: number, className: string) => string): CustomCheerio; + + toggleClass(className: string): CustomCheerio; + toggleClass(className: string, toggleSwitch: boolean): CustomCheerio; + toggleClass(toggleSwitch?: boolean): CustomCheerio; + toggleClass( + func: (index: number, className: string, toggleSwitch: boolean) => string, + toggleSwitch?: boolean + ): CustomCheerio; + + is(selector: string): boolean; + is(element: CheerioElement): boolean; + is(element: CheerioElement[]): boolean; + is(selection: CustomCheerio): boolean; + is(func: (index: number, element: CheerioElement) => boolean): boolean; + + // Form + serialize(): string; + serializeArray(): Array<{ name: string; value: string }>; + + // Traversing + + find(selector: string): CustomCheerio; + find(element: CustomCheerio): CustomCheerio; + + parent(selector?: string): CustomCheerio; + parents(selector?: string): CustomCheerio; + parentsUntil(selector?: string, filter?: string): CustomCheerio; + parentsUntil(element: CheerioElement, filter?: string): CustomCheerio; + parentsUntil(element: CustomCheerio, filter?: string): CustomCheerio; + + prop(name: string): any; + prop(name: string, value: any): CustomCheerio; + + closest(): CustomCheerio; + closest(selector: string): CustomCheerio; + + next(selector?: string): CustomCheerio; + nextAll(): CustomCheerio; + nextAll(selector: string): CustomCheerio; + + nextUntil(selector?: string, filter?: string): CustomCheerio; + nextUntil(element: CheerioElement, filter?: string): CustomCheerio; + nextUntil(element: CustomCheerio, filter?: string): CustomCheerio; + + prev(selector?: string): CustomCheerio; + prevAll(): CustomCheerio; + prevAll(selector: string): CustomCheerio; + + prevUntil(selector?: string, filter?: string): CustomCheerio; + prevUntil(element: CheerioElement, filter?: string): CustomCheerio; + prevUntil(element: CustomCheerio, filter?: string): CustomCheerio; + + slice(start: number, end?: number): CustomCheerio; + + siblings(selector?: string): CustomCheerio; + + children(selector?: string): CustomCheerio; + + contents(): CustomCheerio; + + each(func: (index: number, element: CheerioElement) => any): CustomCheerio; + map(func: (index: number, element: CheerioElement) => any): CustomCheerio; + + filter(selector: string): CustomCheerio; + filter(selection: CustomCheerio): CustomCheerio; + filter(element: CheerioElement): CustomCheerio; + filter(elements: CheerioElement[]): CustomCheerio; + filter(func: (index: number, element: CheerioElement) => boolean): CustomCheerio; + + not(selector: string): CustomCheerio; + not(selection: CustomCheerio): CustomCheerio; + not(element: CheerioElement): CustomCheerio; + not(func: (index: number, element: CheerioElement) => boolean): CustomCheerio; + + first(): CustomCheerio; + last(): CustomCheerio; + + eq(index: number): CustomCheerio; + + get(): any[]; + get(index: number): any; + + index(): number; + index(selector: string): number; + index(selection: CustomCheerio): number; + + end(): CustomCheerio; + + add(selectorOrHtml: string): CustomCheerio; + add(selector: string, context: Document): CustomCheerio; + add(element: CheerioElement): CustomCheerio; + add(elements: CheerioElement[]): CustomCheerio; + add(selection: CustomCheerio): CustomCheerio; + + addBack(): CustomCheerio; + addBack(filter: string): CustomCheerio; + + // Manipulation + appendTo(target: CustomCheerio): CustomCheerio; + prependTo(target: CustomCheerio): CustomCheerio; + + append(content: string, ...contents: any[]): CustomCheerio; + append(content: Document, ...contents: any[]): CustomCheerio; + append(content: Document[], ...contents: any[]): CustomCheerio; + append(content: CustomCheerio, ...contents: any[]): CustomCheerio; + + prepend(content: string, ...contents: any[]): CustomCheerio; + prepend(content: Document, ...contents: any[]): CustomCheerio; + prepend(content: Document[], ...contents: any[]): CustomCheerio; + prepend(content: CustomCheerio, ...contents: any[]): CustomCheerio; + + after(content: string, ...contents: any[]): CustomCheerio; + after(content: Document, ...contents: any[]): CustomCheerio; + after(content: Document[], ...contents: any[]): CustomCheerio; + after(content: CustomCheerio, ...contents: any[]): CustomCheerio; + + insertAfter(content: string): CustomCheerio; + insertAfter(content: Document): CustomCheerio; + insertAfter(content: CustomCheerio): CustomCheerio; + + before(content: string, ...contents: any[]): CustomCheerio; + before(content: Document, ...contents: any[]): CustomCheerio; + before(content: Document[], ...contents: any[]): CustomCheerio; + before(content: CustomCheerio, ...contents: any[]): CustomCheerio; + + insertBefore(content: string): CustomCheerio; + insertBefore(content: Document): CustomCheerio; + insertBefore(content: CustomCheerio): CustomCheerio; + + remove(selector?: string): CustomCheerio; + + replaceWith(content: string): CustomCheerio; + replaceWith(content: CheerioElement): CustomCheerio; + replaceWith(content: CheerioElement[]): CustomCheerio; + replaceWith(content: CustomCheerio): CustomCheerio; + replaceWith(content: () => CustomCheerio): CustomCheerio; + + empty(): CustomCheerio; + + html(): string | null; + html(html: string): CustomCheerio; + + text(): string; + text(text: string): CustomCheerio; + + wrap(content: string): CustomCheerio; + wrap(content: Document): CustomCheerio; + wrap(content: CustomCheerio): CustomCheerio; + + css(propertyName: string): string; + css(propertyNames: string[]): string[]; + css(propertyName: string, value: string): CustomCheerio; + css(propertyName: string, value: number): CustomCheerio; + css(propertyName: string, func: (index: number, value: string) => string): CustomCheerio; + css(propertyName: string, func: (index: number, value: string) => number): CustomCheerio; + css(properties: Record): CustomCheerio; + + // Rendering + + // Miscellaneous + + clone(): CustomCheerio; + + // Not Documented + + toArray(): CheerioElement[]; + + // + // CUSTOM METHODS + // + findTestSubjects(selector: string): CustomCheerio; + findTestSubject(selector: string): CustomCheerio; +} diff --git a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts index 2ae4616283205..b05485618da01 100644 --- a/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts +++ b/test/functional/services/lib/web_element_wrapper/web_element_wrapper.ts @@ -25,6 +25,7 @@ import { PNG } from 'pngjs'; import cheerio from 'cheerio'; import testSubjSelector from '@kbn/test-subj-selector'; import { ToolingLog } from '@kbn/dev-utils'; +import { CustomCheerio, CustomCheerioStatic } from './custom_cheerio_api'; // @ts-ignore not supported yet import { scrollIntoViewIfNecessary } from './scroll_into_view_if_necessary'; import { Browsers } from '../../remote/browsers'; @@ -650,24 +651,28 @@ export class WebElementWrapper { * Gets element innerHTML and wrap it up with cheerio * * @nonstandard - * @return {Promise} + * @return {Promise} */ - public async parseDomContent(): Promise { + public async parseDomContent(): Promise { const htmlContent: any = await this.getAttribute('innerHTML'); const $: any = cheerio.load(htmlContent, { normalizeWhitespace: true, xmlMode: true, }); - $.findTestSubjects = function testSubjects(selector: string) { + $.findTestSubjects = function findTestSubjects(this: CustomCheerioStatic, selector: string) { return this(testSubjSelector(selector)); }; - $.fn.findTestSubjects = function testSubjects(selector: string) { + $.fn.findTestSubjects = function findTestSubjects(this: CustomCheerio, selector: string) { return this.find(testSubjSelector(selector)); }; - $.findTestSubject = $.fn.findTestSubject = function testSubjects(selector: string) { + $.findTestSubject = function findTestSubject(this: CustomCheerioStatic, selector: string) { + return this.findTestSubjects(selector).first(); + }; + + $.fn.findTestSubject = function findTestSubject(this: CustomCheerio, selector: string) { return this.findTestSubjects(selector).first(); }; diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 931544d60225a..6377d97dad28b 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -38,7 +38,8 @@ import { preventParallelCalls } from './prevent_parallel_calls'; import { Browsers } from './browsers'; -const throttleOption = process.env.TEST_THROTTLE_NETWORK; +const throttleOption: string = process.env.TEST_THROTTLE_NETWORK as string; +const headlessBrowser: string = process.env.TEST_BROWSER_HEADLESS as string; const SECOND = 1000; const MINUTE = 60 * SECOND; const NO_QUEUE_COMMANDS = ['getLog', 'getStatus', 'newSession', 'quit']; @@ -73,7 +74,7 @@ async function attemptToCreateCommand(log: ToolingLog, browserType: Browsers) { 'use-fake-device-for-media-stream', 'use-fake-ui-for-media-stream', ]; - if (process.env.TEST_BROWSER_HEADLESS) { + if (headlessBrowser === '1') { // Use --disable-gpu to avoid an error from a missing Mesa library, as per // See: https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md chromeOptions.push('headless', 'disable-gpu'); @@ -90,7 +91,7 @@ async function attemptToCreateCommand(log: ToolingLog, browserType: Browsers) { .build(); case 'firefox': const firefoxOptions = new firefox.Options(); - if (process.env.TEST_BROWSER_HEADLESS) { + if (headlessBrowser === '1') { // See: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Headless_mode firefoxOptions.addArguments('-headless'); } @@ -106,7 +107,7 @@ async function attemptToCreateCommand(log: ToolingLog, browserType: Browsers) { const session = await buildDriverInstance(); - if (throttleOption === 'true' && browserType === 'chrome') { + if (throttleOption === '1' && browserType === 'chrome') { // Only chrome supports this option. log.debug('NETWORK THROTTLED: 768k down, 256k up, 100ms latency.'); diff --git a/test/functional/services/test_subjects.ts b/test/functional/services/test_subjects.ts index ce1ba37f267ec..538870ca8268e 100644 --- a/test/functional/services/test_subjects.ts +++ b/test/functional/services/test_subjects.ts @@ -59,11 +59,14 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { public async missingOrFail( selector: string, - existsOptions?: ExistsOptions + options: ExistsOptions = {} ): Promise { - if (await this.exists(selector, existsOptions)) { - throw new Error(`expected testSubject(${selector}) to not exist`); - } + const { timeout = WAIT_FOR_EXISTS_TIME, allowHidden = false } = options; + + log.debug(`TestSubjects.missingOrFail(${selector})`); + return await (allowHidden + ? this.waitForHidden(selector, timeout) + : find.waitForDeletedByCssSelector(testSubjSelector(selector), timeout)); } public async append(selector: string, text: string): Promise { @@ -245,6 +248,12 @@ export function TestSubjectsProvider({ getService }: FtrProviderContext) { await find.waitForAttributeToChange(testSubjSelector(selector), attribute, value); } + public async waitForHidden(selector: string, timeout?: number): Promise { + log.debug(`TestSubjects.waitForHidden(${selector})`); + const element = await this.find(selector); + await find.waitForElementHidden(element, timeout); + } + public getCssSelector(selector: string): string { return testSubjSelector(selector); } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json index b2d29f553acb7..f120e4051d705 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "13.0.0", + "@elastic/eui": "13.1.1", "react": "^16.8.0", "react-dom": "^16.8.0" } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js index ea5d894b21d46..bd58184cd1185 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js @@ -23,8 +23,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { uiModules } from 'ui/modules'; import chrome from 'ui/chrome'; -import { RequestAdapter } from 'ui/inspector/adapters/request'; -import { DataAdapter } from 'ui/inspector/adapters/data'; +import { RequestAdapter, DataAdapter } from 'ui/inspector/adapters'; import { runPipeline } from 'ui/visualize/loader/pipeline_helpers'; import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; diff --git a/test/types/mocha_decorations.d.ts b/test/mocha_decorations.d.ts similarity index 100% rename from test/types/mocha_decorations.d.ts rename to test/mocha_decorations.d.ts diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json index db8e1611fc3b6..c624057bdd8e9 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/package.json @@ -7,7 +7,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@elastic/eui": "13.0.0", + "@elastic/eui": "13.1.1", "react": "^16.8.0" } } diff --git a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_editor.js b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_editor.js index d9f70096aa6e8..7b14ecf692ffa 100644 --- a/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_editor.js +++ b/test/plugin_functional/plugins/kbn_tp_custom_visualizations/public/self_changing_vis/self_changing_editor.js @@ -27,16 +27,14 @@ import { export class SelfChangingEditor extends React.Component { onCounterChange = (ev) => { - this.props.stageEditorParams({ - counter: parseInt(ev.target.value), - }); + this.props.setValue('counter', parseInt(ev.target.value)); } render() { return ( = { type PublicMethodsOf = Pick>; type MockedKeys = { [P in keyof T]: jest.Mocked }; + +type DeeplyMockedKeys = { + [P in keyof T]: T[P] extends (...args: any[]) => any + ? jest.MockInstance, Parameters> + : DeeplyMockedKeys; +} & + T; diff --git a/webpackShims/moment.js b/webpackShims/moment.js index c6aca40432a85..31476d18c9562 100644 --- a/webpackShims/moment.js +++ b/webpackShims/moment.js @@ -17,4 +17,4 @@ * under the License. */ -module.exports = require('../node_modules/moment/min/moment.min.js'); +module.exports = require('../node_modules/moment/min/moment-with-locales.min.js'); diff --git a/x-pack/README.md b/x-pack/README.md index df6288512c120..0a68608fd8188 100644 --- a/x-pack/README.md +++ b/x-pack/README.md @@ -61,6 +61,8 @@ yarn test:server #### Running functional tests +For more info, see [the Elastic functional test development guide](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html). + The functional UI tests, the API integration tests, and the SAML API integration tests are all run against a live browser, Kibana, and Elasticsearch install. Each set of tests is specified with a unique config that describes how to start the Elasticsearch server, the Kibana server, and what tests to run against them. The sets of tests that exist today are *functional UI tests* ([specified by this config](test/functional/config.js)), *API integration tests* ([specified by this config](test/api_integration/config.js)), and *SAML API integration tests* ([specified by this config](test/saml_api_integration/config.js)). The script runs all sets of tests sequentially like so: diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index ee7de375c1120..e949b0ef6fe40 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -12,15 +12,4 @@ const bluebird = require('bluebird'); bluebird.Promise.setScheduler(function (fn) { global.setImmediate.call(global, fn); }); const MutationObserver = require('mutation-observer'); -// There's a bug in mutation-observer around the `attributes` option -// https://dom.spec.whatwg.org/#mutationobserver -// If either options's attributeOldValue or attributeFilter is present and options's attributes is omitted, then set options's attributes to true. -const _observe = MutationObserver.prototype.observe; -MutationObserver.prototype.observe = function observe(target, options) { - const needsAttributes = options.hasOwnProperty('attributeOldValue') || options.hasOwnProperty('attributeFilter'); - if (needsAttributes && !options.hasOwnProperty('attributes')) { - options.attributes = true; - } - Function.prototype.call(_observe, this, target, options); -}; Object.defineProperty(window, 'MutationObserver', { value: MutationObserver }); diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md index 0dca7d2e12f06..f17456d6ea393 100644 --- a/x-pack/legacy/plugins/actions/README.md +++ b/x-pack/legacy/plugins/actions/README.md @@ -72,11 +72,10 @@ Payload: |Property|Description|Type| |---|---|---| -|attributes.description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| -|attributes.actionTypeId|The id value of the action type you want to call when the action executes.|string| -|attributes.actionTypeConfig|The configuration the action type expects. See related action type to see what attributes is expected. This will also validate against the action type if config validation is defined.|object| -|references|An array of `name`, `type` and `id`. This is the same as `references` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|Array| -|migrationVersion|The version of the most recent migrations. This is the same as `migrationVersion` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|object| +|description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| +|actionTypeId|The id value of the action type you want to call when the action executes.|string| +|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| +|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| #### `DELETE /api/action/{id}`: Delete action @@ -116,10 +115,9 @@ Payload: |Property|Description|Type| |---|---|---| -|attributes.description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| -|attributes.actionTypeConfig|The configuration the action type expects. See related action type to see what attributes is expected. This will also validate against the action type if config validation is defined.|object| -|references|An array of `name`, `type` and `id`. This is the same as `references` in the saved objects API. See the saved objects API documentation.

In most cases, you can leave this empty.|Array| -|version|The document version when read|string| +|description|A description to reference and search in the future. This value will be used to populate dropdowns.|string| +|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| +|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| #### `POST /api/action/{id}/_fire`: Fire action @@ -147,8 +145,7 @@ The following table describes the properties of the `options` object. |---|---|---| |id|The id of the action you want to fire.|string| |params|The `params` value to give the action type executor.|object| -|namespace|The saved object namespace the action exists within.|string| -|basePath|This is a temporary parameter, but we need to capture and track the value of `request.getBasePath()` until future changes are made.

In most cases this can be `undefined` unless you need cross spaces support.|string| +|spaceId|The space id the action is within.|string| ### Example @@ -157,14 +154,13 @@ This example makes action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` fire an email. ``` server.plugins.actions.fire({ id: '3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5', + spaceId: 'default', // The spaceId of the action params: { from: 'example@elastic.co', to: ['destination@elastic.co'], subject: 'My email subject', body: 'My email body', }, - namespace: undefined, // The namespace the action exists within - basePath: undefined, // Usually `request.getBasePath();` or `undefined` }); ``` @@ -175,6 +171,7 @@ Kibana ships with a set of built-in action types: - server log: logs messages to the Kibana log using `server.log()` - email: send an email - slack: post a message to a slack channel +- index: index document(s) into elasticsearch ## server log, action id: `.log` @@ -247,6 +244,27 @@ This action type interfaces with the [Slack Incoming Webhooks feature](https://a |---|---|---| |message|the message text|string| + +## index, action id: `.index` + +The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elastic-stack-overview/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. + +#### config properties + +|Property|Description|Type| +|---|---|---| +|index|The Elasticsearch index to index into.|string _(optional)_| + +#### params properties + +|Property|Description|Type| +|---|---|---| +|index|The Elasticsearch index to index into.|string _(optional)_| +|doc_id|The optional _id of the document.|string _(optional)_| +|execution_time_field|The field that will store/index the action execution time.|string _(optional)_| +|refresh|Setting of the refresh policy for the write request|boolean _(optional)_| +|body|The documument body/bodies to index.|object or object[]| + # Command Line Utility The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: @@ -259,7 +277,7 @@ $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack. "attributes": { "actionTypeId": ".slack", "description": "post to slack", - "actionTypeConfig": {} + "config": {} }, "references": [], "updated_at": "2019-06-26T17:55:42.728Z", diff --git a/x-pack/legacy/plugins/actions/mappings.json b/x-pack/legacy/plugins/actions/mappings.json index e76612ca56c48..e2649568f25ec 100644 --- a/x-pack/legacy/plugins/actions/mappings.json +++ b/x-pack/legacy/plugins/actions/mappings.json @@ -7,11 +7,11 @@ "actionTypeId": { "type": "keyword" }, - "actionTypeConfig": { + "config": { "enabled": false, "type": "object" }, - "actionTypeConfigSecrets": { + "secrets": { "type": "binary" } } diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index a157e85d2ac2c..dcb8b8b299d1a 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -13,6 +13,7 @@ import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/ import { ActionTypeRegistry } from './action_type_registry'; import { ExecutorType } from './types'; import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { ExecutorError } from './lib'; const mockTaskManager = taskManagerMock.create(); @@ -27,6 +28,8 @@ const actionTypeRegistryParams = { getServices, taskManager: mockTaskManager, encryptedSavedObjectsPlugin: encryptedSavedObjectsMock.create(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }; beforeEach(() => jest.resetAllMocks()); @@ -44,22 +47,23 @@ describe('register()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); expect(actionTypeRegistry.has('my-action-type')).toEqual(true); expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); expect(mockTaskManager.registerTaskDefinitions.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "actions:my-action-type": Object { - "createTaskRunner": [MockFunction], - "title": "My action type", - "type": "actions:my-action-type", - }, - }, -] -`); + Array [ + Object { + "actions:my-action-type": Object { + "createTaskRunner": [MockFunction], + "getRetry": [Function], + "maxAttempts": 1, + "title": "My action type", + "type": "actions:my-action-type", + }, + }, + ] + `); expect(getCreateTaskRunnerFunction).toHaveBeenCalledTimes(1); const call = getCreateTaskRunnerFunction.mock.calls[0][0]; expect(call.actionTypeRegistry).toBeTruthy(); @@ -72,20 +76,38 @@ Array [ actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); expect(() => actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }) ).toThrowErrorMatchingInlineSnapshot( `"Action type \\"my-action-type\\" is already registered."` ); }); + + test('provides a getRetry function that handles ExecutorError', () => { + const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + const registerTaskDefinitionsCall = mockTaskManager.registerTaskDefinitions.mock.calls[0][0]; + const getRetry = registerTaskDefinitionsCall['actions:my-action-type'].getRetry!; + + const retryTime = new Date(); + expect(getRetry(0, new Error())).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, true))).toEqual(true); + expect(getRetry(0, new ExecutorError('my message', {}, false))).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, null))).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, undefined))).toEqual(false); + expect(getRetry(0, new ExecutorError('my message', {}, retryTime))).toEqual(retryTime); + }); }); describe('get()', () => { @@ -94,18 +116,16 @@ describe('get()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); const actionType = actionTypeRegistry.get('my-action-type'); expect(actionType).toMatchInlineSnapshot(` -Object { - "executor": [Function], - "id": "my-action-type", - "name": "My action type", - "unencryptedAttributes": Array [], -} -`); + Object { + "executor": [Function], + "id": "my-action-type", + "name": "My action type", + } + `); }); test(`throws an error when action type doesn't exist`, () => { @@ -122,7 +142,6 @@ describe('list()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); const actionTypes = actionTypeRegistry.list(); @@ -146,7 +165,6 @@ describe('has()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); expect(actionTypeRegistry.has('my-action-type')); diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts index feba76b06b81a..635d618878260 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -8,30 +8,37 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; import { ActionType, GetServicesFunction } from './types'; import { TaskManager, TaskRunCreatorFunction } from '../../task_manager'; -import { getCreateTaskRunnerFunction } from './lib'; +import { getCreateTaskRunnerFunction, ExecutorError } from './lib'; import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; +import { SpacesPlugin } from '../../spaces'; interface ConstructorOptions { taskManager: TaskManager; getServices: GetServicesFunction; encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + getBasePath: SpacesPlugin['getBasePath']; } export class ActionTypeRegistry { private readonly taskRunCreatorFunction: TaskRunCreatorFunction; - private readonly getServices: GetServicesFunction; private readonly taskManager: TaskManager; private readonly actionTypes: Map = new Map(); - private readonly encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; - constructor({ getServices, taskManager, encryptedSavedObjectsPlugin }: ConstructorOptions) { - this.getServices = getServices; + constructor({ + getServices, + taskManager, + encryptedSavedObjectsPlugin, + spaceIdToNamespace, + getBasePath, + }: ConstructorOptions) { this.taskManager = taskManager; - this.encryptedSavedObjectsPlugin = encryptedSavedObjectsPlugin; this.taskRunCreatorFunction = getCreateTaskRunnerFunction({ + getServices, actionTypeRegistry: this, - getServices: this.getServices, - encryptedSavedObjectsPlugin: this.encryptedSavedObjectsPlugin, + encryptedSavedObjectsPlugin, + spaceIdToNamespace, + getBasePath, }); } @@ -61,6 +68,14 @@ export class ActionTypeRegistry { [`actions:${actionType.id}`]: { title: actionType.name, type: `actions:${actionType.id}`, + maxAttempts: actionType.maxAttempts || 1, + getRetry(attempts: number, error: any) { + if (error instanceof ExecutorError) { + return error.retry == null ? false : error.retry; + } + // Don't retry other kinds of errors + return false; + }, createTaskRunner: this.taskRunCreatorFunction, }, }); diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index bf16b1190e7fc..d724c28d72ea5 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -10,16 +10,14 @@ import { ActionTypeRegistry } from './action_type_registry'; import { ActionsClient } from './actions_client'; import { ExecutorType } from './types'; import { taskManagerMock } from '../../task_manager/task_manager.mock'; -import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/plugin.mock'; const savedObjectsClient = SavedObjectsClientMock.create(); const mockTaskManager = taskManagerMock.create(); -const mockEncryptedSavedObjectsPlugin = { - getDecryptedAsInternalUser: jest.fn() as EncryptedSavedObjectsPlugin['getDecryptedAsInternalUser'], -} as EncryptedSavedObjectsPlugin; +const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); function getServices() { return { @@ -33,6 +31,8 @@ const actionTypeRegistryParams = { getServices, taskManager: mockTaskManager, encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }; const executor: ExecutorType = async options => { @@ -43,55 +43,56 @@ beforeEach(() => jest.resetAllMocks()); describe('create()', () => { test('creates an action with all given properties', async () => { - const expectedResult = { + const savedObjectCreateResult = { id: '1', type: 'type', - attributes: {}, + attributes: { + description: 'my description', + actionTypeId: 'my-action-type', + config: {}, + }, references: [], }; const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); const actionsClient = new ActionsClient({ actionTypeRegistry, savedObjectsClient, }); - savedObjectsClient.create.mockResolvedValueOnce(expectedResult); + savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); const result = await actionsClient.create({ - attributes: { + action: { description: 'my description', actionTypeId: 'my-action-type', - actionTypeConfig: {}, - }, - options: { - migrationVersion: {}, - references: [], + config: {}, + secrets: {}, }, }); - expect(result).toEqual(expectedResult); + expect(result).toEqual({ + id: '1', + description: 'my description', + actionTypeId: 'my-action-type', + config: {}, + }); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", Object { - "actionTypeConfig": Object {}, - "actionTypeConfigSecrets": Object {}, "actionTypeId": "my-action-type", + "config": Object {}, "description": "my description", - }, - Object { - "migrationVersion": Object {}, - "references": Array [], + "secrets": Object {}, }, ] `); }); - test('validates actionTypeConfig', async () => { + test('validates config', async () => { const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); const actionsClient = new ActionsClient({ actionTypeRegistry, @@ -100,7 +101,6 @@ describe('create()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], validate: { config: schema.object({ param1: schema.string(), @@ -110,14 +110,15 @@ describe('create()', () => { }); await expect( actionsClient.create({ - attributes: { + action: { description: 'my description', actionTypeId: 'my-action-type', - actionTypeConfig: {}, + config: {}, + secrets: {}, }, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` + `"error validating action type config: [param1]: expected value of type [string] but got [undefined]"` ); }); @@ -129,10 +130,11 @@ describe('create()', () => { }); await expect( actionsClient.create({ - attributes: { + action: { description: 'my description', actionTypeId: 'unregistered-action-type', - actionTypeConfig: {}, + config: {}, + secrets: {}, }, }) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -141,52 +143,67 @@ describe('create()', () => { }); test('encrypts action type options unless specified not to', async () => { - const expectedResult = { - id: '1', - type: 'type', - attributes: {}, - references: [], - }; const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: ['a', 'c'], executor, }); const actionsClient = new ActionsClient({ actionTypeRegistry, savedObjectsClient, }); - savedObjectsClient.create.mockResolvedValueOnce(expectedResult); - const result = await actionsClient.create({ + savedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'type', attributes: { description: 'my description', actionTypeId: 'my-action-type', - actionTypeConfig: { + config: { a: true, b: true, c: true, }, + secrets: {}, + }, + references: [], + }); + const result = await actionsClient.create({ + action: { + description: 'my description', + actionTypeId: 'my-action-type', + config: { + a: true, + b: true, + c: true, + }, + secrets: {}, + }, + }); + expect(result).toEqual({ + id: '1', + description: 'my description', + actionTypeId: 'my-action-type', + config: { + a: true, + b: true, + c: true, }, }); - expect(result).toEqual(expectedResult); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", Object { - "actionTypeConfig": Object { + "actionTypeId": "my-action-type", + "config": Object { "a": true, - "c": true, - }, - "actionTypeConfigSecrets": Object { "b": true, + "c": true, }, - "actionTypeId": "my-action-type", "description": "my description", + "secrets": Object {}, }, - undefined, ] `); }); @@ -194,20 +211,21 @@ describe('create()', () => { describe('get()', () => { test('calls savedObjectsClient with id', async () => { - const expectedResult = { - id: '1', - type: 'type', - attributes: {}, - references: [], - }; const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); const actionsClient = new ActionsClient({ actionTypeRegistry, savedObjectsClient, }); - savedObjectsClient.get.mockResolvedValueOnce(expectedResult); + savedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: {}, + references: [], + }); const result = await actionsClient.get({ id: '1' }); - expect(result).toEqual(expectedResult); + expect(result).toEqual({ + id: '1', + }); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -228,7 +246,11 @@ describe('find()', () => { { id: '1', type: 'type', - attributes: {}, + attributes: { + config: { + foo: 'bar', + }, + }, references: [], }, ], @@ -240,7 +262,19 @@ describe('find()', () => { }); savedObjectsClient.find.mockResolvedValueOnce(expectedResult); const result = await actionsClient.find({}); - expect(result).toEqual(expectedResult); + expect(result).toEqual({ + total: 1, + perPage: 10, + page: 1, + data: [ + { + id: '1', + config: { + foo: 'bar', + }, + }, + ], + }); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -275,17 +309,10 @@ describe('delete()', () => { describe('update()', () => { test('updates an action with all given properties', async () => { - const expectedResult = { - id: '1', - type: 'action', - attributes: {}, - references: [], - }; const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], executor, }); const actionsClient = new ActionsClient({ @@ -300,28 +327,42 @@ describe('update()', () => { }, references: [], }); - savedObjectsClient.update.mockResolvedValueOnce(expectedResult); - const result = await actionsClient.update({ + savedObjectsClient.update.mockResolvedValueOnce({ id: 'my-action', + type: 'action', attributes: { + actionTypeId: 'my-action-type', description: 'my description', - actionTypeConfig: {}, + config: {}, + secrets: {}, }, - options: {}, + references: [], + }); + const result = await actionsClient.update({ + id: 'my-action', + action: { + description: 'my description', + config: {}, + secrets: {}, + }, + }); + expect(result).toEqual({ + id: 'my-action', + actionTypeId: 'my-action-type', + description: 'my description', + config: {}, }); - expect(result).toEqual(expectedResult); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "my-action", Object { - "actionTypeConfig": Object {}, - "actionTypeConfigSecrets": Object {}, "actionTypeId": "my-action-type", + "config": Object {}, "description": "my description", + "secrets": Object {}, }, - Object {}, ] `); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); @@ -333,7 +374,7 @@ describe('update()', () => { `); }); - test('validates actionTypeConfig', async () => { + test('validates config', async () => { const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); const actionsClient = new ActionsClient({ actionTypeRegistry, @@ -342,7 +383,6 @@ describe('update()', () => { actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: [], validate: { config: schema.object({ param1: schema.string(), @@ -361,29 +401,22 @@ describe('update()', () => { await expect( actionsClient.update({ id: 'my-action', - attributes: { + action: { description: 'my description', - actionTypeConfig: {}, + config: {}, + secrets: {}, }, - options: {}, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` + `"error validating action type config: [param1]: expected value of type [string] but got [undefined]"` ); }); test('encrypts action type options unless specified not to', async () => { - const expectedResult = { - id: '1', - type: 'type', - attributes: {}, - references: [], - }; const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionTypeRegistry.register({ id: 'my-action-type', name: 'My action type', - unencryptedAttributes: ['a', 'c'], executor, }); const actionsClient = new ActionsClient({ @@ -398,37 +431,58 @@ describe('update()', () => { }, references: [], }); - savedObjectsClient.update.mockResolvedValueOnce(expectedResult); - const result = await actionsClient.update({ + savedObjectsClient.update.mockResolvedValueOnce({ id: 'my-action', + type: 'action', attributes: { + actionTypeId: 'my-action-type', description: 'my description', - actionTypeConfig: { + config: { a: true, b: true, c: true, }, + secrets: {}, + }, + references: [], + }); + const result = await actionsClient.update({ + id: 'my-action', + action: { + description: 'my description', + config: { + a: true, + b: true, + c: true, + }, + secrets: {}, + }, + }); + expect(result).toEqual({ + id: 'my-action', + actionTypeId: 'my-action-type', + description: 'my description', + config: { + a: true, + b: true, + c: true, }, - options: {}, }); - expect(result).toEqual(expectedResult); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "my-action", Object { - "actionTypeConfig": Object { + "actionTypeId": "my-action-type", + "config": Object { "a": true, - "c": true, - }, - "actionTypeConfigSecrets": Object { "b": true, + "c": true, }, - "actionTypeId": "my-action-type", "description": "my description", + "secrets": Object {}, }, - Object {}, ] `); }); diff --git a/x-pack/legacy/plugins/actions/server/actions_client.ts b/x-pack/legacy/plugins/actions/server/actions_client.ts index 4ab62a6e434ad..2c5e84d0d5bee 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.ts @@ -4,23 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, SavedObjectAttributes } from 'src/core/server'; +import { SavedObjectsClientContract, SavedObjectAttributes, SavedObject } from 'src/core/server'; import { ActionTypeRegistry } from './action_type_registry'; -import { SavedObjectReference } from './types'; -import { validateActionTypeConfig } from './lib'; +import { validateConfig, validateSecrets } from './lib'; +import { ActionResult } from './types'; -interface Action extends SavedObjectAttributes { +interface ActionUpdate extends SavedObjectAttributes { description: string; + config: SavedObjectAttributes; + secrets: SavedObjectAttributes; +} + +interface Action extends ActionUpdate { actionTypeId: string; - actionTypeConfig: SavedObjectAttributes; } interface CreateOptions { - attributes: Action; - options?: { - migrationVersion?: Record; - references?: SavedObjectReference[]; - }; + action: Action; } interface FindOptions { @@ -39,6 +39,13 @@ interface FindOptions { }; } +interface FindResult { + page: number; + perPage: number; + total: number; + data: ActionResult[]; +} + interface ConstructorOptions { actionTypeRegistry: ActionTypeRegistry; savedObjectsClient: SavedObjectsClientContract; @@ -46,11 +53,7 @@ interface ConstructorOptions { interface UpdateOptions { id: string; - attributes: { - description: string; - actionTypeConfig: SavedObjectAttributes; - }; - options: { version?: string; references?: SavedObjectReference[] }; + action: ActionUpdate; } export class ActionsClient { @@ -65,38 +68,82 @@ export class ActionsClient { /** * Create an action */ - public async create({ attributes, options }: CreateOptions) { - const { actionTypeId } = attributes; + public async create({ action }: CreateOptions): Promise { + const { actionTypeId, description, config, secrets } = action; const actionType = this.actionTypeRegistry.get(actionTypeId); - const validatedActionTypeConfig = validateActionTypeConfig( - actionType, - attributes.actionTypeConfig - ); - const actionWithSplitActionTypeConfig = this.moveEncryptedAttributesToSecrets( - actionType.unencryptedAttributes, - { - ...attributes, - actionTypeConfig: validatedActionTypeConfig, - } - ); - return await this.savedObjectsClient.create('action', actionWithSplitActionTypeConfig, options); + const validatedActionTypeConfig = validateConfig(actionType, config); + const validatedActionTypeSecrets = validateSecrets(actionType, secrets); + + const result = await this.savedObjectsClient.create('action', { + actionTypeId, + description, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }); + + return { + id: result.id, + actionTypeId: result.attributes.actionTypeId, + description: result.attributes.description, + config: result.attributes.config, + }; + } + + /** + * Update action + */ + public async update({ id, action }: UpdateOptions): Promise { + const existingObject = await this.savedObjectsClient.get('action', id); + const { actionTypeId } = existingObject.attributes; + const { description, config, secrets } = action; + const actionType = this.actionTypeRegistry.get(actionTypeId); + const validatedActionTypeConfig = validateConfig(actionType, config); + const validatedActionTypeSecrets = validateSecrets(actionType, secrets); + + const result = await this.savedObjectsClient.update('action', id, { + actionTypeId, + description, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }); + + return { + id, + actionTypeId: result.attributes.actionTypeId as string, + description: result.attributes.description as string, + config: result.attributes.config as Record, + }; } /** * Get an action */ - public async get({ id }: { id: string }) { - return await this.savedObjectsClient.get('action', id); + public async get({ id }: { id: string }): Promise { + const result = await this.savedObjectsClient.get('action', id); + + return { + id, + actionTypeId: result.attributes.actionTypeId as string, + description: result.attributes.description as string, + config: result.attributes.config as Record, + }; } /** * Find actions */ - public async find({ options = {} }: FindOptions) { - return await this.savedObjectsClient.find({ + public async find({ options = {} }: FindOptions): Promise { + const findResult = await this.savedObjectsClient.find({ ...options, type: 'action', }); + + return { + page: findResult.page, + perPage: findResult.per_page, + total: findResult.total, + data: findResult.saved_objects.map(actionFromSavedObject), + }; } /** @@ -105,53 +152,11 @@ export class ActionsClient { public async delete({ id }: { id: string }) { return await this.savedObjectsClient.delete('action', id); } +} - /** - * Update action - */ - public async update({ id, attributes, options = {} }: UpdateOptions) { - const existingObject = await this.savedObjectsClient.get('action', id); - const { actionTypeId } = existingObject.attributes; - const actionType = this.actionTypeRegistry.get(actionTypeId); - - const validatedActionTypeConfig = validateActionTypeConfig( - actionType, - attributes.actionTypeConfig - ); - attributes = this.moveEncryptedAttributesToSecrets(actionType.unencryptedAttributes, { - ...attributes, - actionTypeConfig: validatedActionTypeConfig, - }); - return await this.savedObjectsClient.update( - 'action', - id, - { - ...attributes, - actionTypeId, - }, - options - ); - } - - /** - * Set actionTypeConfigSecrets values on a given action - */ - private moveEncryptedAttributesToSecrets( - unencryptedAttributes: string[] = [], - action: Action | UpdateOptions['attributes'] - ) { - const actionTypeConfig: Record = {}; - const actionTypeConfigSecrets = { ...action.actionTypeConfig }; - for (const attributeKey of unencryptedAttributes) { - actionTypeConfig[attributeKey] = actionTypeConfigSecrets[attributeKey]; - delete actionTypeConfigSecrets[attributeKey]; - } - - return { - ...action, - // Important these overwrite attributes for encryption purposes - actionTypeConfig, - actionTypeConfigSecrets, - }; - } +function actionFromSavedObject(savedObject: SavedObject) { + return { + id: savedObject.id, + ...savedObject.attributes, + }; } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 5bfcb8d211ddb..7ebb1ff943750 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -12,11 +12,11 @@ import { ActionType, ActionTypeExecutorOptions } from '../types'; import { ActionTypeRegistry } from '../action_type_registry'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; -import { validateActionTypeConfig, validateActionTypeParams } from '../lib'; +import { validateParams, validateConfig, validateSecrets } from '../lib'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { registerBuiltInActionTypes } from './index'; import { sendEmail } from './lib/send_email'; -import { ActionParamsType, ActionTypeConfigType } from './email'; +import { ActionParamsType, ActionTypeConfigType, ActionTypeSecretsType } from './email'; const sendEmailMock = sendEmail as jest.Mock; @@ -43,6 +43,8 @@ beforeAll(() => { getServices, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }); registerBuiltInActionTypes(actionTypeRegistry); @@ -71,11 +73,9 @@ describe('config validation', () => { test('config validation succeeds when config is valid', () => { const config: Record = { service: 'gmail', - user: 'bob', - password: 'supersecret', from: 'bob@example.com', }; - expect(validateActionTypeConfig(actionType, config)).toEqual({ + expect(validateConfig(actionType, config)).toEqual({ ...config, host: null, port: null, @@ -85,7 +85,7 @@ describe('config validation', () => { delete config.service; config.host = 'elastic.co'; config.port = 8080; - expect(validateActionTypeConfig(actionType, config)).toEqual({ + expect(validateConfig(actionType, config)).toEqual({ ...config, service: null, secure: null, @@ -101,37 +101,58 @@ describe('config validation', () => { // empty object expect(() => { - validateActionTypeConfig(actionType, {}); + validateConfig(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [user]: expected value of type [string] but got [undefined]"` + `"error validating action type config: [from]: expected value of type [string] but got [undefined]"` ); // no service or host/port expect(() => { - validateActionTypeConfig(actionType, baseConfig); + validateConfig(actionType, baseConfig); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: either [service] or [host]/[port] is required"` + `"error validating action type config: [user]: definition for this key is missing"` ); // host but no port expect(() => { - validateActionTypeConfig(actionType, { ...baseConfig, host: 'elastic.co' }); + validateConfig(actionType, { ...baseConfig, host: 'elastic.co' }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [port] is required if [service] is not provided"` + `"error validating action type config: [user]: definition for this key is missing"` ); // port but no host expect(() => { - validateActionTypeConfig(actionType, { ...baseConfig, port: 8080 }); + validateConfig(actionType, { ...baseConfig, port: 8080 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [host] is required if [service] is not provided"` + `"error validating action type config: [user]: definition for this key is missing"` ); // invalid service expect(() => { - validateActionTypeConfig(actionType, { ...baseConfig, service: 'bad-nodemailer-service' }); + validateConfig(actionType, { + ...baseConfig, + service: 'bad-nodemailer-service', + }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [user]: definition for this key is missing"` + ); + }); +}); + +describe('secrets validation', () => { + test('secrets validation succeeds when secrets is valid', () => { + const secrets: Record = { + user: 'bob', + password: 'supersecret', + }; + expect(validateSecrets(actionType, secrets)).toEqual(secrets); + }); + + test('secrets validation fails when secrets is not valid', () => { + expect(() => { + validateSecrets(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [service] value \\"bad-nodemailer-service\\" is not valid"` + `"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"` ); }); }); @@ -143,7 +164,7 @@ describe('params validation', () => { subject: 'this is a test', message: 'this is the message', }; - expect(validateActionTypeParams(actionType, params)).toMatchInlineSnapshot(` + expect(validateParams(actionType, params)).toMatchInlineSnapshot(` Object { "bcc": Array [], "cc": Array [], @@ -159,9 +180,9 @@ Object { test('params validation fails when params is not valid', () => { // empty object expect(() => { - validateActionTypeParams(actionType, {}); + validateParams(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [subject]: expected value of type [string] but got [undefined]"` + `"error validating action params: [subject]: expected value of type [string] but got [undefined]"` ); }); }); @@ -173,9 +194,11 @@ describe('execute()', () => { host: 'a host', port: 42, secure: true, + from: 'bob@example.com', + }; + const secrets: ActionTypeSecretsType = { user: 'bob', password: 'supersecret', - from: 'bob@example.com', }; const params: ActionParamsType = { to: ['jim@example.com'], @@ -185,7 +208,8 @@ describe('execute()', () => { message: 'a message to you', }; - const executorOptions: ActionTypeExecutorOptions = { config, params, services }; + const id = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { id, config, params, secrets, services }; sendEmailMock.mockReset(); await actionType.executor(executorOptions); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index 7d8df0e35314b..a2d3aa58656aa 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -4,28 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf, Type } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerServices from 'nodemailer/lib/well-known/services.json'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; +import { nullableType } from './lib/nullable'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; const PORT_MAX = 256 * 256 - 1; -function nullableType(type: Type) { - return schema.oneOf([type, schema.literal(null)], { defaultValue: () => null }); -} - // config definition -const unencryptedConfigProperties = ['service', 'host', 'port', 'secure', 'from']; - export type ActionTypeConfigType = TypeOf; const ConfigSchema = schema.object( { - user: schema.string(), - password: schema.string(), service: nullableType(schema.string()), host: nullableType(schema.string()), port: nullableType(schema.number({ min: 1, max: PORT_MAX })), @@ -63,6 +56,15 @@ function validateConfig(configObject: any): string | void { } } +// secrets definition + +export type ActionTypeSecretsType = TypeOf; + +const SecretsSchema = schema.object({ + user: schema.string(), + password: schema.string(), +}); + // params definition export type ActionParamsType = TypeOf; @@ -97,9 +99,9 @@ function validateParams(paramsObject: any): string | void { export const actionType: ActionType = { id: '.email', name: 'email', - unencryptedAttributes: unencryptedConfigProperties, validate: { config: ConfigSchema, + secrets: SecretsSchema, params: ParamsSchema, }, executor, @@ -108,13 +110,15 @@ export const actionType: ActionType = { // action executor async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; const config = execOptions.config as ActionTypeConfigType; + const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; const services = execOptions.services; const transport: any = { - user: config.user, - password: config.password, + user: secrets.user, + password: secrets.password, }; if (config.service !== null) { @@ -146,7 +150,7 @@ async function executor(execOptions: ActionTypeExecutorOptions): Promise ({ + sendEmail: jest.fn(), +})); + +import { ActionType, ActionTypeExecutorOptions } from '../types'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; +import { taskManagerMock } from '../../../task_manager/task_manager.mock'; +import { validateConfig, validateParams } from '../lib'; +import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { registerBuiltInActionTypes } from './index'; +import { ActionParamsType, ActionTypeConfigType } from './es_index'; + +const ACTION_TYPE_ID = '.index'; +const NO_OP_FN = () => {}; + +const services = { + log: NO_OP_FN, + callCluster: jest.fn(), + savedObjectsClient: SavedObjectsClientMock.create(), +}; + +function getServices() { + return services; +} + +let actionTypeRegistry: ActionTypeRegistry; +let actionType: ActionType; + +const mockEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); + +beforeAll(() => { + actionTypeRegistry = new ActionTypeRegistry({ + getServices, + taskManager: taskManagerMock.create(), + encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }); + + registerBuiltInActionTypes(actionTypeRegistry); + + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('action is registered', () => { + test('gets registered with builtin actions', () => { + expect(actionTypeRegistry.has(ACTION_TYPE_ID)).toEqual(true); + }); +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('index'); + }); +}); + +describe('config validation', () => { + test('config validation succeeds when config is valid', () => { + const config: Record = {}; + + expect(validateConfig(actionType, config)).toEqual({ + ...config, + index: null, + }); + + config.index = 'testing-123'; + expect(validateConfig(actionType, config)).toEqual({ + ...config, + index: 'testing-123', + }); + }); + + test('config validation fails when config is not valid', () => { + const baseConfig: Record = { + indeX: 'bob', + }; + + expect(() => { + validateConfig(actionType, baseConfig); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [indeX]: definition for this key is missing"` + ); + + delete baseConfig.user; + baseConfig.index = 666; + + expect(() => { + validateConfig(actionType, baseConfig); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action type config: [index]: types that failed validation: +- [index.0]: expected value of type [string] but got [number] +- [index.1]: expected value to equal [null] but got [666]" +`); + }); +}); + +describe('params validation', () => { + test('params validation succeeds when params is valid', () => { + const params: Record = { + index: 'testing-123', + executionTimeField: 'field-used-for-time', + refresh: true, + documents: [{ rando: 'thing' }], + }; + expect(validateParams(actionType, params)).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "rando": "thing", + }, + ], + "executionTimeField": "field-used-for-time", + "index": "testing-123", + "refresh": true, + } + `); + + delete params.index; + delete params.refresh; + delete params.executionTimeField; + expect(validateParams(actionType, params)).toMatchInlineSnapshot(` + Object { + "documents": Array [ + Object { + "rando": "thing", + }, + ], + } + `); + }); + + test('params validation fails when params is not valid', () => { + expect(() => { + validateParams(actionType, { documents: [{}], jim: 'bob' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [jim]: definition for this key is missing"` + ); + + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [documents]: expected value of type [array] but got [undefined]"` + ); + + expect(() => { + validateParams(actionType, { index: 666 }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [index]: expected value of type [string] but got [number]"` + ); + + expect(() => { + validateParams(actionType, { executionTimeField: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [executionTimeField]: expected value of type [string] but got [boolean]"` + ); + + expect(() => { + validateParams(actionType, { refresh: 'true' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [refresh]: expected value of type [boolean] but got [string]"` + ); + + expect(() => { + validateParams(actionType, { documents: ['should be an object'] }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [documents.0]: expected value of type [object] but got [string]"` + ); + }); +}); + +describe('execute()', () => { + test('ensure parameters are as expected', async () => { + const secrets = {}; + let config: ActionTypeConfigType; + let params: ActionParamsType; + let executorOptions: ActionTypeExecutorOptions; + + // minimal params, index via param + config = { index: null }; + params = { + index: 'index-via-param', + documents: [{ jim: 'bob' }], + executionTimeField: undefined, + refresh: undefined, + }; + + const id = 'some-id'; + + executorOptions = { id, config, secrets, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jim": "bob", + }, + ], + "index": "index-via-param", + }, + ], + ] + `); + + // full params (except index), index via config + config = { index: 'index-via-config' }; + params = { + index: undefined, + documents: [{ jimbob: 'jr' }], + executionTimeField: 'field_to_use_for_time', + refresh: true, + }; + + executorOptions = { id, config, secrets, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + const calls = services.callCluster.mock.calls; + const timeValue = calls[0][1].body[1].field_to_use_for_time; + expect(timeValue).toBeInstanceOf(Date); + delete calls[0][1].body[1].field_to_use_for_time; + expect(calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jimbob": "jr", + }, + ], + "index": "index-via-config", + "refresh": true, + }, + ], + ] + `); + + // minimal params, index via config and param + config = { index: 'index-via-config' }; + params = { + index: 'index-via-param', + documents: [{ jim: 'bob' }], + executionTimeField: undefined, + refresh: undefined, + }; + + executorOptions = { id, config, secrets, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "jim": "bob", + }, + ], + "index": "index-via-config", + }, + ], + ] + `); + + // multiple documents + config = { index: null }; + params = { + index: 'index-via-param', + documents: [{ a: 1 }, { b: 2 }], + executionTimeField: undefined, + refresh: undefined, + }; + + executorOptions = { id, config, secrets, params, services }; + services.callCluster.mockClear(); + await actionType.executor(executorOptions); + + expect(services.callCluster.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "bulk", + Object { + "body": Array [ + Object { + "index": Object {}, + }, + Object { + "a": 1, + }, + Object { + "index": Object {}, + }, + Object { + "b": 2, + }, + ], + "index": "index-via-param", + }, + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts new file mode 100644 index 0000000000000..4d32633f541b3 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +import { nullableType } from './lib/nullable'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; + +// config definition + +export type ActionTypeConfigType = TypeOf; + +const ConfigSchema = schema.object({ + index: nullableType(schema.string()), +}); + +// params definition + +export type ActionParamsType = TypeOf; + +// see: https://www.elastic.co/guide/en/elastic-stack-overview/current/actions-index.html +// - timeout not added here, as this seems to be a generic thing we want to do +// eventually: https://github.com/elastic/kibana/projects/26#card-24087404 +const ParamsSchema = schema.object({ + index: schema.maybe(schema.string()), + executionTimeField: schema.maybe(schema.string()), + refresh: schema.maybe(schema.boolean()), + documents: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), +}); + +// action type definition + +export const actionType: ActionType = { + id: '.index', + name: 'index', + validate: { + config: ConfigSchema, + params: ParamsSchema, + }, + executor, +}; + +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; + const config = execOptions.config as ActionTypeConfigType; + const params = execOptions.params as ActionParamsType; + const services = execOptions.services; + + if (config.index == null && params.index == null) { + return { + status: 'error', + message: `index param needs to be set because not set in config for action ${id}`, + }; + } + + if (config.index != null && params.index != null) { + services.log( + ['debug', 'actions'], + `index passed in params overridden by index set in config for action ${id}` + ); + } + + const index = config.index || params.index; + + const bulkBody = []; + for (const document of params.documents) { + if (params.executionTimeField != null) { + document[params.executionTimeField] = new Date(); + } + + bulkBody.push({ index: {} }); + bulkBody.push(document); + } + + const bulkParams: any = { + index, + body: bulkBody, + }; + + if (params.refresh != null) { + bulkParams.refresh = params.refresh; + } + + let result; + try { + result = await services.callCluster('bulk', bulkParams); + } catch (err) { + return { + status: 'error', + message: `error in action ${id} indexing documents: ${err.message}`, + }; + } + + return { status: 'ok', data: result }; +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts index 1b59a6438120f..ea05979336184 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts @@ -9,9 +9,11 @@ import { ActionTypeRegistry } from '../action_type_registry'; import { actionType as serverLogActionType } from './server_log'; import { actionType as slackActionType } from './slack'; import { actionType as emailActionType } from './email'; +import { actionType as indexActionType } from './es_index'; export function registerBuiltInActionTypes(actionTypeRegistry: ActionTypeRegistry) { actionTypeRegistry.register(serverLogActionType); actionTypeRegistry.register(slackActionType); actionTypeRegistry.register(emailActionType); + actionTypeRegistry.register(indexActionType); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts new file mode 100644 index 0000000000000..e2ea1005ca181 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/nullable.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, Type } from '@kbn/config-schema'; + +// TODO: remove once this is merged: https://github.com/elastic/kibana/pull/41728 + +export function nullableType(type: Type) { + return schema.oneOf([type, schema.literal(null)], { defaultValue: () => null }); +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index 83f5cf06b7957..ef38c5b910627 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -8,7 +8,7 @@ import { ActionType, Services } from '../types'; import { ActionTypeRegistry } from '../action_type_registry'; import { taskManagerMock } from '../../../task_manager/task_manager.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/plugin.mock'; -import { validateActionTypeParams } from '../lib'; +import { validateParams } from '../lib'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { registerBuiltInActionTypes } from './index'; @@ -35,6 +35,8 @@ beforeAll(() => { getServices, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }); registerBuiltInActionTypes(actionTypeRegistry); }); @@ -57,7 +59,7 @@ describe('get()', () => { }); }); -describe('validateActionTypeParams()', () => { +describe('validateParams()', () => { let actionType: ActionType; beforeAll(() => { @@ -66,12 +68,12 @@ describe('validateActionTypeParams()', () => { }); test('should validate and pass when params is valid', () => { - expect(validateActionTypeParams(actionType, { message: 'a message' })).toEqual({ + expect(validateParams(actionType, { message: 'a message' })).toEqual({ message: 'a message', tags: ['info', 'alerting'], }); expect( - validateActionTypeParams(actionType, { + validateParams(actionType, { message: 'a message', tags: ['info', 'blorg'], }) @@ -83,27 +85,27 @@ describe('validateActionTypeParams()', () => { test('should validate and throw error when params is invalid', () => { expect(() => { - validateActionTypeParams(actionType, {}); + validateParams(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [message]: expected value of type [string] but got [undefined]"` + `"error validating action params: [message]: expected value of type [string] but got [undefined]"` ); expect(() => { - validateActionTypeParams(actionType, { message: 1 }); + validateParams(actionType, { message: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [message]: expected value of type [string] but got [number]"` + `"error validating action params: [message]: expected value of type [string] but got [number]"` ); expect(() => { - validateActionTypeParams(actionType, { message: 'x', tags: 2 }); + validateParams(actionType, { message: 'x', tags: 2 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [tags]: expected value of type [array] but got [number]"` + `"error validating action params: [tags]: expected value of type [array] but got [number]"` ); expect(() => { - validateActionTypeParams(actionType, { message: 'x', tags: [2] }); + validateParams(actionType, { message: 'x', tags: [2] }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [tags.0]: expected value of type [string] but got [number]"` + `"error validating action params: [tags.0]: expected value of type [string] but got [number]"` ); }); }); @@ -114,14 +116,17 @@ describe('execute()', () => { services.log = mockLog; const actionType = actionTypeRegistry.get(ACTION_TYPE_ID); + const id = 'some-id'; await actionType.executor({ + id, services: { log: mockLog, callCluster: async (path: string, opts: any) => {}, savedObjectsClient: SavedObjectsClientMock.create(), }, - config: {}, params: { message: 'message text here', tags: ['tag1', 'tag2'] }, + config: {}, + secrets: {}, }); expect(mockLog).toMatchInlineSnapshot(` [MockFunction] { diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 426dbb606744c..a11362ff63716 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -10,12 +10,6 @@ import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from const DEFAULT_TAGS = ['info', 'alerting']; -// config definition - -const unencryptedConfigProperties: string[] = []; - -const ConfigSchema = schema.object({}); - // params definition export type ActionParamsType = TypeOf; @@ -30,9 +24,7 @@ const ParamsSchema = schema.object({ export const actionType: ActionType = { id: '.server-log', name: 'server-log', - unencryptedAttributes: unencryptedConfigProperties, validate: { - config: ConfigSchema, params: ParamsSchema, }, executor, @@ -41,6 +33,7 @@ export const actionType: ActionType = { // action executor async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const id = execOptions.id; const params = execOptions.params as ActionParamsType; const services = execOptions.services; @@ -49,7 +42,7 @@ async function executor(execOptions: ActionTypeExecutorOptions): Promise { getServices, taskManager: taskManagerMock.create(), encryptedSavedObjectsPlugin: mockEncryptedSavedObjectsPlugin, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }); actionTypeRegistry.register(getActionType({ executor: mockSlackExecutor })); actionType = actionTypeRegistry.get(ACTION_TYPE_ID); @@ -76,44 +77,44 @@ describe('action is registered', () => { describe('validateParams()', () => { test('should validate and pass when params is valid', () => { - expect(validateActionTypeParams(actionType, { message: 'a message' })).toEqual({ + expect(validateParams(actionType, { message: 'a message' })).toEqual({ message: 'a message', }); }); test('should validate and throw error when params is invalid', () => { expect(() => { - validateActionTypeParams(actionType, {}); + validateParams(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [message]: expected value of type [string] but got [undefined]"` + `"error validating action params: [message]: expected value of type [string] but got [undefined]"` ); expect(() => { - validateActionTypeParams(actionType, { message: 1 }); + validateParams(actionType, { message: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [message]: expected value of type [string] but got [number]"` + `"error validating action params: [message]: expected value of type [string] but got [number]"` ); }); }); -describe('validateActionTypeConfig()', () => { +describe('validateActionTypeSecrets()', () => { test('should validate and pass when config is valid', () => { - validateActionTypeConfig(actionType, { + validateSecrets(actionType, { webhookUrl: 'https://example.com', }); }); test('should validate and throw error when config is invalid', () => { expect(() => { - validateActionTypeConfig(actionType, {}); + validateSecrets(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [webhookUrl]: expected value of type [string] but got [undefined]"` + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [undefined]"` ); expect(() => { - validateActionTypeConfig(actionType, { webhookUrl: 1 }); + validateSecrets(actionType, { webhookUrl: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [webhookUrl]: expected value of type [string] but got [number]"` + `"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [number]"` ); }); }); @@ -121,8 +122,10 @@ describe('validateActionTypeConfig()', () => { describe('execute()', () => { test('calls the mock executor with success', async () => { const response = await actionType.executor({ + id: 'some-id', services, - config: { webhookUrl: 'http://example.com' }, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, }); expect(response).toMatchInlineSnapshot(` @@ -135,8 +138,10 @@ Object { test('calls the mock executor with failure', async () => { await expect( actionType.executor({ + id: 'some-id', services, - config: { webhookUrl: 'http://example.com' }, + config: {}, + secrets: { webhookUrl: 'http://example.com' }, params: { message: 'failure: this invocation should fail' }, }) ).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index e077c06305bda..a5bd254a20837 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -14,13 +14,11 @@ import { ExecutorType, } from '../types'; -// config definition +// secrets definition -const unencryptedConfigProperties: string[] = []; +export type ActionTypeSecretsType = TypeOf; -export type ActionTypeConfigType = TypeOf; - -const ConfigSchema = schema.object({ +const SecretsSchema = schema.object({ webhookUrl: schema.string(), }); @@ -41,9 +39,8 @@ export function getActionType({ executor }: { executor?: ExecutorType } = {}): A return { id: '.slack', name: 'slack', - unencryptedAttributes: unencryptedConfigProperties, validate: { - config: ConfigSchema, + secrets: SecretsSchema, params: ParamsSchema, }, executor, @@ -58,11 +55,12 @@ export const actionType = getActionType(); async function slackExecutor( execOptions: ActionTypeExecutorOptions ): Promise { - const config = execOptions.config as ActionTypeConfigType; + const id = execOptions.id; + const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; let result: IncomingWebhookResult; - const { webhookUrl } = config; + const { webhookUrl } = secrets; const { message } = params; try { @@ -70,14 +68,14 @@ async function slackExecutor( result = await webhook.send(message); } catch (err) { if (err.original == null || err.original.response == null) { - return errorResult(err.message); + return errorResult(id, err.message); } const { status, statusText, headers } = err.original.response; // special handling for 5xx if (status >= 500) { - return retryResult(err.message); + return retryResult(id, err.message); } // special handling for rate limiting @@ -86,20 +84,20 @@ async function slackExecutor( if (retryAfterString != null) { const retryAfter = parseInt(retryAfterString, 10); if (!isNaN(retryAfter)) { - return retryResultSeconds(err.message, retryAfter); + return retryResultSeconds(id, err.message, retryAfter); } } } - return errorResult(`${err.message} - ${statusText}`); + return errorResult(id, `${err.message} - ${statusText}`); } if (result == null) { - return errorResult(`unexpected null response from slack`); + return errorResult(id, `unexpected null response from slack`); } if (result.text !== 'ok') { - return errorResult(`unexpected text response from slack (expecting 'ok')`); + return errorResult(id, `unexpected text response from slack (expecting 'ok')`); } return successResult(result); @@ -109,28 +107,32 @@ function successResult(data: any): ActionTypeExecutorResult { return { status: 'ok', data }; } -function errorResult(message: string): ActionTypeExecutorResult { +function errorResult(id: string, message: string): ActionTypeExecutorResult { return { status: 'error', - message: `an error occurred posting a slack message: ${message}`, + message: `an error occurred in action ${id} posting a slack message: ${message}`, }; } -function retryResult(message: string): ActionTypeExecutorResult { +function retryResult(id: string, message: string): ActionTypeExecutorResult { return { status: 'error', - message: `an error occurred posting a slack message, retrying later`, + message: `an error occurred in action ${id} posting a slack message, retrying later`, retry: true, }; } -function retryResultSeconds(message: string, retryAfter: number = 60): ActionTypeExecutorResult { +function retryResultSeconds( + id: string, + message: string, + retryAfter: number = 60 +): ActionTypeExecutorResult { const retryEpoch = Date.now() + retryAfter * 1000; const retry = new Date(retryEpoch); const retryString = retry.toISOString(); return { status: 'error', - message: `an error occurred posting a slack message, retry at ${retryString}: ${message}`, + message: `an error occurred in action ${id} posting a slack message, retry at ${retryString}: ${message}`, retry, }; } diff --git a/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts b/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts index 8def316c12df5..1f286d371e633 100644 --- a/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/create_fire_function.test.ts @@ -10,6 +10,7 @@ import { SavedObjectsClientMock } from '../../../../../src/core/server/mocks'; const mockTaskManager = taskManagerMock.create(); const savedObjectsClient = SavedObjectsClientMock.create(); +const spaceIdToNamespace = jest.fn(); beforeEach(() => jest.resetAllMocks()); @@ -18,6 +19,7 @@ describe('fire()', () => { const fireFn = createFireFunction({ taskManager: mockTaskManager, internalSavedObjectsRepository: savedObjectsClient, + spaceIdToNamespace, }); savedObjectsClient.get.mockResolvedValueOnce({ id: '123', @@ -27,41 +29,41 @@ describe('fire()', () => { }, references: [], }); + spaceIdToNamespace.mockReturnValueOnce('namespace1'); await fireFn({ id: '123', params: { baz: false }, - namespace: 'abc', - basePath: '/s/default', + spaceId: 'default', }); expect(mockTaskManager.schedule).toHaveBeenCalledTimes(1); expect(mockTaskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "params": Object { - "actionTypeParams": Object { - "baz": false, - }, - "basePath": "/s/default", - "id": "123", - "namespace": "abc", - }, - "scope": Array [ - "actions", - ], - "state": Object {}, - "taskType": "actions:mock-action", - }, -] -`); + Array [ + Object { + "params": Object { + "id": "123", + "params": Object { + "baz": false, + }, + "spaceId": "default", + }, + "scope": Array [ + "actions", + ], + "state": Object {}, + "taskType": "actions:mock-action", + }, + ] + `); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "123", - Object { - "namespace": "abc", - }, -] -`); + Array [ + "action", + "123", + Object { + "namespace": "namespace1", + }, + ] + `); + expect(spaceIdToNamespace).toHaveBeenCalledWith('default'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/create_fire_function.ts b/x-pack/legacy/plugins/actions/server/create_fire_function.ts index 7ff31f4d455f9..0bf11cbf5dd40 100644 --- a/x-pack/legacy/plugins/actions/server/create_fire_function.ts +++ b/x-pack/legacy/plugins/actions/server/create_fire_function.ts @@ -6,32 +6,34 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { TaskManager } from '../../task_manager'; +import { SpacesPlugin } from '../../spaces'; interface CreateFireFunctionOptions { taskManager: TaskManager; internalSavedObjectsRepository: SavedObjectsClientContract; + spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; } -interface FireOptions { +export interface FireOptions { id: string; params: Record; - namespace?: string; - basePath: string; + spaceId: string; } export function createFireFunction({ taskManager, internalSavedObjectsRepository, + spaceIdToNamespace, }: CreateFireFunctionOptions) { - return async function fire({ id, params, namespace, basePath }: FireOptions) { + return async function fire({ id, params, spaceId }: FireOptions) { + const namespace = spaceIdToNamespace(spaceId); const actionSavedObject = await internalSavedObjectsRepository.get('action', id, { namespace }); await taskManager.schedule({ taskType: `actions:${actionSavedObject.attributes.actionTypeId}`, params: { id, - basePath, - namespace, - actionTypeParams: params, + spaceId, + params, }, state: {}, scope: ['actions'], diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts index 765653f0fec8c..27cff53bb97d1 100644 --- a/x-pack/legacy/plugins/actions/server/init.ts +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -18,19 +18,31 @@ import { listActionTypesRoute, fireRoute, } from './routes'; - import { registerBuiltInActionTypes } from './builtin_action_types'; +import { SpacesPlugin } from '../../spaces'; +import { createOptionalPlugin } from '../../../server/lib/optional_plugin'; export function init(server: Legacy.Server) { + const config = server.config(); + const spaces = createOptionalPlugin( + config, + 'xpack.spaces', + server.plugins, + 'spaces' + ); + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( callWithInternalUser ); // Encrypted attributes + // - `secrets` properties will be encrypted + // - `config` will be included in AAD + // - everything else excluded from AAD server.plugins.encrypted_saved_objects!.registerType({ type: 'action', - attributesToEncrypt: new Set(['actionTypeConfigSecrets']), + attributesToEncrypt: new Set(['secrets']), attributesToExcludeFromAAD: new Set(['description']), }); @@ -55,6 +67,14 @@ export function init(server: Legacy.Server) { getServices, taskManager: taskManager!, encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects!, + getBasePath(...args) { + return spaces.isEnabled + ? spaces.getBasePath(...args) + : server.config().get('server.basePath'); + }, + spaceIdToNamespace(...args) { + return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; + }, }); registerBuiltInActionTypes(actionTypeRegistry); @@ -75,6 +95,9 @@ export function init(server: Legacy.Server) { const fireFn = createFireFunction({ taskManager: taskManager!, internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, + spaceIdToNamespace(...args) { + return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; + }, }); // Expose functions to server diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts index 73a5566474a2a..0f14df6e8c678 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts @@ -39,7 +39,6 @@ test('successfully executes', async () => { const actionType = { id: 'test', name: 'Test', - unencryptedAttributes: [], executor: jest.fn(), }; const actionSavedObject = { @@ -47,10 +46,10 @@ test('successfully executes', async () => { type: 'action', attributes: { actionTypeId: 'test', - actionTypeConfig: { + config: { bar: true, }, - actionTypeConfigSecrets: { + secrets: { baz: true, }, }, @@ -70,20 +69,22 @@ test('successfully executes', async () => { expect(actionTypeRegistry.get).toHaveBeenCalledWith('test'); expect(actionType.executor).toHaveBeenCalledWith({ + id: '1', services: expect.anything(), config: { bar: true, + }, + secrets: { baz: true, }, params: { foo: true }, }); }); -test('provides empty config when actionTypeConfig and / or actionTypeConfigSecrets is empty', async () => { +test('provides empty config when config and / or secrets is empty', async () => { const actionType = { id: 'test', name: 'Test', - unencryptedAttributes: [], executor: jest.fn(), }; const actionSavedObject = { @@ -101,14 +102,13 @@ test('provides empty config when actionTypeConfig and / or actionTypeConfigSecre expect(actionType.executor).toHaveBeenCalledTimes(1); const executorCall = actionType.executor.mock.calls[0][0]; - expect(executorCall.config).toMatchInlineSnapshot(`Object {}`); + expect(executorCall.config).toMatchInlineSnapshot(`undefined`); }); test('throws an error when config is invalid', async () => { const actionType = { id: 'test', name: 'Test', - unencryptedAttributes: [], validate: { config: schema.object({ param1: schema.string(), @@ -128,16 +128,18 @@ test('throws an error when config is invalid', async () => { encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); - await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` - ); + const result = await execute(executeParams); + expect(result).toEqual({ + status: 'error', + retry: false, + message: `error validating action type config: [param1]: expected value of type [string] but got [undefined]`, + }); }); test('throws an error when params is invalid', async () => { const actionType = { id: 'test', name: 'Test', - unencryptedAttributes: [], validate: { params: schema.object({ param1: schema.string(), @@ -157,7 +159,10 @@ test('throws an error when params is invalid', async () => { encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); - await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]"` - ); + const result = await execute(executeParams); + expect(result).toEqual({ + status: 'error', + retry: false, + message: `error validating action params: [param1]: expected value of type [string] but got [undefined]`, + }); }); diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.ts b/x-pack/legacy/plugins/actions/server/lib/execute.ts index e75425e0cae6c..95c4f17dd922e 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.ts @@ -5,13 +5,12 @@ */ import { Services, ActionTypeRegistryContract, ActionTypeExecutorResult } from '../types'; -import { validateActionTypeConfig } from './validate_action_type_config'; -import { validateActionTypeParams } from './validate_action_type_params'; +import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; interface ExecuteOptions { actionId: string; - namespace: string; + namespace?: string; services: Services; params: Record; encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; @@ -31,12 +30,18 @@ export async function execute({ namespace, }); const actionType = actionTypeRegistry.get(action.attributes.actionTypeId); - const mergedActionTypeConfig = { - ...(action.attributes.actionTypeConfig || {}), - ...(action.attributes.actionTypeConfigSecrets || {}), - }; - const validatedConfig = validateActionTypeConfig(actionType, mergedActionTypeConfig); - const validatedParams = validateActionTypeParams(actionType, params); + + let validatedParams; + let validatedConfig; + let validatedSecrets; + + try { + validatedParams = validateParams(actionType, params); + validatedConfig = validateConfig(actionType, action.attributes.config); + validatedSecrets = validateSecrets(actionType, action.attributes.secrets); + } catch (err) { + return { status: 'error', message: err.message, retry: false }; + } let result: ActionTypeExecutorResult | null = null; @@ -45,9 +50,11 @@ export async function execute({ try { result = await actionType.executor({ + id: actionId, services, - config: validatedConfig, params: validatedParams, + config: validatedConfig, + secrets: validatedSecrets, }); } catch (err) { services.log( diff --git a/x-pack/legacy/plugins/actions/server/lib/executor_error.ts b/x-pack/legacy/plugins/actions/server/lib/executor_error.ts new file mode 100644 index 0000000000000..5e0dee3f3cc2d --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/executor_error.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class ExecutorError extends Error { + readonly data?: any; + readonly retry?: null | boolean | Date; + constructor(message?: string, data?: any, retry?: null | boolean | Date) { + super(message); + this.data = data; + this.retry = retry; + } +} diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts index 489325748b4df..4f732afc3ec7f 100644 --- a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.test.ts @@ -12,14 +12,15 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; +import { ExecutorError } from './executor_error'; +const spaceIdToNamespace = jest.fn(); const actionTypeRegistry = actionTypeRegistryMock.create(); const mockedEncryptedSavedObjectsPlugin = encryptedSavedObjectsMock.create(); const actionType = { id: '1', name: '1', - unencryptedAttributes: [], executor: jest.fn(), }; @@ -34,7 +35,9 @@ const getCreateTaskRunnerFunctionParams = { }; }, actionTypeRegistry, + spaceIdToNamespace, encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin, + getBasePath: jest.fn().mockReturnValue(undefined), }; const taskInstanceMock = { @@ -42,8 +45,8 @@ const taskInstanceMock = { state: {}, params: { id: '2', - actionTypeParams: { baz: true }, - namespace: 'test', + params: { baz: true }, + spaceId: 'test', }, taskType: 'actions:1', }; @@ -54,11 +57,16 @@ test('executes the task by calling the executor with proper parameters', async ( const { execute: mockExecute } = jest.requireMock('./execute'); const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ status: 'ok' }); + spaceIdToNamespace.mockReturnValueOnce('namespace-test'); + const runnerResult = await runner.run(); expect(runnerResult).toBeUndefined(); + expect(spaceIdToNamespace).toHaveBeenCalledWith('test'); expect(mockExecute).toHaveBeenCalledWith({ - namespace: 'test', + namespace: 'namespace-test', actionId: '2', actionTypeRegistry, encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin, @@ -66,3 +74,25 @@ test('executes the task by calling the executor with proper parameters', async ( params: { baz: true }, }); }); + +test('throws an error with suggested retry logic when return status is error', async () => { + const { execute: mockExecute } = jest.requireMock('./execute'); + const createTaskRunner = getCreateTaskRunnerFunction(getCreateTaskRunnerFunctionParams); + const runner = createTaskRunner({ taskInstance: taskInstanceMock }); + + mockExecute.mockResolvedValueOnce({ + status: 'error', + message: 'Error message', + data: { foo: true }, + retry: false, + }); + + try { + await runner.run(); + throw new Error('Should have thrown'); + } catch (e) { + expect(e instanceof ExecutorError).toEqual(true); + expect(e.data).toEqual({ foo: true }); + expect(e.retry).toEqual(false); + } +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts index 04b7345781e3c..f9398f25ff7ff 100644 --- a/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts +++ b/x-pack/legacy/plugins/actions/server/lib/get_create_task_runner_function.ts @@ -5,14 +5,18 @@ */ import { execute } from './execute'; +import { ExecutorError } from './executor_error'; import { ActionTypeRegistryContract, GetServicesFunction } from '../types'; import { TaskInstance } from '../../../task_manager'; import { EncryptedSavedObjectsPlugin } from '../../../encrypted_saved_objects'; +import { SpacesPlugin } from '../../../spaces'; interface CreateTaskRunnerFunctionOptions { getServices: GetServicesFunction; actionTypeRegistry: ActionTypeRegistryContract; encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; + spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + getBasePath: SpacesPlugin['getBasePath']; } interface TaskRunnerOptions { @@ -23,19 +27,32 @@ export function getCreateTaskRunnerFunction({ getServices, actionTypeRegistry, encryptedSavedObjectsPlugin, + spaceIdToNamespace, + getBasePath, }: CreateTaskRunnerFunctionOptions) { return ({ taskInstance }: TaskRunnerOptions) => { return { run: async () => { - const { namespace, id, actionTypeParams } = taskInstance.params; - await execute({ + const { spaceId, id, params } = taskInstance.params; + const namespace = spaceIdToNamespace(spaceId); + const basePath = getBasePath(spaceId); + const executorResult = await execute({ namespace, actionTypeRegistry, encryptedSavedObjectsPlugin, actionId: id, - services: getServices(taskInstance.params.basePath), - params: actionTypeParams, + services: getServices(basePath), + params, }); + if (executorResult.status === 'error') { + // Task manager error handler only kicks in when an error thrown (at this time) + // So what we have to do is throw when the return status is `error`. + throw new ExecutorError( + executorResult.message, + executorResult.data, + executorResult.retry + ); + } }, }; }; diff --git a/x-pack/legacy/plugins/actions/server/lib/index.ts b/x-pack/legacy/plugins/actions/server/lib/index.ts index 23305f4eba90e..c1cca1f68addb 100644 --- a/x-pack/legacy/plugins/actions/server/lib/index.ts +++ b/x-pack/legacy/plugins/actions/server/lib/index.ts @@ -6,5 +6,5 @@ export { execute } from './execute'; export { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; -export { validateActionTypeConfig } from './validate_action_type_config'; -export { validateActionTypeParams } from './validate_action_type_params'; +export { ExecutorError } from './executor_error'; +export { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts deleted file mode 100644 index b348384d6e529..0000000000000 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { validateActionTypeConfig } from './validate_action_type_config'; -import { ExecutorType } from '../types'; - -const executor: ExecutorType = async options => { - return { status: 'ok' }; -}; - -test('should return passed in config when validation not defined', () => { - const result = validateActionTypeConfig( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - executor, - }, - { - foo: true, - } - ); - expect(result).toEqual({ foo: true }); -}); - -test('should validate and apply defaults when actionTypeConfig is valid', () => { - const result = validateActionTypeConfig( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - validate: { - config: schema.object({ - param1: schema.string(), - param2: schema.string({ defaultValue: 'default-value' }), - }), - }, - executor, - }, - { param1: 'value' } - ); - expect(result).toEqual({ - param1: 'value', - param2: 'default-value', - }); -}); - -test('should validate and throw error when actionTypeConfig is invalid', () => { - expect(() => - validateActionTypeConfig( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - validate: { - config: schema.object({ - obj: schema.object({ - param1: schema.string(), - }), - }), - }, - executor, - }, - { - obj: {}, - } - ) - ).toThrowErrorMatchingInlineSnapshot( - `"The actionTypeConfig is invalid: [obj.param1]: expected value of type [string] but got [undefined]"` - ); -}); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts deleted file mode 100644 index ce8bc7dba2a9a..0000000000000 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { ActionType } from '../types'; - -export function validateActionTypeConfig>( - actionType: ActionType, - config: T -): T { - const validator = actionType.validate && actionType.validate.config; - if (!validator) { - return config; - } - - try { - return validator.validate(config); - } catch (err) { - throw Boom.badRequest(`The actionTypeConfig is invalid: ${err.message}`); - } -} diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts deleted file mode 100644 index 58de8c01d89b0..0000000000000 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { validateActionTypeParams } from './validate_action_type_params'; -import { ExecutorType } from '../types'; - -const executor: ExecutorType = async options => { - return { status: 'ok' }; -}; - -test('should return passed in params when validation not defined', () => { - const result = validateActionTypeParams( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - executor, - }, - { - foo: true, - } - ); - expect(result).toEqual({ - foo: true, - }); -}); - -test('should validate and apply defaults when params is valid', () => { - const result = validateActionTypeParams( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - validate: { - params: schema.object({ - param1: schema.string(), - param2: schema.string({ defaultValue: 'default-value' }), - }), - }, - executor, - }, - { param1: 'value' } - ); - expect(result).toEqual({ - param1: 'value', - param2: 'default-value', - }); -}); - -test('should validate and throw error when params is invalid', () => { - expect(() => - validateActionTypeParams( - { - id: 'my-action-type', - name: 'My action type', - unencryptedAttributes: [], - validate: { - params: schema.object({ - param1: schema.string(), - }), - }, - executor, - }, - {} - ) - ).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]"` - ); -}); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts deleted file mode 100644 index 4d18c27d79faf..0000000000000 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { ActionType } from '../types'; - -export function validateActionTypeParams>( - actionType: ActionType, - params: T -): T { - const validator = actionType.validate && actionType.validate.params; - if (!validator) { - return params; - } - try { - return validator.validate(params); - } catch (err) { - throw Boom.badRequest(`The actionParams is invalid: ${err.message}`); - } -} diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.test.ts new file mode 100644 index 0000000000000..4cb28728fb421 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; +import { ActionType, ExecutorType } from '../types'; + +const executor: ExecutorType = async options => { + return { status: 'ok' }; +}; + +test('should validate when there are no validators', () => { + const actionType: ActionType = { id: 'foo', name: 'bar', executor }; + const testValue = { any: ['old', 'thing'] }; + + const result = validateConfig(actionType, testValue); + expect(result).toEqual(testValue); +}); + +test('should validate when there are no individual validators', () => { + const actionType: ActionType = { id: 'foo', name: 'bar', executor, validate: {} }; + + let result; + const testValue = { any: ['old', 'thing'] }; + + result = validateParams(actionType, testValue); + expect(result).toEqual(testValue); + + result = validateConfig(actionType, testValue); + expect(result).toEqual(testValue); + + result = validateSecrets(actionType, testValue); + expect(result).toEqual(testValue); +}); + +test('should validate when validators return incoming value', () => { + const selfValidator = { validate: (value: any) => value }; + const actionType: ActionType = { + id: 'foo', + name: 'bar', + executor, + validate: { + params: selfValidator, + config: selfValidator, + secrets: selfValidator, + }, + }; + + let result; + const testValue = { any: ['old', 'thing'] }; + + result = validateParams(actionType, testValue); + expect(result).toEqual(testValue); + + result = validateConfig(actionType, testValue); + expect(result).toEqual(testValue); + + result = validateSecrets(actionType, testValue); + expect(result).toEqual(testValue); +}); + +test('should validate when validators return different values', () => { + const returnedValue: any = { something: { shaped: 'differently' } }; + const selfValidator = { validate: (value: any) => returnedValue }; + const actionType: ActionType = { + id: 'foo', + name: 'bar', + executor, + validate: { + params: selfValidator, + config: selfValidator, + secrets: selfValidator, + }, + }; + + let result; + const testValue = { any: ['old', 'thing'] }; + + result = validateParams(actionType, testValue); + expect(result).toEqual(returnedValue); + + result = validateConfig(actionType, testValue); + expect(result).toEqual(returnedValue); + + result = validateSecrets(actionType, testValue); + expect(result).toEqual(returnedValue); +}); + +test('should throw with expected error when validators fail', () => { + const erroringValidator = { + validate: (value: any) => { + throw new Error('test error'); + }, + }; + const actionType: ActionType = { + id: 'foo', + name: 'bar', + executor, + validate: { + params: erroringValidator, + config: erroringValidator, + secrets: erroringValidator, + }, + }; + + const testValue = { any: ['old', 'thing'] }; + + expect(() => validateParams(actionType, testValue)).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: test error"` + ); + + expect(() => validateConfig(actionType, testValue)).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: test error"` + ); + + expect(() => validateSecrets(actionType, testValue)).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: test error"` + ); +}); + +test('should work with @kbn/config-schema', () => { + const testSchema = schema.object({ foo: schema.string() }); + const actionType: ActionType = { + id: 'foo', + name: 'bar', + executor, + validate: { + params: testSchema, + config: testSchema, + secrets: testSchema, + }, + }; + + const result = validateParams(actionType, { foo: 'bar' }); + expect(result).toEqual({ foo: 'bar' }); + + expect(() => validateParams(actionType, { bar: 2 })).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [foo]: expected value of type [string] but got [undefined]"` + ); +}); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.ts b/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.ts new file mode 100644 index 0000000000000..45ef867834ea8 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/lib/validate_with_schema.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { ActionType } from '../types'; + +export function validateParams(actionType: ActionType, value: any) { + return validateWithSchema(actionType, 'params', value); +} + +export function validateConfig(actionType: ActionType, value: any) { + return validateWithSchema(actionType, 'config', value); +} + +export function validateSecrets(actionType: ActionType, value: any) { + return validateWithSchema(actionType, 'secrets', value); +} + +type ValidKeys = 'params' | 'config' | 'secrets'; + +function validateWithSchema( + actionType: ActionType, + key: ValidKeys, + value: any +): Record { + if (actionType.validate == null) return value; + + let name; + try { + switch (key) { + case 'params': + name = 'action params'; + if (actionType.validate.params == null) return value; + return actionType.validate.params.validate(value); + + case 'config': + name = 'action type config'; + if (actionType.validate.config == null) return value; + return actionType.validate.config.validate(value); + + case 'secrets': + name = 'action type secrets'; + if (actionType.validate.secrets == null) return value; + return actionType.validate.secrets.validate(value); + } + } catch (err) { + throw Boom.badRequest(`error validating ${name}: ${err.message}`); + } + + // should never happen, but left here for future-proofing + throw new Error(`invalid actionType validate key: ${key}`); +} diff --git a/x-pack/legacy/plugins/actions/server/routes/create.test.ts b/x-pack/legacy/plugins/actions/server/routes/create.test.ts index f1e93c46b80ef..ec57149256501 100644 --- a/x-pack/legacy/plugins/actions/server/routes/create.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/create.test.ts @@ -19,42 +19,19 @@ it('creates an action with proper parameters', async () => { method: 'POST', url: '/api/action', payload: { - attributes: { - description: 'My description', - actionTypeId: 'abc', - actionTypeConfig: { foo: true }, - }, - migrationVersion: { - abc: '1.2.3', - }, - references: [ - { - name: 'ref_0', - type: 'bcd', - id: '234', - }, - ], + description: 'My description', + actionTypeId: 'abc', + config: { foo: true }, + secrets: {}, }, }; const createResult = { id: '1', type: 'action', - attributes: { - description: 'My description', - actionTypeId: 'abc', - actionTypeConfig: { foo: true }, - actionTypeConfigSecrets: {}, - }, - migrationVersion: { - abc: '1.2.3', - }, - references: [ - { - name: 'ref_0', - type: 'bcd', - id: '234', - }, - ], + description: 'My description', + actionTypeId: 'abc', + config: { foo: true }, + secrets: {}, }; actionsClient.create.mockResolvedValueOnce(createResult); @@ -64,28 +41,17 @@ it('creates an action with proper parameters', async () => { expect(response).toEqual({ id: '1' }); expect(actionsClient.create).toHaveBeenCalledTimes(1); expect(actionsClient.create.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object { - "actionTypeConfig": Object { - "foo": true, - }, - "actionTypeId": "abc", - "description": "My description", - }, - "options": Object { - "migrationVersion": Object { - "abc": "1.2.3", - }, - "references": Array [ - Object { - "id": "234", - "name": "ref_0", - "type": "bcd", + Array [ + Object { + "action": Object { + "actionTypeId": "abc", + "config": Object { + "foo": true, + }, + "description": "My description", + "secrets": Object {}, }, - ], - }, - }, -] -`); + }, + ] + `); }); diff --git a/x-pack/legacy/plugins/actions/server/routes/create.ts b/x-pack/legacy/plugins/actions/server/routes/create.ts index 0cba5816879f9..6e4034d3258a7 100644 --- a/x-pack/legacy/plugins/actions/server/routes/create.ts +++ b/x-pack/legacy/plugins/actions/server/routes/create.ts @@ -6,7 +6,7 @@ import Joi from 'joi'; import Hapi from 'hapi'; -import { WithoutQueryAndParams, SavedObjectReference } from '../types'; +import { WithoutQueryAndParams } from '../types'; interface CreateRequest extends WithoutQueryAndParams { query: { @@ -16,13 +16,10 @@ interface CreateRequest extends WithoutQueryAndParams { id?: string; }; payload: { - attributes: { - description: string; - actionTypeId: string; - actionTypeConfig: Record; - }; - migrationVersion?: Record; - references: SavedObjectReference[]; + description: string; + actionTypeId: string; + config: Record; + secrets: Record; }; } @@ -35,37 +32,21 @@ export function createRoute(server: Hapi.Server) { options: { abortEarly: false, }, - payload: Joi.object().keys({ - attributes: Joi.object() - .keys({ - description: Joi.string().required(), - actionTypeId: Joi.string().required(), - actionTypeConfig: Joi.object().required(), - }) - .required(), - migrationVersion: Joi.object().optional(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), - }), + payload: Joi.object() + .keys({ + description: Joi.string().required(), + actionTypeId: Joi.string().required(), + config: Joi.object().default({}), + secrets: Joi.object().default({}), + }) + .required(), }, }, async handler(request: CreateRequest) { const actionsClient = request.getActionsClient!(); - const createdAction = await actionsClient.create({ - attributes: request.payload.attributes, - options: { - migrationVersion: request.payload.migrationVersion, - references: request.payload.references, - }, - }); + const action = request.payload; + const createdAction = await actionsClient.create({ action }); return { id: createdAction.id }; }, diff --git a/x-pack/legacy/plugins/actions/server/routes/delete.test.ts b/x-pack/legacy/plugins/actions/server/routes/delete.test.ts index 37c7f3b2c89a9..a655b804f397f 100644 --- a/x-pack/legacy/plugins/actions/server/routes/delete.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/delete.test.ts @@ -22,9 +22,8 @@ it('deletes an action with proper parameters', async () => { actionsClient.delete.mockResolvedValueOnce({ success: true }); const { payload, statusCode } = await server.inject(request); - expect(statusCode).toBe(200); - const response = JSON.parse(payload); - expect(response).toEqual({ success: true }); + expect(statusCode).toBe(204); + expect(payload).toEqual(''); expect(actionsClient.delete).toHaveBeenCalledTimes(1); expect(actionsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/legacy/plugins/actions/server/routes/delete.ts b/x-pack/legacy/plugins/actions/server/routes/delete.ts index eed8b7a10cded..6a47b4395d9cd 100644 --- a/x-pack/legacy/plugins/actions/server/routes/delete.ts +++ b/x-pack/legacy/plugins/actions/server/routes/delete.ts @@ -26,10 +26,11 @@ export function deleteRoute(server: Hapi.Server) { .required(), }, }, - async handler(request: DeleteRequest) { + async handler(request: DeleteRequest, h: Hapi.ResponseToolkit) { const { id } = request.params; const actionsClient = request.getActionsClient!(); - return await actionsClient.delete({ id }); + await actionsClient.delete({ id }); + return h.response().code(204); }, }); } diff --git a/x-pack/legacy/plugins/actions/server/routes/find.test.ts b/x-pack/legacy/plugins/actions/server/routes/find.test.ts index afb8f583e541a..f2073a906e7e4 100644 --- a/x-pack/legacy/plugins/actions/server/routes/find.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/find.test.ts @@ -29,9 +29,9 @@ it('sends proper arguments to action find function', async () => { }; const expectedResult = { total: 0, - per_page: 10, + perPage: 10, page: 1, - saved_objects: [], + data: [], }; actionsClient.find.mockResolvedValueOnce(expectedResult); diff --git a/x-pack/legacy/plugins/actions/server/routes/get.test.ts b/x-pack/legacy/plugins/actions/server/routes/get.test.ts index bec51eff1e803..8d1949774445d 100644 --- a/x-pack/legacy/plugins/actions/server/routes/get.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/get.test.ts @@ -6,6 +6,7 @@ import { createMockServer } from './_mock_server'; import { getRoute } from './get'; +import { ActionResult } from '../types'; const { server, actionsClient } = createMockServer(); getRoute(server); @@ -19,11 +20,11 @@ it('calls get with proper parameters', async () => { method: 'GET', url: '/api/action/1', }; - const expectedResult = { + const expectedResult: ActionResult = { id: '1', - type: 'action', - attributes: {}, - references: [], + actionTypeId: 'my-action-type-id', + config: {}, + description: 'my action type description', }; actionsClient.get.mockResolvedValueOnce(expectedResult); diff --git a/x-pack/legacy/plugins/actions/server/routes/update.test.ts b/x-pack/legacy/plugins/actions/server/routes/update.test.ts index 492542d0190b3..1d7699b8a163e 100644 --- a/x-pack/legacy/plugins/actions/server/routes/update.test.ts +++ b/x-pack/legacy/plugins/actions/server/routes/update.test.ts @@ -6,6 +6,7 @@ import { createMockServer } from './_mock_server'; import { updateRoute } from './update'; +import { ActionResult } from '../types'; const { server, actionsClient } = createMockServer(); updateRoute(server); @@ -19,35 +20,15 @@ it('calls the update function with proper parameters', async () => { method: 'PUT', url: '/api/action/1', payload: { - attributes: { - description: 'My description', - actionTypeConfig: { foo: true }, - }, - version: '2', - references: [ - { - name: 'ref_0', - type: 'bcd', - id: '234', - }, - ], + description: 'My description', + config: { foo: true }, }, }; - const updateResult = { + const updateResult: ActionResult = { id: '1', - type: 'action', - attributes: { - description: 'My description', - actionTypeConfig: { foo: true }, - }, - version: '2', - references: [ - { - name: 'ref_0', - type: 'bcd', - id: '234', - }, - ], + actionTypeId: 'my-action-type-id', + description: 'My description', + config: { foo: true }, }; actionsClient.update.mockResolvedValueOnce(updateResult); @@ -57,26 +38,17 @@ it('calls the update function with proper parameters', async () => { expect(response).toEqual({ id: '1' }); expect(actionsClient.update).toHaveBeenCalledTimes(1); expect(actionsClient.update.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "attributes": Object { - "actionTypeConfig": Object { - "foo": true, - }, - "description": "My description", - }, - "id": "1", - "options": Object { - "references": Array [ - Object { - "id": "234", - "name": "ref_0", - "type": "bcd", + Array [ + Object { + "action": Object { + "config": Object { + "foo": true, + }, + "description": "My description", + "secrets": Object {}, }, - ], - "version": "2", - }, - }, -] -`); + "id": "1", + }, + ] + `); }); diff --git a/x-pack/legacy/plugins/actions/server/routes/update.ts b/x-pack/legacy/plugins/actions/server/routes/update.ts index 68931c21225aa..45933109a8bdb 100644 --- a/x-pack/legacy/plugins/actions/server/routes/update.ts +++ b/x-pack/legacy/plugins/actions/server/routes/update.ts @@ -7,17 +7,11 @@ import Joi from 'joi'; import Hapi from 'hapi'; -import { SavedObjectReference } from '../types'; - interface UpdateRequest extends Hapi.Request { payload: { - attributes: { - description: string; - actionTypeId: string; - actionTypeConfig: Record; - }; - version?: string; - references: SavedObjectReference[]; + description: string; + config: Record; + secrets: Record; }; } @@ -37,36 +31,18 @@ export function updateRoute(server: Hapi.Server) { .required(), payload: Joi.object() .keys({ - attributes: Joi.object() - .keys({ - description: Joi.string().required(), - actionTypeConfig: Joi.object().required(), - }) - .required(), - version: Joi.string(), - references: Joi.array() - .items( - Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().required(), - id: Joi.string().required(), - }) - ) - .default([]), + description: Joi.string().required(), + config: Joi.object().default({}), + secrets: Joi.object().default({}), }) .required(), }, }, async handler(request: UpdateRequest) { const { id } = request.params; - const { attributes, version, references } = request.payload; - const options = { version, references }; + const { description, config, secrets } = request.payload; const actionsClient = request.getActionsClient!(); - await actionsClient.update({ - id, - attributes, - options, - }); + await actionsClient.update({ id, action: { description, config, secrets } }); return { id }; }, }); diff --git a/x-pack/legacy/plugins/actions/server/types.ts b/x-pack/legacy/plugins/actions/server/types.ts index 3999370d7acc7..a3134b4cd4d1f 100644 --- a/x-pack/legacy/plugins/actions/server/types.ts +++ b/x-pack/legacy/plugins/actions/server/types.ts @@ -6,17 +6,12 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { ActionTypeRegistry } from './action_type_registry'; +import { FireOptions } from './create_fire_function'; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (basePath: string, overwrites?: Partial) => Services; export type ActionTypeRegistryContract = PublicMethodsOf; -export interface SavedObjectReference { - name: string; - type: string; - id: string; -} - export interface Services { callCluster(path: string, opts: any): Promise; savedObjectsClient: SavedObjectsClientContract; @@ -26,16 +21,25 @@ export interface Services { export interface ActionsPlugin { registerType: ActionTypeRegistry['register']; listTypes: ActionTypeRegistry['list']; - fire(options: { id: string; params: Record; basePath: string }): Promise; + fire(options: FireOptions): Promise; } // the parameters passed to an action type executor function export interface ActionTypeExecutorOptions { + id: string; services: Services; config: Record; + secrets: Record; params: Record; } +export interface ActionResult { + id: string; + actionTypeId: string; + description: string; + config: Record; +} + // the result returned from an action type executor function export interface ActionTypeExecutorResult { status: 'ok' | 'error'; @@ -49,13 +53,18 @@ export type ExecutorType = ( options: ActionTypeExecutorOptions ) => Promise; +interface ValidatorType { + validate(value: any): any; +} + export interface ActionType { id: string; name: string; - unencryptedAttributes: string[]; + maxAttempts?: number; validate?: { - params?: { validate: (object: any) => any }; - config?: { validate: (object: any) => any }; + params?: ValidatorType; + config?: ValidatorType; + secrets?: ValidatorType; }; executor: ExecutorType; } diff --git a/x-pack/legacy/plugins/alerting/README.md b/x-pack/legacy/plugins/alerting/README.md index 091d5a9da0115..18034807b7ac7 100644 --- a/x-pack/legacy/plugins/alerting/README.md +++ b/x-pack/legacy/plugins/alerting/README.md @@ -52,8 +52,8 @@ This is the primary function for an alert type. Whenever the alert needs to exec |services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| |services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

**NOTE**: This currently only works when security is disabled. A future PR will add support for enabled security using Elasticsearch API tokens.| |services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| -|scheduledRunAt|The date and time the alert type execution was scheduled to be called.| -|previousScheduledRunAt|The previous date and time the alert type was scheduled to be called.| +|startedAt|The date and time the alert type started execution.| +|previousStartedAt|The previous date and time the alert type started a successful execution.| |params|Parameters for the execution. This is where the parameters you require will be passed in. (example threshold). Use alert type validation to ensure values are set before execution.| |state|State returned from previous execution. This is the alert level state. What the executor returns will be serialized and provided here at the next execution.| @@ -74,8 +74,8 @@ server.plugins.alerting.registerType({ }), }, async executor({ - scheduledRunAt, - previousScheduledRunAt, + startedAt, + previousStartedAt, services, params, state, @@ -131,8 +131,8 @@ server.plugins.alerting.registerType({ }), }, async executor({ - scheduledRunAt, - previousScheduledRunAt, + startedAt, + previousStartedAt, services, params, state, diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 442023951714e..f14da9e9ed83d 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -25,6 +25,8 @@ const alertTypeRegistryParams = { taskManager, fireAction: jest.fn(), internalSavedObjectsRepository: SavedObjectsClientMock.create(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }; beforeEach(() => jest.resetAllMocks()); @@ -46,7 +48,7 @@ describe('has()', () => { }); }); -describe('registry()', () => { +describe('register()', () => { test('registers the executor with the task manager', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const { getCreateTaskRunnerFunction } = require('./lib/get_create_task_runner_function'); @@ -79,6 +81,8 @@ Object { } `); expect(firstCall.internalSavedObjectsRepository).toBeTruthy(); + expect(firstCall.getBasePath).toBeTruthy(); + expect(firstCall.spaceIdToNamespace).toBeTruthy(); expect(firstCall.fireAction).toMatchInlineSnapshot(`[MockFunction]`); }); diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index 61a358473ef46..7e763858fd20e 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -11,12 +11,15 @@ import { AlertType, Services } from './types'; import { TaskManager } from '../../task_manager'; import { getCreateTaskRunnerFunction } from './lib'; import { ActionsPlugin } from '../../actions'; +import { SpacesPlugin } from '../../spaces'; interface ConstructorOptions { getServices: (basePath: string) => Services; taskManager: TaskManager; fireAction: ActionsPlugin['fire']; internalSavedObjectsRepository: SavedObjectsClientContract; + spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + getBasePath: SpacesPlugin['getBasePath']; } export class AlertTypeRegistry { @@ -25,17 +28,23 @@ export class AlertTypeRegistry { private readonly fireAction: ActionsPlugin['fire']; private readonly alertTypes: Map = new Map(); private readonly internalSavedObjectsRepository: SavedObjectsClientContract; + private readonly spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + private readonly getBasePath: SpacesPlugin['getBasePath']; constructor({ internalSavedObjectsRepository, fireAction, taskManager, getServices, + spaceIdToNamespace, + getBasePath, }: ConstructorOptions) { this.taskManager = taskManager; this.fireAction = fireAction; this.internalSavedObjectsRepository = internalSavedObjectsRepository; this.getServices = getServices; + this.getBasePath = getBasePath; + this.spaceIdToNamespace = spaceIdToNamespace; } public has(id: string) { @@ -63,6 +72,8 @@ export class AlertTypeRegistry { getServices: this.getServices, fireAction: this.fireAction, internalSavedObjectsRepository: this.internalSavedObjectsRepository, + getBasePath: this.getBasePath, + spaceIdToNamespace: this.spaceIdToNamespace, }), }, }); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index 2041ab7cfcefe..c6eae114e725a 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -19,7 +19,7 @@ const alertsClientParams = { taskManager, alertTypeRegistry, savedObjectsClient, - basePath: '/s/default', + spaceId: 'default', }; beforeEach(() => jest.resetAllMocks()); @@ -94,12 +94,12 @@ describe('create()', () => { taskManager.schedule.mockResolvedValueOnce({ id: 'task-123', taskType: 'alerting:123', - sequenceNumber: 1, - primaryTerm: 1, scheduledAt: new Date(), attempts: 1, status: 'idle', runAt: new Date(), + startedAt: null, + retryAt: null, state: {}, params: {}, }); @@ -119,99 +119,98 @@ describe('create()', () => { }); const result = await alertsClient.create({ data }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "id": "1", - "interval": "10s", - "scheduledTaskId": "task-123", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(savedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "actionRef": "action_0", - "group": "default", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "enabled": true, - "interval": "10s", - } - `); + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "enabled": true, + "interval": "10s", + } + `); expect(savedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); expect(taskManager.schedule).toHaveBeenCalledTimes(1); expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "params": Object { - "alertId": "1", - "basePath": "/s/default", - }, - "scope": Array [ - "alerting", - ], - "state": Object { - "alertInstances": Object {}, - "alertTypeState": Object {}, - "previousScheduledRunAt": null, - "scheduledRunAt": 2019-02-12T21:01:22.479Z, - }, - "taskType": "alerting:123", - }, - ] - `); + Array [ + Object { + "params": Object { + "alertId": "1", + "spaceId": "default", + }, + "scope": Array [ + "alerting", + ], + "state": Object { + "alertInstances": Object {}, + "alertTypeState": Object {}, + "previousStartedAt": null, + }, + "taskType": "alerting:123", + }, + ] + `); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "scheduledTaskId": "task-123", - } - `); + Object { + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); }); test('creates a disabled alert', async () => { @@ -252,25 +251,25 @@ describe('create()', () => { }); const result = await alertsClient.create({ data }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "enabled": false, - "id": "1", - "interval": 10000, - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "enabled": false, + "id": "1", + "interval": 10000, + } + `); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(taskManager.schedule).toHaveBeenCalledTimes(0); }); @@ -351,11 +350,11 @@ describe('create()', () => { ); expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); }); test('returns task manager error if cleanup fails, logs to console', async () => { @@ -400,14 +399,14 @@ describe('create()', () => { ); expect(alertsClientParams.log).toHaveBeenCalledTimes(1); expect(alertsClientParams.log.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ - "alerting", - "error", - ], - "Failed to cleanup alert \\"1\\" after scheduling task failed. Error: Saved object delete error", - ] - `); + Array [ + Array [ + "alerting", + "error", + ], + "Failed to cleanup alert \\"1\\" after scheduling task failed. Error: Saved object delete error", + ] + `); }); test('throws an error if alert type not registerd', async () => { @@ -437,8 +436,6 @@ describe('enable()', () => { }); taskManager.schedule.mockResolvedValueOnce({ id: 'task-123', - sequenceNumber: 1, - primaryTerm: 1, scheduledAt: new Date(), attempts: 0, status: 'idle', @@ -446,6 +443,8 @@ describe('enable()', () => { state: {}, params: {}, taskType: '', + startedAt: null, + retryAt: null, }); await alertsClient.enable({ id: '1' }); @@ -464,13 +463,12 @@ describe('enable()', () => { taskType: `alerting:2`, params: { alertId: '1', - basePath: '/s/default', + spaceId: 'default', }, state: { alertInstances: {}, alertTypeState: {}, - previousScheduledRunAt: null, - scheduledRunAt: mockedDate, + previousStartedAt: null, }, scope: ['alerting'], }); @@ -577,31 +575,31 @@ describe('get()', () => { }); const result = await alertsClient.get({ id: '1' }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, - "id": "1", - "interval": "10s", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + } + `); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); }); test(`throws an error when references aren't found`, async () => { @@ -672,34 +670,39 @@ describe('find()', () => { }); const result = await alertsClient.find(); expect(result).toMatchInlineSnapshot(` - Array [ + Object { + "data": Array [ + Object { + "actions": Array [ Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeId": "123", - "alertTypeParams": Object { - "bar": true, - }, + "group": "default", "id": "1", - "interval": "10s", + "params": Object { + "foo": true, + }, }, - ] - `); + ], + "alertTypeId": "123", + "alertTypeParams": Object { + "bar": true, + }, + "id": "1", + "interval": "10s", + }, + ], + "page": 1, + "perPage": 10, + "total": 1, + } + `); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "type": "alert", - }, - ] - `); + Array [ + Object { + "type": "alert", + }, + ] + `); }); }); @@ -737,32 +740,21 @@ describe('delete()', () => { savedObjectsClient.delete.mockResolvedValueOnce({ success: true, }); - taskManager.remove.mockResolvedValueOnce({ - index: '.task_manager', - id: 'task-123', - sequenceNumber: 1, - primaryTerm: 1, - result: '', - }); const result = await alertsClient.delete({ id: '1' }); - expect(result).toMatchInlineSnapshot(` - Object { - "success": true, - } - `); + expect(result).toEqual({ success: true }); expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "alert", - "1", - ] - `); + Array [ + "alert", + "1", + ] + `); expect(taskManager.remove).toHaveBeenCalledTimes(1); expect(taskManager.remove.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "task-123", - ] - `); + Array [ + "task-123", + ] + `); }); }); @@ -834,58 +826,58 @@ describe('update()', () => { }, }); expect(result).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "group": "default", - "id": "1", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeParams": Object { - "bar": true, - }, - "enabled": true, - "id": "1", - "interval": "10s", - "scheduledTaskId": "task-123", - } - `); + Object { + "actions": Array [ + Object { + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "enabled": true, + "id": "1", + "interval": "10s", + "scheduledTaskId": "task-123", + } + `); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "actions": Array [ - Object { - "actionRef": "action_0", - "group": "default", - "params": Object { - "foo": true, - }, - }, - ], - "alertTypeParams": Object { - "bar": true, - }, - "interval": "10s", - } - `); + Object { + "actions": Array [ + Object { + "actionRef": "action_0", + "group": "default", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeParams": Object { + "bar": true, + }, + "interval": "10s", + } + `); expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - "version": "123", - } - `); + Object { + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + "version": "123", + } + `); }); it('should validate alertTypeParams', async () => { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index c4f87270d5b7e..cbd1ba3eab53e 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -8,14 +8,14 @@ import { omit } from 'lodash'; import { SavedObjectsClientContract, SavedObjectReference } from 'src/core/server'; import { Alert, RawAlert, AlertTypeRegistry, AlertAction, Log } from './types'; import { TaskManager } from '../../task_manager'; -import { validateAlertTypeParams, parseDuration } from './lib'; +import { validateAlertTypeParams } from './lib'; interface ConstructorOptions { log: Log; taskManager: TaskManager; savedObjectsClient: SavedObjectsClientContract; alertTypeRegistry: AlertTypeRegistry; - basePath: string; + spaceId?: string; } interface FindOptions { @@ -34,6 +34,13 @@ interface FindOptions { }; } +interface FindResult { + page: number; + perPage: number; + total: number; + data: object[]; +} + interface CreateOptions { data: Alert; options?: { @@ -53,7 +60,7 @@ interface UpdateOptions { export class AlertsClient { private readonly log: Log; - private readonly basePath: string; + private readonly spaceId?: string; private readonly taskManager: TaskManager; private readonly savedObjectsClient: SavedObjectsClientContract; private readonly alertTypeRegistry: AlertTypeRegistry; @@ -63,10 +70,10 @@ export class AlertsClient { savedObjectsClient, taskManager, log, - basePath, + spaceId, }: ConstructorOptions) { this.log = log; - this.basePath = basePath; + this.spaceId = spaceId; this.taskManager = taskManager; this.alertTypeRegistry = alertTypeRegistry; this.savedObjectsClient = savedObjectsClient; @@ -90,8 +97,7 @@ export class AlertsClient { scheduledTask = await this.scheduleAlert( createdAlert.id, rawAlert.alertTypeId, - rawAlert.interval, - this.basePath + rawAlert.interval ); } catch (e) { // Cleanup data, something went wrong scheduling the task @@ -124,14 +130,22 @@ export class AlertsClient { return this.getAlertFromRaw(result.id, result.attributes, result.references); } - public async find({ options = {} }: FindOptions = {}) { + public async find({ options = {} }: FindOptions = {}): Promise { const results = await this.savedObjectsClient.find({ ...options, type: 'alert', }); - return results.saved_objects.map(result => + + const data = results.saved_objects.map(result => this.getAlertFromRaw(result.id, result.attributes, result.references) ); + + return { + page: results.page, + perPage: results.per_page, + total: results.total, + data, + }; } public async delete({ id }: { id: string }) { @@ -174,8 +188,7 @@ export class AlertsClient { const scheduledTask = await this.scheduleAlert( id, existingObject.attributes.alertTypeId, - existingObject.attributes.interval, - this.basePath + existingObject.attributes.interval ); await this.savedObjectsClient.update( 'alert', @@ -205,18 +218,15 @@ export class AlertsClient { } } - private async scheduleAlert(id: string, alertTypeId: string, interval: string, basePath: string) { + private async scheduleAlert(id: string, alertTypeId: string, interval: string) { return await this.taskManager.schedule({ taskType: `alerting:${alertTypeId}`, params: { alertId: id, - basePath, + spaceId: this.spaceId, }, state: { - // This is here because we can't rely on the task manager's internal runAt. - // It changes it for timeout, etc when a task is running. - scheduledRunAt: new Date(Date.now() + parseDuration(interval)), - previousScheduledRunAt: null, + previousStartedAt: null, alertTypeState: {}, alertInstances: {}, }, diff --git a/x-pack/legacy/plugins/alerting/server/init.ts b/x-pack/legacy/plugins/alerting/server/init.ts index ec1255cc5ac20..bf7b4b8009b97 100644 --- a/x-pack/legacy/plugins/alerting/server/init.ts +++ b/x-pack/legacy/plugins/alerting/server/init.ts @@ -18,8 +18,18 @@ import { import { AlertingPlugin, Services } from './types'; import { AlertTypeRegistry } from './alert_type_registry'; import { AlertsClient } from './alerts_client'; +import { SpacesPlugin } from '../../spaces'; +import { createOptionalPlugin } from '../../../server/lib/optional_plugin'; export function init(server: Legacy.Server) { + const config = server.config(); + const spaces = createOptionalPlugin( + config, + 'xpack.spaces', + server.plugins, + 'spaces' + ); + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); const savedObjectsRepositoryWithInternalUser = server.savedObjects.getSavedObjectsRepository( callWithInternalUser @@ -43,6 +53,14 @@ export function init(server: Legacy.Server) { taskManager: taskManager!, fireAction: server.plugins.actions!.fire, internalSavedObjectsRepository: savedObjectsRepositoryWithInternalUser, + getBasePath(...args) { + return spaces.isEnabled + ? spaces.getBasePath(...args) + : server.config().get('server.basePath'); + }, + spaceIdToNamespace(...args) { + return spaces.isEnabled ? spaces.spaceIdToNamespace(...args) : undefined; + }, }); // Register routes @@ -64,7 +82,7 @@ export function init(server: Legacy.Server) { savedObjectsClient, alertTypeRegistry, taskManager: taskManager!, - basePath: request.getBasePath(), + spaceId: spaces.isEnabled ? spaces.getSpaceId(request) : undefined, }); return alertsClient; }); diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts index ba8e35dbd7ba9..bbafd60cdbc64 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.test.ts @@ -7,8 +7,10 @@ import { createFireHandler } from './create_fire_handler'; const createFireHandlerParams = { - basePath: '/s/default', fireAction: jest.fn(), + spaceId: 'default', + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), alertSavedObject: { id: '1', type: 'alert', @@ -47,18 +49,18 @@ test('calls fireAction per selected action', async () => { await fireHandler('default', {}, {}); expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "basePath": "/s/default", - "id": "1", - "params": Object { - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - }, -] -`); + Array [ + Object { + "id": "1", + "params": Object { + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "spaceId": "default", + }, + ] + `); }); test('limits fireAction per action group', async () => { @@ -72,18 +74,18 @@ test('context attribute gets parameterized', async () => { await fireHandler('default', { value: 'context-val' }, {}); expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "basePath": "/s/default", - "id": "1", - "params": Object { - "contextVal": "My context-val goes here", - "foo": true, - "stateVal": "My goes here", - }, - }, -] -`); + Array [ + Object { + "id": "1", + "params": Object { + "contextVal": "My context-val goes here", + "foo": true, + "stateVal": "My goes here", + }, + "spaceId": "default", + }, + ] + `); }); test('state attribute gets parameterized', async () => { @@ -91,23 +93,23 @@ test('state attribute gets parameterized', async () => { await fireHandler('default', {}, { value: 'state-val' }); expect(createFireHandlerParams.fireAction).toHaveBeenCalledTimes(1); expect(createFireHandlerParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "basePath": "/s/default", - "id": "1", - "params": Object { - "contextVal": "My goes here", - "foo": true, - "stateVal": "My state-val goes here", - }, - }, -] -`); + Array [ + Object { + "id": "1", + "params": Object { + "contextVal": "My goes here", + "foo": true, + "stateVal": "My state-val goes here", + }, + "spaceId": "default", + }, + ] + `); }); test('throws error if reference not found', async () => { const params = { - basePath: '/s/default', + spaceId: 'default', fireAction: jest.fn(), alertSavedObject: { id: '1', diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts index 3a271365105c7..f51b374298a07 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/create_fire_handler.ts @@ -12,13 +12,13 @@ import { transformActionParams } from './transform_action_params'; interface CreateFireHandlerOptions { fireAction: ActionsPlugin['fire']; alertSavedObject: SavedObject; - basePath: string; + spaceId: string; } export function createFireHandler({ fireAction, alertSavedObject, - basePath, + spaceId, }: CreateFireHandlerOptions) { return async (actionGroup: string, context: Context, state: State) => { const alertActions: RawAlertAction[] = alertSavedObject.attributes.actions; @@ -43,7 +43,7 @@ export function createFireHandler({ await fireAction({ id: action.id, params: action.params, - basePath, + spaceId, }); } }; diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts index c4a992773e2e5..95969d3d9a17c 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.test.ts @@ -4,18 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ +import sinon from 'sinon'; import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions } from '../types'; +import { ConcreteTaskInstance } from '../../../task_manager'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; import { getCreateTaskRunnerFunction } from './get_create_task_runner_function'; -const mockedNow = new Date('2019-06-03T18:55:25.982Z'); -const mockedLastRunAt = new Date('2019-06-03T18:55:20.982Z'); -(global as any).Date = class Date extends global.Date { - static now() { - return mockedNow.getTime(); - } -}; +let fakeTimer: sinon.SinonFakeTimers; +let mockedTaskInstance: ConcreteTaskInstance; + +beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + mockedTaskInstance = { + id: '', + attempts: 0, + status: 'running', + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + startedAt: new Date(Date.now() - 5 * 60 * 1000), + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + }; +}); + +afterAll(() => fakeTimer.restore()); const savedObjectsClient = SavedObjectsClientMock.create(); @@ -34,17 +54,8 @@ const getCreateTaskRunnerFunctionParams = { }, fireAction: jest.fn(), internalSavedObjectsRepository: savedObjectsClient, -}; - -const mockedTaskInstance = { - runAt: mockedLastRunAt, - state: { - scheduledRunAt: mockedLastRunAt, - }, - taskType: 'alerting:test', - params: { - alertId: '1', - }, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), }; const mockedAlertTypeSavedObject = { @@ -84,24 +95,23 @@ test('successfully executes the task', async () => { const runner = createTaskRunner({ taskInstance: mockedTaskInstance }); const runnerResult = await runner.run(); expect(runnerResult).toMatchInlineSnapshot(` - Object { - "runAt": 2019-06-03T18:55:30.982Z, - "state": Object { - "alertInstances": Object {}, - "alertTypeState": undefined, - "previousScheduledRunAt": 2019-06-03T18:55:20.982Z, - "scheduledRunAt": 2019-06-03T18:55:30.982Z, - }, - } - `); + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); expect(getCreateTaskRunnerFunctionParams.alertType.executor).toHaveBeenCalledTimes(1); const call = getCreateTaskRunnerFunctionParams.alertType.executor.mock.calls[0][0]; expect(call.params).toMatchInlineSnapshot(` - Object { - "bar": true, - } - `); - expect(call.scheduledRunAt).toMatchInlineSnapshot(`2019-06-03T18:55:20.982Z`); + Object { + "bar": true, + } + `); + expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); expect(call.services.alertInstanceFactory).toBeTruthy(); expect(call.services.callCluster).toBeTruthy(); @@ -122,11 +132,11 @@ test('fireAction is called per alert instance that fired', async () => { expect(getCreateTaskRunnerFunctionParams.fireAction.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { - "basePath": undefined, "id": "1", "params": Object { "foo": true, }, + "spaceId": undefined, }, ] `); @@ -154,17 +164,17 @@ test('persists alertInstances passed in from state, only if they fire', async () }); const runnerResult = await runner.run(); expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` - Object { - "1": Object { - "meta": Object { - "lastFired": 1559588125982, - }, - "state": Object { - "bar": false, - }, - }, - } - `); + Object { + "1": Object { + "meta": Object { + "lastFired": 0, + }, + "state": Object { + "bar": false, + }, + }, + } + `); }); test('validates params before executing the alert type', async () => { diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts index c21ddbe7ed986..2ac3023a2079c 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/get_create_task_runner_function.ts @@ -7,22 +7,25 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { ActionsPlugin } from '../../../actions'; import { AlertType, Services, AlertServices } from '../types'; -import { TaskInstance } from '../../../task_manager'; +import { ConcreteTaskInstance } from '../../../task_manager'; import { createFireHandler } from './create_fire_handler'; import { createAlertInstanceFactory } from './create_alert_instance_factory'; import { AlertInstance } from './alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from './validate_alert_type_params'; +import { SpacesPlugin } from '../../../spaces'; interface CreateTaskRunnerFunctionOptions { getServices: (basePath: string) => Services; alertType: AlertType; fireAction: ActionsPlugin['fire']; internalSavedObjectsRepository: SavedObjectsClientContract; + spaceIdToNamespace: SpacesPlugin['spaceIdToNamespace']; + getBasePath: SpacesPlugin['getBasePath']; } interface TaskRunnerOptions { - taskInstance: TaskInstance; + taskInstance: ConcreteTaskInstance; } export function getCreateTaskRunnerFunction({ @@ -30,13 +33,17 @@ export function getCreateTaskRunnerFunction({ alertType, fireAction, internalSavedObjectsRepository, + spaceIdToNamespace, + getBasePath, }: CreateTaskRunnerFunctionOptions) { return ({ taskInstance }: TaskRunnerOptions) => { return { run: async () => { + const namespace = spaceIdToNamespace(taskInstance.params.spaceId); const alertSavedObject = await internalSavedObjectsRepository.get( 'alert', - taskInstance.params.alertId + taskInstance.params.alertId, + { namespace } ); // Validate @@ -48,7 +55,7 @@ export function getCreateTaskRunnerFunction({ const fireHandler = createFireHandler({ alertSavedObject, fireAction, - basePath: taskInstance.params.basePath, + spaceId: taskInstance.params.spaceId, }); const alertInstances: Record = {}; const alertInstancesData = taskInstance.state.alertInstances || {}; @@ -66,8 +73,8 @@ export function getCreateTaskRunnerFunction({ services: alertTypeServices, params: validatedAlertTypeParams, state: taskInstance.state.alertTypeState || {}, - scheduledRunAt: taskInstance.state.scheduledRunAt, - previousScheduledRunAt: taskInstance.state.previousScheduledRunAt, + startedAt: taskInstance.startedAt!, + previousStartedAt: taskInstance.state.previousStartedAt, }); await Promise.all( @@ -88,7 +95,7 @@ export function getCreateTaskRunnerFunction({ ); const nextRunAt = getNextRunAt( - new Date(taskInstance.state.scheduledRunAt), + new Date(taskInstance.startedAt!), alertSavedObject.attributes.interval ); @@ -96,9 +103,7 @@ export function getCreateTaskRunnerFunction({ state: { alertTypeState, alertInstances, - // We store nextRunAt ourselves since task manager changes runAt when executing a task - scheduledRunAt: nextRunAt, - previousScheduledRunAt: taskInstance.state.scheduledRunAt, + previousStartedAt: taskInstance.startedAt!, }, runAt: nextRunAt, }; diff --git a/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts b/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts index 80cddf4b56ff5..7e3948640034e 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/delete.test.ts @@ -20,9 +20,8 @@ test('deletes an alert with proper parameters', async () => { alertsClient.delete.mockResolvedValueOnce({}); const { payload, statusCode } = await server.inject(request); - expect(statusCode).toBe(200); - const response = JSON.parse(payload); - expect(response).toEqual({}); + expect(statusCode).toBe(204); + expect(payload).toEqual(''); expect(alertsClient.delete).toHaveBeenCalledTimes(1); expect(alertsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/legacy/plugins/alerting/server/routes/delete.ts b/x-pack/legacy/plugins/alerting/server/routes/delete.ts index 9352bd3726832..7dbb336b3cc1d 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/delete.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/delete.ts @@ -26,10 +26,11 @@ export function deleteAlertRoute(server: Hapi.Server) { .required(), }, }, - async handler(request: DeleteRequest) { + async handler(request: DeleteRequest, h: Hapi.ResponseToolkit) { const { id } = request.params; const alertsClient = request.getAlertsClient!(); - return await alertsClient.delete({ id }); + await alertsClient.delete({ id }); + return h.response().code(204); }, }); } diff --git a/x-pack/legacy/plugins/alerting/server/routes/find.test.ts b/x-pack/legacy/plugins/alerting/server/routes/find.test.ts index 8bc41a51dc7a1..73ab2ddd594fa 100644 --- a/x-pack/legacy/plugins/alerting/server/routes/find.test.ts +++ b/x-pack/legacy/plugins/alerting/server/routes/find.test.ts @@ -26,11 +26,18 @@ test('sends proper arguments to alert find function', async () => { 'fields=description', }; - alertsClient.find.mockResolvedValueOnce([]); + const expectedResult = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + + alertsClient.find.mockResolvedValueOnce(expectedResult); const { payload, statusCode } = await server.inject(request); expect(statusCode).toBe(200); const response = JSON.parse(payload); - expect(response).toEqual([]); + expect(response).toEqual(expectedResult); expect(alertsClient.find).toHaveBeenCalledTimes(1); expect(alertsClient.find.mock.calls[0]).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 50fbba498226f..b1e268431e40e 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -29,8 +29,8 @@ export interface AlertServices extends Services { } export interface AlertExecutorOptions { - scheduledRunAt: Date; - previousScheduledRunAt?: Date; + startedAt: Date; + previousStartedAt?: Date; services: AlertServices; params: Record; state: State; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/StickyErrorProperties.tsx b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/StickyErrorProperties.tsx index 567ad19e13c0f..783eb4e85fb80 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/StickyErrorProperties.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/StickyErrorProperties.tsx @@ -18,16 +18,15 @@ import { import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { APMError } from '../../../../../typings/es_schemas/ui/APMError'; import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction'; -import { APMLink } from '../../../shared/Links/APMLink'; -import { legacyEncodeURIComponent } from '../../../shared/Links/url_helpers'; import { StickyProperties } from '../../../shared/StickyProperties'; +import { TransactionLink } from '../../../shared/Links/apm/TransactionLink'; interface Props { error: APMError; transaction: Transaction | undefined; } -function TransactionLink({ +function TransactionLinkWrapper({ transaction }: { transaction: Transaction | undefined; @@ -41,22 +40,10 @@ function TransactionLink({ return {transaction.transaction.id}; } - const path = `/${ - transaction.service.name - }/transactions/${legacyEncodeURIComponent( - transaction.transaction.type - )}/${legacyEncodeURIComponent(transaction.transaction.name)}`; - return ( - + {transaction.transaction.id} - + ); } @@ -105,7 +92,7 @@ export function StickyErrorProperties({ error, transaction }: Props) { defaultMessage: 'Transaction sample ID' } ), - val: , + val: , width: '25%' }, { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/StickyErrorProperties.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/StickyErrorProperties.test.tsx.snap index 257eb47c95649..74d0880d40fb6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/StickyErrorProperties.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ErrorGroupDetails/DetailView/__snapshots__/StickyErrorProperties.test.tsx.snap @@ -32,7 +32,7 @@ exports[`StickyErrorProperties should render StickyProperties 1`] = ` Object { "fieldName": "transaction.id", "label": "Transaction sample ID", - "val": List should render with data 1`] = ` >
a0ce2 @@ -670,9 +670,8 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` onMouseOver={[Function]} > List should render with data 1`] = ` onMouseOver={[Function]} >
List should render with data 1`] = ` > f3ac9 @@ -779,9 +777,8 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` onMouseOver={[Function]} > List should render with data 1`] = ` onMouseOver={[Function]} >
List should render with data 1`] = ` > e9086 @@ -888,9 +884,8 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` onMouseOver={[Function]} > List should render with data 1`] = ` onMouseOver={[Function]} >
List should render with data 1`] = ` > 8673d @@ -997,9 +991,8 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` onMouseOver={[Function]} > List should render with data 1`] = ` onMouseOver={[Function]} >
= props => { width: px(unit * 6), render: (groupId: string) => { return ( - + {groupId.slice(0, 5) || NOT_AVAILABLE_LABEL} ); @@ -85,7 +85,9 @@ const ErrorGroupList: React.FC = props => { id="error-message-tooltip" content={message || NOT_AVAILABLE_LABEL} > - + {message || NOT_AVAILABLE_LABEL} diff --git a/x-pack/legacy/plugins/apm/public/components/app/GlobalHelpExtension/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/GlobalHelpExtension/index.tsx index def6608eaf7f1..fb10a65d975bf 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/GlobalHelpExtension/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/GlobalHelpExtension/index.tsx @@ -8,15 +8,17 @@ import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; import styled from 'styled-components'; -import chrome from 'ui/chrome'; import url from 'url'; import { px, units } from '../../../style/variables'; +import { useCore } from '../../../hooks/useCore'; const Container = styled.div` margin: ${px(units.minus)} 0; `; export const GlobalHelpExtension: React.SFC = () => { + const core = useCore(); + return ( @@ -33,7 +35,7 @@ export const GlobalHelpExtension: React.SFC = () => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx index 665bcf7e1aacb..4042560859632 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Home/index.tsx @@ -17,7 +17,7 @@ import { HistoryTabs, IHistoryTab } from '../../shared/HistoryTabs'; import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; import { ServiceOverview } from '../ServiceOverview'; import { TraceOverview } from '../TraceOverview'; -import { APMLink } from '../../shared/Links/APMLink'; +import { APMLink } from '../../shared/Links/apm/APMLink'; const homeTabs: IHistoryTab[] = [ { diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx index 30aeb5fe31733..5eb2626f44872 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx @@ -7,14 +7,16 @@ import { Location } from 'history'; import { last } from 'lodash'; import React from 'react'; -import chrome from 'ui/chrome'; -import { getAPMHref } from '../../shared/Links/APMLink'; +import { InternalCoreStart } from 'src/core/public'; +import { useCore } from '../../../hooks/useCore'; +import { getAPMHref } from '../../shared/Links/apm/APMLink'; import { Breadcrumb, ProvideBreadcrumbs } from './ProvideBreadcrumbs'; import { routes } from './route_config'; interface Props { location: Location; breadcrumbs: Breadcrumb[]; + core: InternalCoreStart; } class UpdateBreadcrumbsComponent extends React.Component { @@ -26,7 +28,7 @@ class UpdateBreadcrumbsComponent extends React.Component { const current = last(breadcrumbs) || { text: '' }; document.title = current.text; - chrome.breadcrumbs.set(breadcrumbs); + this.props.core.chrome.setBreadcrumbs(breadcrumbs); } public componentDidMount() { @@ -43,6 +45,7 @@ class UpdateBreadcrumbsComponent extends React.Component { } export function UpdateBreadcrumbs() { + const core = useCore(); return ( )} /> diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js b/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js index dd4fbdbb24492..20d747d32afae 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/UpdateBreadcrumbs.test.js @@ -7,35 +7,18 @@ import { mount } from 'enzyme'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import chrome from 'ui/chrome'; import { UpdateBreadcrumbs } from '../UpdateBreadcrumbs'; +import * as hooks from '../../../../hooks/useCore'; jest.mock('ui/kfetch'); -jest.mock( - 'ui/chrome', - () => ({ - breadcrumbs: { - set: jest.fn() - }, - getBasePath: () => `/some/base/path`, - getUiSettingsClient: () => { - return { - get: key => { - switch (key) { - case 'timepicker:timeDefaults': - return { from: 'now-15m', to: 'now', mode: 'quick' }; - case 'timepicker:refreshIntervalDefaults': - return { pause: false, value: 0 }; - default: - throw new Error(`Unexpected config key: ${key}`); - } - } - }; - } - }), - { virtual: true } -); +const coreMock = { + chrome: { + setBreadcrumbs: jest.fn() + } +}; + +jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); function expectBreadcrumbToMatchSnapshot(route) { mount( @@ -43,8 +26,8 @@ function expectBreadcrumbToMatchSnapshot(route) { ); - expect(chrome.breadcrumbs.set).toHaveBeenCalledTimes(1); - expect(chrome.breadcrumbs.set.mock.calls[0][0]).toMatchSnapshot(); + expect(coreMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); + expect(coreMock.chrome.setBreadcrumbs.mock.calls[0][0]).toMatchSnapshot(); } describe('Breadcrumbs', () => { @@ -55,7 +38,7 @@ describe('Breadcrumbs', () => { global.document = { title: 'Kibana' }; - chrome.breadcrumbs.set.mockReset(); + coreMock.chrome.setBreadcrumbs.mockReset(); }); afterEach(() => { @@ -67,37 +50,37 @@ describe('Breadcrumbs', () => { expect(global.document.title).toMatchInlineSnapshot(`"APM"`); }); - it('/:serviceName/errors/:groupId', () => { - expectBreadcrumbToMatchSnapshot('/opbeans-node/errors/myGroupId'); + it('/services/:serviceName/errors/:groupId', () => { + expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors/myGroupId'); expect(global.document.title).toMatchInlineSnapshot(`"myGroupId"`); }); - it('/:serviceName/errors', () => { - expectBreadcrumbToMatchSnapshot('/opbeans-node/errors'); + it('/services/:serviceName/errors', () => { + expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors'); expect(global.document.title).toMatchInlineSnapshot(`"Errors"`); }); it('/:serviceName', () => { expectBreadcrumbToMatchSnapshot('/opbeans-node'); - expect(global.document.title).toMatchInlineSnapshot(`"opbeans-node"`); + expect(global.document.title).toMatchInlineSnapshot(`"APM"`); }); - it('/:serviceName/transactions', () => { - expectBreadcrumbToMatchSnapshot('/opbeans-node/transactions'); + it('/services/:serviceName/transactions', () => { + expectBreadcrumbToMatchSnapshot('/services/opbeans-node/transactions'); expect(global.document.title).toMatchInlineSnapshot(`"Transactions"`); }); - it('/:serviceName/transactions/:transactionType', () => { - expectBreadcrumbToMatchSnapshot('/opbeans-node/transactions/request'); + it('/services/:serviceName/transactions/:transactionType', () => { + expectBreadcrumbToMatchSnapshot( + '/services/opbeans-node/transactions/request' + ); expect(global.document.title).toMatchInlineSnapshot(`"Transactions"`); }); - it('/:serviceName/transactions/:transactionType/:transactionName', () => { + it('/services/:serviceName/transactions/:transactionType/:transactionName', () => { expectBreadcrumbToMatchSnapshot( - '/opbeans-node/transactions/request/my-transaction-name' - ); - expect(global.document.title).toMatchInlineSnapshot( - `"my-transaction-name"` + '/services/opbeans-node/transactions/request/my-transaction-name' ); + expect(global.document.title).toMatchInlineSnapshot(`"Transactions"`); }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/__snapshots__/UpdateBreadcrumbs.test.js.snap b/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/__snapshots__/UpdateBreadcrumbs.test.js.snap index 3274461c6552b..d340659eef131 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/__snapshots__/UpdateBreadcrumbs.test.js.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/__test__/__snapshots__/UpdateBreadcrumbs.test.js.snap @@ -6,102 +6,114 @@ Array [ "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, - Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, ] `; -exports[`Breadcrumbs /:serviceName/errors 1`] = ` +exports[`Breadcrumbs /services/:serviceName/errors 1`] = ` Array [ Object { "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Services", + }, + Object { + "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "opbeans-node", }, Object { - "href": "#/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "Errors", }, ] `; -exports[`Breadcrumbs /:serviceName/errors/:groupId 1`] = ` +exports[`Breadcrumbs /services/:serviceName/errors/:groupId 1`] = ` Array [ Object { "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Services", + }, + Object { + "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "opbeans-node", }, Object { - "href": "#/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "Errors", }, Object { - "href": "#/opbeans-node/errors/myGroupId?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services/opbeans-node/errors/myGroupId?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "myGroupId", }, ] `; -exports[`Breadcrumbs /:serviceName/transactions 1`] = ` +exports[`Breadcrumbs /services/:serviceName/transactions 1`] = ` Array [ Object { "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Services", + }, + Object { + "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "opbeans-node", }, Object { - "href": "#/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "Transactions", }, ] `; -exports[`Breadcrumbs /:serviceName/transactions/:transactionType 1`] = ` +exports[`Breadcrumbs /services/:serviceName/transactions/:transactionType 1`] = ` Array [ Object { "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Services", + }, + Object { + "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "opbeans-node", }, Object { - "href": "#/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "href": "#/services/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "Transactions", }, ] `; -exports[`Breadcrumbs /:serviceName/transactions/:transactionType/:transactionName 1`] = ` +exports[`Breadcrumbs /services/:serviceName/transactions/:transactionType/:transactionName 1`] = ` Array [ Object { "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", "text": "APM", }, Object { - "href": "#/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", + "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Services", }, Object { - "href": "#/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Transactions", + "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "opbeans-node", }, Object { - "href": "#/opbeans-node/transactions/request/my-transaction-name?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "my-transaction-name", + "href": "#/services/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", + "text": "Transactions", }, ] `; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx index 5a2c16c9fbfe0..9f19961d1361c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Redirect, RouteComponentProps } from 'react-router-dom'; -import { legacyDecodeURIComponent } from '../../../shared/Links/url_helpers'; import { ErrorGroupDetails } from '../../ErrorGroupDetails'; import { ServiceDetails } from '../../ServiceDetails'; import { TransactionDetails } from '../../TransactionDetails'; @@ -15,6 +14,7 @@ import { Home } from '../../Home'; import { BreadcrumbRoute } from '../ProvideBreadcrumbs'; import { RouteName } from './route_names'; import { SettingsList } from '../../Settings/SettingsList'; +import { toQuery } from '../../../shared/Links/url_helpers'; interface RouteParams { serviceName: string; @@ -68,63 +68,62 @@ export const routes: BreadcrumbRoute[] = [ }, { exact: true, - path: '/:serviceName', + path: '/services/:serviceName', breadcrumb: ({ match }) => match.params.serviceName, render: (props: RouteComponentProps) => - renderAsRedirectTo(`/${props.match.params.serviceName}/transactions`)( - props - ), + renderAsRedirectTo( + `/services/${props.match.params.serviceName}/transactions` + )(props), name: RouteName.SERVICE }, + + // errors { exact: true, - path: '/:serviceName/errors/:groupId', + path: '/services/:serviceName/errors/:groupId', component: ErrorGroupDetails, breadcrumb: ({ match }) => match.params.groupId, name: RouteName.ERROR }, { exact: true, - path: '/:serviceName/errors', + path: '/services/:serviceName/errors', component: ServiceDetails, breadcrumb: i18n.translate('xpack.apm.breadcrumb.errorsTitle', { defaultMessage: 'Errors' }), name: RouteName.ERRORS }, + + // transactions { exact: true, - path: '/:serviceName/transactions', + path: '/services/:serviceName/transactions', component: ServiceDetails, breadcrumb: i18n.translate('xpack.apm.breadcrumb.transactionsTitle', { defaultMessage: 'Transactions' }), name: RouteName.TRANSACTIONS }, - // Have to split this out as its own route to prevent duplicate breadcrumbs from both matching - // if we use :transactionType? as a single route here { exact: true, - path: '/:serviceName/transactions/:transactionType', - component: ServiceDetails, - breadcrumb: null, - name: RouteName.TRANSACTION_TYPE + path: '/services/:serviceName/transactions/view', + component: TransactionDetails, + breadcrumb: ({ location }) => { + const query = toQuery(location.search); + return query.transactionName as string; + }, + name: RouteName.TRANSACTION_NAME }, + + // metrics { exact: true, - path: '/:serviceName/metrics', + path: '/services/:serviceName/metrics', component: ServiceDetails, breadcrumb: i18n.translate('xpack.apm.breadcrumb.metricsTitle', { defaultMessage: 'Metrics' }), name: RouteName.METRICS - }, - { - exact: true, - path: '/:serviceName/transactions/:transactionType/:transactionName', - component: TransactionDetails, - breadcrumb: ({ match }) => - legacyDecodeURIComponent(match.params.transactionName) || '', - name: RouteName.TRANSACTION_NAME } ]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/useUpdateBadgeEffect.ts b/x-pack/legacy/plugins/apm/public/components/app/Main/useUpdateBadgeEffect.ts index 47c95a5da5a70..866a5a6884d1b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/useUpdateBadgeEffect.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/useUpdateBadgeEffect.ts @@ -7,12 +7,14 @@ import { i18n } from '@kbn/i18n'; import { useEffect } from 'react'; import { capabilities } from 'ui/capabilities'; -import chrome from 'ui/chrome'; +import { useCore } from '../../../hooks/useCore'; export const useUpdateBadgeEffect = () => { + const { chrome } = useCore(); + useEffect(() => { const uiCapabilities = capabilities.get(); - chrome.badge.set( + chrome.setBadge( !uiCapabilities.apm.save ? { text: i18n.translate('xpack.apm.header.badge.readOnly.text', { @@ -25,5 +27,5 @@ export const useUpdateBadgeEffect = () => { } : undefined ); - }, []); + }, [chrome]); }; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx index ec7d3a827ce5e..4a5f4c53d6e43 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceDetailTabs.tsx @@ -11,59 +11,55 @@ import { HistoryTabs } from '../../shared/HistoryTabs'; import { ErrorGroupOverview } from '../ErrorGroupOverview'; import { TransactionOverview } from '../TransactionOverview'; import { ServiceMetrics } from '../ServiceMetrics'; +import { useFetcher } from '../../../hooks/useFetcher'; +import { loadServiceAgentName } from '../../../services/rest/apm/services'; +import { isRumAgentName } from '../../../../common/agent_name'; interface Props { - transactionTypes: string[]; urlParams: IUrlParams; - isRumAgent?: boolean; - agentName?: string; } -export function ServiceDetailTabs({ - transactionTypes, - urlParams, - isRumAgent, - agentName -}: Props) { - const { serviceName } = urlParams; - const headTransactionType = transactionTypes[0]; +export function ServiceDetailTabs({ urlParams }: Props) { + const { serviceName, start, end } = urlParams; + const { data: agentName } = useFetcher(() => { + if (serviceName && start && end) { + return loadServiceAgentName({ serviceName, start, end }); + } + }, [serviceName, start, end]); + const transactionsTab = { title: i18n.translate('xpack.apm.serviceDetails.transactionsTabLabel', { defaultMessage: 'Transactions' }), - path: headTransactionType - ? `/${serviceName}/transactions/${headTransactionType}` - : `/${serviceName}/transactions`, - routePath: `/${serviceName}/transactions/:transactionType?`, - render: () => ( - - ), + path: `/services/${serviceName}/transactions`, + render: () => , name: 'transactions' }; + const errorsTab = { title: i18n.translate('xpack.apm.serviceDetails.errorsTabLabel', { defaultMessage: 'Errors' }), - path: `/${serviceName}/errors`, + path: `/services/${serviceName}/errors`, render: () => { return ; }, name: 'errors' }; - const metricsTab = { - title: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { - defaultMessage: 'Metrics' - }), - path: `/${serviceName}/metrics`, - render: () => , - name: 'metrics' - }; - const tabs = isRumAgent - ? [transactionsTab, errorsTab] - : [transactionsTab, errorsTab, metricsTab]; + + const tabs = [transactionsTab, errorsTab]; + if (agentName && !isRumAgentName(agentName)) { + const metricsTab = { + title: i18n.translate('xpack.apm.serviceDetails.metricsTabLabel', { + defaultMessage: 'Metrics' + }), + path: `/services/${serviceName}/metrics`, + render: () => , + name: 'metrics' + }; + + tabs.push(metricsTab); + } return ; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx index aaad257ce4f05..cb2c1df7a7093 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/index.tsx @@ -17,7 +17,6 @@ interface Props { isOpen: boolean; onClose: () => void; urlParams: IUrlParams; - serviceTransactionTypes: string[]; } interface State { @@ -155,7 +154,7 @@ export class MachineLearningFlyout extends Component { }; public render() { - const { isOpen, onClose, urlParams, serviceTransactionTypes } = this.props; + const { isOpen, onClose, urlParams } = this.props; const { serviceName } = urlParams; const { isCreatingJob, hasIndexPattern } = this.state; @@ -169,8 +168,7 @@ export class MachineLearningFlyout extends Component { isCreatingJob={isCreatingJob} onClickCreate={this.onClickCreate} onClose={onClose} - serviceName={serviceName} - serviceTransactionTypes={serviceTransactionTypes} + urlParams={urlParams} /> ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx index 640214d4a7089..92e63f0ada8f6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/MachineLearningFlyout/view.tsx @@ -20,21 +20,23 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import { isEmpty } from 'lodash'; import { FETCH_STATUS, useFetcher } from '../../../../../hooks/useFetcher'; import { getHasMLJob } from '../../../../../services/rest/ml'; import { KibanaLink } from '../../../../shared/Links/KibanaLink'; import { MLJobLink } from '../../../../shared/Links/MachineLearningLinks/MLJobLink'; import { MLLink } from '../../../../shared/Links/MachineLearningLinks/MLLink'; import { TransactionSelect } from './TransactionSelect'; +import { IUrlParams } from '../../../../../context/UrlParamsContext/types'; +import { useServiceTransactionTypes } from '../../../../../hooks/useServiceTransactionTypes'; interface Props { hasIndexPattern: boolean; isCreatingJob: boolean; onClickCreate: ({ transactionType }: { transactionType: string }) => void; onClose: () => void; - serviceName: string; - serviceTransactionTypes: string[]; + urlParams: IUrlParams; } export function MachineLearningFlyoutView({ @@ -42,16 +44,31 @@ export function MachineLearningFlyoutView({ isCreatingJob, onClickCreate, onClose, - serviceName, - serviceTransactionTypes + urlParams }: Props) { - const [transactionType, setTransactionType] = useState( - serviceTransactionTypes[0] - ); - const { data: hasMLJob = false, status } = useFetcher( - () => getHasMLJob({ serviceName, transactionType }), - [serviceName, transactionType] - ); + const { serviceName } = urlParams; + const transactionTypes = useServiceTransactionTypes(urlParams); + + const [selectedTransactionType, setSelectedTransactionType] = useState< + string | undefined + >(undefined); + const { data: hasMLJob = false, status } = useFetcher(() => { + if (serviceName && selectedTransactionType) { + return getHasMLJob({ + serviceName, + transactionType: selectedTransactionType + }); + } + }, [serviceName, selectedTransactionType]); + + // update selectedTransactionType when list of transaction types has loaded + useEffect(() => { + setSelectedTransactionType(transactionTypes[0]); + }, [transactionTypes]); + + if (!serviceName || !selectedTransactionType || isEmpty(transactionTypes)) { + return null; + } const isLoadingMLJob = status === FETCH_STATUS.LOADING; @@ -91,13 +108,13 @@ export function MachineLearningFlyoutView({ 'There is currently a job running for {serviceName} ({transactionType}).', values: { serviceName, - transactionType + transactionType: selectedTransactionType } } )}{' '} {i18n.translate( 'xpack.apm.serviceDetails.enableAnomalyDetectionPanel.callout.jobExistsDescription.viewJobLinkText', @@ -199,12 +216,12 @@ export function MachineLearningFlyoutView({ - {serviceTransactionTypes.length > 1 ? ( + {transactionTypes.length > 1 ? ( { - setTransactionType(value); + setSelectedTransactionType(value); }} /> ) : null} @@ -212,7 +229,9 @@ export function MachineLearningFlyoutView({ onClickCreate({ transactionType })} + onClick={() => + onClickCreate({ transactionType: selectedTransactionType }) + } fill disabled={ isCreatingJob || diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx index 45701f414b010..134934ff8425e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/WatcherFlyout.tsx @@ -30,20 +30,20 @@ import { memoize, padLeft, range } from 'lodash'; import moment from 'moment-timezone'; import React, { Component } from 'react'; import styled from 'styled-components'; -import chrome from 'ui/chrome'; import { toastNotifications } from 'ui/notify'; +import { InternalCoreStart } from 'src/core/public'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { KibanaLink } from '../../../shared/Links/KibanaLink'; import { createErrorGroupWatch, Schedule } from './createErrorGroupWatch'; import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; +import { CoreContext } from '../../../../context/CoreContext'; type ScheduleKey = keyof Schedule; -const getUserTimezone = memoize(() => { - const uiSettings = chrome.getUiSettingsClient(); - return uiSettings.get('dateFormat:tz') === 'Browser' +const getUserTimezone = memoize((core: InternalCoreStart): string => { + return core.uiSettings.get('dateFormat:tz') === 'Browser' ? moment.tz.guess() - : uiSettings.get('dateFormat:tz'); + : core.uiSettings.get('dateFormat:tz'); }); const SmallInput = styled.div` @@ -83,6 +83,7 @@ export class WatcherFlyout extends Component< WatcherFlyoutProps, WatcherFlyoutState > { + static contextType = CoreContext; public state: WatcherFlyoutState = { schedule: 'daily', threshold: 10, @@ -155,6 +156,7 @@ export class WatcherFlyout extends Component< }; public createWatch = () => { + const core: InternalCoreStart = this.context; const { serviceName } = this.props.urlParams; if (!serviceName) { @@ -190,13 +192,18 @@ export class WatcherFlyout extends Component< unit: 'h' }; + const apmIndexPatternTitle = core.injectedMetadata.getInjectedVar( + 'apmIndexPatternTitle' + ) as string; + return createErrorGroupWatch({ emails, schedule, serviceName, slackUrl, threshold: this.state.threshold, - timeRange + timeRange, + apmIndexPatternTitle }) .then((id: string) => { this.props.onClose(); @@ -271,7 +278,8 @@ export class WatcherFlyout extends Component< return null; } - const userTimezoneSetting = getUserTimezone(); + const core: InternalCoreStart = this.context; + const userTimezoneSetting = getUserTimezone(core); const dailyTime = this.state.daily; const inputTime = `${dailyTime}Z`; // Add tz to make into UTC const inputFormat = 'HH:mmZ'; // Parse as 24 hour w. tz diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts index c952ce7f7ced2..1bc2c8e2a84ea 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/__test__/createErrorGroupWatch.test.ts @@ -6,7 +6,6 @@ import { isArray, isObject, isString } from 'lodash'; import mustache from 'mustache'; -import chrome from 'ui/chrome'; import uuid from 'uuid'; import { StringMap } from '../../../../../../typings/common'; // @ts-ignore @@ -23,7 +22,6 @@ describe('createErrorGroupWatch', () => { let createWatchResponse: string; let tmpl: any; beforeEach(async () => { - chrome.getInjected = jest.fn().mockReturnValue('myIndexPattern'); jest.spyOn(uuid, 'v4').mockReturnValue(new Buffer('mocked-uuid')); jest.spyOn(rest, 'createWatch').mockReturnValue(undefined); @@ -37,7 +35,8 @@ describe('createErrorGroupWatch', () => { serviceName: 'opbeans-node', slackUrl: 'https://hooks.slack.com/services/slackid1/slackid2/slackid3', threshold: 10, - timeRange: { value: 24, unit: 'h' } + timeRange: { value: 24, unit: 'h' }, + apmIndexPatternTitle: 'myIndexPattern' }); const watchBody = rest.createWatch.mock.calls[0][1]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts index 81ec61fc1bb5d..2617fef6de1d2 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/createErrorGroupWatch.ts @@ -6,7 +6,6 @@ import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; -import chrome from 'ui/chrome'; import url from 'url'; import uuid from 'uuid'; import { @@ -46,6 +45,7 @@ interface Arguments { value: number; unit: string; }; + apmIndexPatternTitle: string; } interface Actions { @@ -60,10 +60,10 @@ export async function createErrorGroupWatch({ serviceName, slackUrl, threshold, - timeRange + timeRange, + apmIndexPatternTitle }: Arguments) { const id = `apm-${uuid.v4()}`; - const apmIndexPatternTitle = chrome.getInjected('apmIndexPatternTitle'); const slackUrlPath = getSlackPathUrl(slackUrl); const emailTemplate = i18n.translate( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx index 9835c01fa1018..189e8f495afc4 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceDetails/ServiceIntegrations/index.tsx @@ -13,14 +13,14 @@ import { import { i18n } from '@kbn/i18n'; import { memoize } from 'lodash'; import React, { Fragment } from 'react'; -import chrome from 'ui/chrome'; +import { InternalCoreStart } from 'src/core/public'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; import { LicenseContext } from '../../../../context/LicenseContext'; import { MachineLearningFlyout } from './MachineLearningFlyout'; import { WatcherFlyout } from './WatcherFlyout'; +import { CoreContext } from '../../../../context/CoreContext'; interface Props { - transactionTypes: string[]; urlParams: IUrlParams; } interface State { @@ -30,6 +30,7 @@ interface State { type FlyoutName = null | 'ML' | 'Watcher'; export class ServiceIntegrations extends React.Component { + static contextType = CoreContext; public state: State = { isPopoverOpen: false, activeFlyout: null }; public getPanelItems = memoize((mlAvailable: boolean) => { @@ -65,6 +66,8 @@ export class ServiceIntegrations extends React.Component { }; public getWatcherPanelItems = () => { + const core: InternalCoreStart = this.context; + return [ { name: i18n.translate( @@ -87,7 +90,7 @@ export class ServiceIntegrations extends React.Component { } ), icon: 'watchesApp', - href: chrome.addBasePath( + href: core.http.basePath.prepend( '/app/kibana#/management/elasticsearch/watcher' ), target: '_blank', @@ -153,7 +156,6 @@ export class ServiceIntegrations extends React.Component { isOpen={this.state.activeFlyout === 'ML'} onClose={this.closeFlyouts} urlParams={this.props.urlParams} - serviceTransactionTypes={this.props.transactionTypes} /> { - if (serviceName && start && end) { - return loadServiceDetails({ serviceName, start, end, uiFilters }); - } - }, [serviceName, start, end, uiFilters]); - - if (!serviceDetailsData) { - return null; - } - - const isRumAgent = isRumAgentName(serviceDetailsData.agentName); + const { urlParams } = useUrlParams(); + const { serviceName } = urlParams; return (
@@ -39,20 +25,12 @@ export function ServiceDetails() { - + - +
); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx index 9d28db93d6f01..5e76140ce21e8 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMetrics/index.tsx @@ -12,7 +12,7 @@ import { useUrlParams } from '../../../hooks/useUrlParams'; import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; interface ServiceMetricsProps { - agentName?: string; + agentName: string; } export function ServiceMetrics({ agentName }: ServiceMetricsProps) { @@ -21,18 +21,18 @@ export function ServiceMetrics({ agentName }: ServiceMetricsProps) { const { start, end } = urlParams; return ( - - {data.charts.map(chart => ( - - - + + + {data.charts.map(chart => ( + + - - - - ))} - - + +
+ ))} + + + ); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx index 0b87590576342..de058d6ef973a 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/NoServicesMessage.tsx @@ -10,18 +10,24 @@ import React from 'react'; import { KibanaLink } from '../../shared/Links/KibanaLink'; import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; import { LoadingStatePrompt } from '../../shared/LoadingStatePrompt'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; +import { ErrorStatePrompt } from '../../shared/ErrorStatePrompt'; interface Props { // any data submitted from APM agents found (not just in the given time range) historicalDataFound: boolean; - isLoading: boolean; + status: FETCH_STATUS | undefined; } -export function NoServicesMessage({ historicalDataFound, isLoading }: Props) { - if (isLoading) { +export function NoServicesMessage({ historicalDataFound, status }: Props) { + if (status === 'loading') { return ; } + if (status === 'failure') { + return ; + } + if (historicalDataFound) { return ( List should render columns correctly 1`] = ` position="top" > opbeans-python diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx index 8ae355aa53cf1..31e05379928ec 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/ServiceList/index.tsx @@ -12,7 +12,7 @@ import { ServiceListAPIResponse } from '../../../../../server/lib/services/get_s import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; import { fontSizes, truncate } from '../../../../style/variables'; import { asDecimal, asMillis } from '../../../../utils/formatters'; -import { APMLink } from '../../../shared/Links/APMLink'; +import { APMLink } from '../../../shared/Links/apm/APMLink'; import { ManagedTable } from '../../../shared/ManagedTable'; import { EnvironmentBadge } from '../../../shared/EnvironmentBadge'; @@ -50,7 +50,7 @@ export const SERVICE_COLUMNS = [ sortable: true, render: (serviceName: string) => ( - + {formatString(serviceName)} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx index 8b68e6207bb4c..fdf373bf97f94 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/NoServicesMessage.test.tsx @@ -7,19 +7,20 @@ import { shallow } from 'enzyme'; import React from 'react'; import { NoServicesMessage } from '../NoServicesMessage'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; describe('NoServicesMessage', () => { - it('should show only a "not found" message when historical data is found', () => { - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should show a "no services installed" message, a link to the set up instructions page, a message about upgrading APM server, and a link to the upgrade assistant when NO historical data is found', () => { - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); + Object.values(FETCH_STATUS).forEach(status => { + [true, false].forEach(historicalDataFound => { + it(`status: ${status} and historicalDataFound: ${historicalDataFound}`, () => { + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx index b616111d7b71b..013d803e6411d 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/ServiceOverview.test.tsx @@ -10,7 +10,9 @@ import 'react-testing-library/cleanup-after-each'; import { toastNotifications } from 'ui/notify'; import * as apmRestServices from '../../../../services/rest/apm/services'; import { ServiceOverview } from '..'; -import * as hooks from '../../../../hooks/useUrlParams'; +import * as urlParamsHooks from '../../../../hooks/useUrlParams'; +import * as coreHooks from '../../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; jest.mock('ui/kfetch'); @@ -20,13 +22,22 @@ function renderServiceOverview() { describe('Service Overview -> View', () => { beforeEach(() => { + const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } + } as unknown) as InternalCoreStart; + // mock urlParams - spyOn(hooks, 'useUrlParams').and.returnValue({ + spyOn(urlParamsHooks, 'useUrlParams').and.returnValue({ urlParams: { start: 'myStart', end: 'myEnd' } }); + spyOn(coreHooks, 'useCore').and.returnValue(coreMock); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap index 02dbe22aeb19d..42e2b59da2e10 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/NoServicesMessage.test.tsx.snap @@ -1,6 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NoServicesMessage should show a "no services installed" message, a link to the set up instructions page, a message about upgrading APM server, and a link to the upgrade assistant when NO historical data is found 1`] = ` +exports[`NoServicesMessage status: failure and historicalDataFound: false 1`] = ``; + +exports[`NoServicesMessage status: failure and historicalDataFound: true 1`] = ``; + +exports[`NoServicesMessage status: loading and historicalDataFound: false 1`] = ``; + +exports[`NoServicesMessage status: loading and historicalDataFound: true 1`] = ``; + +exports[`NoServicesMessage status: success and historicalDataFound: false 1`] = ` `; -exports[`NoServicesMessage should show only a "not found" message when historical data is found 1`] = ` +exports[`NoServicesMessage status: success and historicalDataFound: true 1`] = ` Learn more by visiting the Kibana Upgrade Assistant @@ -96,7 +96,7 @@ NodeList [ />
+ } + body={i18n.translate('xpack.apm.error.prompt.body', { + defaultMessage: `Please inspect your browser's developer console for details.` + })} + titleSize="s" + /> + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx index 0bef2e49a77e8..797dd7fcdb2f1 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/KueryBar/index.tsx @@ -7,21 +7,20 @@ import React, { useState, useEffect } from 'react'; import { uniqueId, startsWith } from 'lodash'; import { EuiCallOut } from '@elastic/eui'; -import chrome from 'ui/chrome'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { AutocompleteSuggestion } from 'ui/autocomplete_providers'; +import { + AutocompleteSuggestion, + getAutocompleteProvider +} from 'ui/autocomplete_providers'; import { StaticIndexPattern } from 'ui/index_patterns'; +import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { getFromSavedObject } from 'ui/index_patterns/static_utils'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { KibanaLink } from '../Links/KibanaLink'; // @ts-ignore import { Typeahead } from './Typeahead'; -import { - convertKueryToEsQuery, - getSuggestions, - getAPMIndexPatternForKuery -} from '../../../services/kuery'; // @ts-ignore import { getBoolFilter } from './get_bool_filter'; import { useLocation } from '../../../hooks/useLocation'; @@ -29,6 +28,8 @@ import { useUrlParams } from '../../../hooks/useUrlParams'; import { history } from '../../../utils/history'; import { useMatchedRoutes } from '../../../hooks/useMatchedRoutes'; import { RouteName } from '../../app/Main/route_config/route_names'; +import { useCore } from '../../../hooks/useCore'; +import { getAPMIndexPattern } from '../../../services/rest/savedObjects'; const Container = styled.div` margin-bottom: 10px; @@ -41,7 +42,52 @@ interface State { isLoadingSuggestions: boolean; } +function convertKueryToEsQuery( + kuery: string, + indexPattern: StaticIndexPattern +) { + const ast = fromKueryExpression(kuery); + return toElasticsearchQuery(ast, indexPattern); +} + +async function getAPMIndexPatternForKuery(): Promise< + StaticIndexPattern | undefined +> { + const apmIndexPattern = await getAPMIndexPattern(); + if (!apmIndexPattern) { + return; + } + return getFromSavedObject(apmIndexPattern); +} + +function getSuggestions( + query: string, + selectionStart: number, + apmIndexPattern: StaticIndexPattern, + boolFilter: unknown +) { + const autocompleteProvider = getAutocompleteProvider('kuery'); + if (!autocompleteProvider) { + return []; + } + const config = { + get: () => true + }; + + const getAutocompleteSuggestions = autocompleteProvider({ + config, + indexPatterns: [apmIndexPattern], + boolFilter + }); + return getAutocompleteSuggestions({ + query, + selectionStart, + selectionEnd: selectionStart + }); +} + export function KueryBar() { + const core = useCore(); const [state, setState] = useState({ indexPattern: null, suggestions: [], @@ -52,7 +98,9 @@ export function KueryBar() { const location = useLocation(); const matchedRoutes = useMatchedRoutes(); - const apmIndexPatternTitle = chrome.getInjected('apmIndexPatternTitle'); + const apmIndexPatternTitle = core.injectedMetadata.getInjectedVar( + 'apmIndexPatternTitle' + ); const indexPatternMissing = !state.isLoadingIndexPattern && !state.indexPattern; let currentRequestCheck; @@ -72,15 +120,19 @@ export function KueryBar() { let didCancel = false; async function loadIndexPattern() { - setState({ ...state, isLoadingIndexPattern: true }); + setState(value => ({ ...value, isLoadingIndexPattern: true })); const indexPattern = await getAPMIndexPatternForKuery(); if (didCancel) { return; } if (!indexPattern) { - setState({ ...state, isLoadingIndexPattern: false }); + setState(value => ({ ...value, isLoadingIndexPattern: false })); } else { - setState({ ...state, indexPattern, isLoadingIndexPattern: false }); + setState(value => ({ + ...value, + indexPattern, + isLoadingIndexPattern: false + })); } } loadIndexPattern(); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx index d5633611f09e8..0673ab5e75cc6 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/DiscoverLink.tsx @@ -6,12 +6,12 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; -import chrome from 'ui/chrome'; import url from 'url'; import rison, { RisonValue } from 'rison-node'; import { useAPMIndexPattern } from '../../../../hooks/useAPMIndexPattern'; import { useLocation } from '../../../../hooks/useLocation'; import { getTimepickerRisonData } from '../rison_helpers'; +import { useCore } from '../../../../hooks/useCore'; interface Props { query: { @@ -31,6 +31,7 @@ interface Props { } export function DiscoverLink({ query = {}, ...rest }: Props) { + const core = useCore(); const apmIndexPattern = useAPMIndexPattern(); const location = useLocation(); @@ -47,7 +48,7 @@ export function DiscoverLink({ query = {}, ...rest }: Props) { }; const href = url.format({ - pathname: chrome.addBasePath('/app/kibana'), + pathname: core.http.basePath.prepend('/app/kibana'), hash: `/discover?_g=${rison.encode(risonQuery._g)}&_a=${rison.encode( risonQuery._a as RisonValue )}` diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx index 91e055411cc0b..a47df4886c40c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/DiscoverLinks/__test__/DiscoverLinks.integration.test.tsx @@ -14,6 +14,8 @@ import { getRenderedHref } from '../../../../../utils/testHelpers'; import { DiscoverErrorLink } from '../DiscoverErrorLink'; import { DiscoverSpanLink } from '../DiscoverSpanLink'; import { DiscoverTransactionLink } from '../DiscoverTransactionLink'; +import * as hooks from '../../../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; jest.mock('ui/kfetch'); @@ -25,6 +27,16 @@ jest beforeAll(() => { jest.spyOn(console, 'error').mockImplementation(() => null); + + const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } + } as unknown) as InternalCoreStart; + + jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); }); afterAll(() => { @@ -49,7 +61,7 @@ test('DiscoverTransactionLink should produce the correct URL', async () => { ); expect(href).toEqual( - `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'processor.event:"transaction" AND transaction.id:"8b60bd32ecc6e150" AND trace.id:"8b60bd32ecc6e1506735a8b6cfcf175c"'))` + `/basepath/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'processor.event:"transaction" AND transaction.id:"8b60bd32ecc6e150" AND trace.id:"8b60bd32ecc6e1506735a8b6cfcf175c"'))` ); }); @@ -65,7 +77,7 @@ test('DiscoverSpanLink should produce the correct URL', async () => { } as Location); expect(href).toEqual( - `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'span.id:"test-span-id"'))` + `/basepath/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'span.id:"test-span-id"'))` ); }); @@ -86,7 +98,7 @@ test('DiscoverErrorLink should produce the correct URL', async () => { ); expect(href).toEqual( - `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key"'),sort:('@timestamp':desc))` + `/basepath/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key"'),sort:('@timestamp':desc))` ); }); @@ -108,6 +120,6 @@ test('DiscoverErrorLink should include optional kuery string in URL', async () = ); expect(href).toEqual( - `/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key" AND some:kuery-string'),sort:('@timestamp':desc))` + `/basepath/app/kibana#/discover?_g=(refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now))&_a=(index:apm-index-pattern-id,interval:auto,query:(language:lucene,query:'service.name:"service-name" AND error.grouping_key:"grouping-key" AND some:kuery-string'),sort:('@timestamp':desc))` ); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx index 832cb13f3ba27..9925d87a159ca 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.test.tsx @@ -8,11 +8,18 @@ import { Location } from 'history'; import React from 'react'; import { getRenderedHref } from '../../../utils/testHelpers'; import { InfraLink } from './InfraLink'; -import chrome from 'ui/chrome'; +import * as hooks from '../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; -jest - .spyOn(chrome, 'addBasePath') - .mockImplementation(path => `/basepath${path}`); +const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } +} as unknown) as InternalCoreStart; + +jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); test('InfraLink produces the correct URL', async () => { const href = await getRenderedHref( diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx index 6bc2f0c355a23..eda64b4bbedb2 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/InfraLink.tsx @@ -7,9 +7,9 @@ import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; import { compact } from 'lodash'; import React from 'react'; -import chrome from 'ui/chrome'; import url from 'url'; import { fromQuery } from './url_helpers'; +import { useCore } from '../../../hooks/useCore'; interface InfraQueryParams { time?: number; @@ -24,9 +24,10 @@ interface Props extends EuiLinkAnchorProps { } export function InfraLink({ path, query = {}, ...rest }: Props) { + const core = useCore(); const nextSearch = fromQuery(query); const href = url.format({ - pathname: chrome.addBasePath('/app/infra'), + pathname: core.http.basePath.prepend('/app/infra'), hash: compact([path, nextSearch]).join('?') }); return ; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx index e1cf2f5f4d562..24637f971bf3c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.test.tsx @@ -8,16 +8,30 @@ import { Location } from 'history'; import React from 'react'; import { getRenderedHref } from '../../../utils/testHelpers'; import { KibanaLink } from './KibanaLink'; -import chrome from 'ui/chrome'; +import * as hooks from '../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; -jest - .spyOn(chrome, 'addBasePath') - .mockImplementation(path => `/basepath${path}`); +describe('KibanaLink', () => { + beforeEach(() => { + const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } + } as unknown) as InternalCoreStart; -test('KibanaLink produces the correct URL', async () => { - const href = await getRenderedHref(() => , { - search: '?rangeFrom=now-5h&rangeTo=now-2h' - } as Location); + jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); + }); - expect(href).toMatchInlineSnapshot(`"/basepath/app/kibana#/some/path"`); + afterEach(() => { + jest.resetAllMocks(); + }); + + it('produces the correct URL', async () => { + const href = await getRenderedHref(() => , { + search: '?rangeFrom=now-5h&rangeTo=now-2h' + } as Location); + expect(href).toMatchInlineSnapshot(`"/basepath/app/kibana#/some/path"`); + }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx index cc558a35bf609..53fe9da734644 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/KibanaLink.tsx @@ -6,8 +6,8 @@ import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; import React from 'react'; -import chrome from 'ui/chrome'; import url from 'url'; +import { useCore } from '../../../hooks/useCore'; interface Props extends EuiLinkAnchorProps { path?: string; @@ -15,8 +15,9 @@ interface Props extends EuiLinkAnchorProps { } export function KibanaLink({ path, ...rest }: Props) { + const core = useCore(); const href = url.format({ - pathname: chrome.addBasePath('/app/kibana'), + pathname: core.http.basePath.prepend('/app/kibana'), hash: path }); return ; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index 0f84b3614cb4f..c577a38029d29 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -8,8 +8,25 @@ import { Location } from 'history'; import React from 'react'; import { getRenderedHref } from '../../../../utils/testHelpers'; import { MLJobLink } from './MLJobLink'; +import * as hooks from '../../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; describe('MLJobLink', () => { + beforeEach(() => { + const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } + } as unknown) as InternalCoreStart; + + spyOn(hooks, 'useCore').and.returnValue(coreMock); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); it('should produce the correct URL', async () => { const href = await getRenderedHref( () => ( @@ -22,7 +39,7 @@ describe('MLJobLink', () => { ); expect(href).toEqual( - `/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` + `/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` ); }); }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index ac31ac55952bf..2bb4da88236ca 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -8,14 +8,21 @@ import { Location } from 'history'; import React from 'react'; import { getRenderedHref } from '../../../../utils/testHelpers'; import { MLLink } from './MLLink'; -import chrome from 'ui/chrome'; import * as savedObjects from '../../../../services/rest/savedObjects'; +import * as hooks from '../../../../hooks/useCore'; +import { InternalCoreStart } from 'src/core/public'; jest.mock('ui/kfetch'); -jest - .spyOn(chrome, 'addBasePath') - .mockImplementation(path => `/basepath${path}`); +const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } +} as unknown) as InternalCoreStart; + +jest.spyOn(hooks, 'useCore').mockReturnValue(coreMock); jest .spyOn(savedObjects, 'getAPMIndexPattern') diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx index 949c64a2171dc..e0b9331d28496 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.tsx @@ -6,11 +6,11 @@ import { EuiLink } from '@elastic/eui'; import React from 'react'; -import chrome from 'ui/chrome'; import url from 'url'; import rison, { RisonValue } from 'rison-node'; import { useLocation } from '../../../../hooks/useLocation'; import { getTimepickerRisonData, TimepickerRisonData } from '../rison_helpers'; +import { useCore } from '../../../../hooks/useCore'; interface MlRisonData { ml?: { @@ -25,6 +25,7 @@ interface Props { } export function MLLink({ children, path = '', query = {} }: Props) { + const core = useCore(); const location = useLocation(); const risonQuery: MlRisonData & TimepickerRisonData = getTimepickerRisonData( @@ -36,7 +37,7 @@ export function MLLink({ children, path = '', query = {} }: Props) { } const href = url.format({ - pathname: chrome.addBasePath('/app/ml'), + pathname: core.http.basePath.prepend('/app/ml'), hash: `${path}?_g=${rison.encode(risonQuery as RisonValue)}` }); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx similarity index 96% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.test.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx index 610f0d5302d94..a319860c75b62 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx @@ -6,7 +6,7 @@ import { Location } from 'history'; import React from 'react'; -import { getRenderedHref } from '../../../utils/testHelpers'; +import { getRenderedHref } from '../../../../utils/testHelpers'; import { APMLink } from './APMLink'; test('APMLink should produce the correct URL', async () => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx similarity index 85% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 4e739ff8f63d0..662ffea88eba4 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/APMLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -8,9 +8,9 @@ import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; import React from 'react'; import url from 'url'; import { pick } from 'lodash'; -import { useLocation } from '../../../hooks/useLocation'; -import { APMQueryParams, toQuery, fromQuery } from './url_helpers'; -import { TIMEPICKER_DEFAULTS } from '../../../context/UrlParamsContext/constants'; +import { useLocation } from '../../../../hooks/useLocation'; +import { APMQueryParams, toQuery, fromQuery } from '../url_helpers'; +import { TIMEPICKER_DEFAULTS } from '../../../../context/UrlParamsContext/constants'; interface Props extends EuiLinkAnchorProps { path?: string; diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/TransactionLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionLink.tsx similarity index 59% rename from x-pack/legacy/plugins/apm/public/components/shared/Links/TransactionLink.tsx rename to x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionLink.tsx index a8d3877058646..6fa2743fc2823 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/TransactionLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/TransactionLink.tsx @@ -5,12 +5,11 @@ */ import React from 'react'; -import { Transaction } from '../../../../typings/es_schemas/ui/Transaction'; +import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction'; import { APMLink } from './APMLink'; -import { legacyEncodeURIComponent } from './url_helpers'; interface TransactionLinkProps { - transaction?: Transaction; + transaction: Transaction | undefined; } export const TransactionLink: React.SFC = ({ @@ -22,18 +21,15 @@ export const TransactionLink: React.SFC = ({ } const serviceName = transaction.service.name; - const transactionType = legacyEncodeURIComponent( - transaction.transaction.type - ); const traceId = transaction.trace.id; const transactionId = transaction.transaction.id; - const name = transaction.transaction.name; - const encodedName = legacyEncodeURIComponent(name); + const transactionName = transaction.transaction.name; + const transactionType = transaction.transaction.type; return ( {children} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx index dadb733aeb7b6..ac728e72fa877 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.test.tsx @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -import { toJson } from '../testHelpers'; -import { - fromQuery, - legacyDecodeURIComponent, - legacyEncodeURIComponent, - toQuery -} from './url_helpers'; +import { fromQuery, toQuery } from './url_helpers'; describe('toQuery', () => { it('should parse string to object', () => { @@ -65,64 +58,3 @@ describe('fromQuery and toQuery', () => { ).toEqual('name=john%20doe&rangeFrom=2019-03-03T12:00:00.000Z&path=a%2Fb'); }); }); - -describe('legacyEncodeURIComponent', () => { - it('should encode a string with forward slashes', () => { - expect(legacyEncodeURIComponent('a/b/c')).toBe('a~2Fb~2Fc'); - }); - - it('should encode a string with tilde', () => { - expect(legacyEncodeURIComponent('a~b~c')).toBe('a~7Eb~7Ec'); - }); - - it('should encode a string with spaces', () => { - expect(legacyEncodeURIComponent('a b c')).toBe('a~20b~20c'); - }); -}); - -describe('legacyDecodeURIComponent', () => { - ['a/b/c', 'a~b~c', 'GET /', 'foo ~ bar /'].map(input => { - it(`should encode and decode ${input}`, () => { - const converted = legacyDecodeURIComponent( - legacyEncodeURIComponent(input) - ); - expect(converted).toBe(input); - }); - }); - - describe('when Angular decodes forward slashes in a url', () => { - it('should decode value correctly', () => { - const transactionName = 'GET a/b/c/'; - const encodedTransactionName = legacyEncodeURIComponent(transactionName); - const parsedUrl = emulateAngular( - `/transaction/${encodedTransactionName}` - ); - const decodedTransactionName = legacyDecodeURIComponent( - parsedUrl.split('/')[2] - ); - - expect(decodedTransactionName).toBe(transactionName); - }); - - it('should decode value incorrectly when using vanilla encodeURIComponent', () => { - const transactionName = 'GET a/b/c/'; - const encodedTransactionName = encodeURIComponent(transactionName); - const parsedUrl = emulateAngular( - `/transaction/${encodedTransactionName}` - ); - const decodedTransactionName = decodeURIComponent( - parsedUrl.split('/')[2] - ); - - expect(decodedTransactionName).not.toBe(transactionName); - }); - }); -}); - -// Angular decodes forward slashes in path params -function emulateAngular(input: string) { - return input - .split('/') - .map(pathParam => pathParam.replace(/%2F/g, '/')) - .join('/'); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts index df61d3de66952..0575c0837668c 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -21,6 +21,8 @@ export function fromQuery(query: StringMap) { export interface APMQueryParams { transactionId?: string; + transactionName?: string; + transactionType?: string; traceId?: string; detailTab?: string; flyoutDetailTab?: string; @@ -41,19 +43,3 @@ export interface APMQueryParams { // forces every value of T[K] to be type: string type StringifyAll = { [K in keyof T]: string }; type APMQueryParamsRaw = StringifyAll; - -// This is downright horrible 😭 💔 -// Angular decodes encoded url tokens like "%2F" to "/" which causes problems when path params contains forward slashes -// This was originally fixed in Angular, but roled back to avoid breaking backwards compatability: https://github.com/angular/angular.js/commit/2bdf7126878c87474bb7588ce093d0a3c57b0026 -export function legacyEncodeURIComponent(rawUrl: string | undefined) { - return ( - rawUrl && - encodeURIComponent(rawUrl) - .replace(/~/g, '%7E') - .replace(/%/g, '~') - ); -} - -export function legacyDecodeURIComponent(encodedUrl: string | undefined) { - return encodedUrl && decodeURIComponent(encodedUrl.replace(/~/g, '%')); -} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index e0b8b40e3dcf4..2e3382f71b204 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -14,7 +14,6 @@ import { EuiPopover, EuiLink } from '@elastic/eui'; -import chrome from 'ui/chrome'; import url from 'url'; import { i18n } from '@kbn/i18n'; import React, { useState, FunctionComponent } from 'react'; @@ -25,6 +24,7 @@ import { DiscoverTransactionLink } from '../Links/DiscoverLinks/DiscoverTransact import { InfraLink } from '../Links/InfraLink'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { fromQuery } from '../Links/url_helpers'; +import { useCore } from '../../../hooks/useCore'; function getInfraMetricsQuery(transaction: Transaction) { const plus5 = new Date(transaction['@timestamp']); @@ -66,6 +66,8 @@ export const TransactionActionMenu: FunctionComponent = ( ) => { const { transaction } = props; + const core = useCore(); + const [isOpen, setIsOpen] = useState(false); const { urlParams } = useUrlParams(); @@ -164,7 +166,7 @@ export const TransactionActionMenu: FunctionComponent = ( ); const uptimeLink = url.format({ - pathname: chrome.addBasePath('/app/uptime'), + pathname: core.http.basePath.prepend('/app/uptime'), hash: `/?${fromQuery( pick( { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 6b4460253a0ad..89adbd5c0d832 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -10,8 +10,10 @@ import 'react-testing-library/cleanup-after-each'; import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction'; import * as Transactions from './mockData'; -import * as hooks from '../../../../hooks/useAPMIndexPattern'; +import * as apmIndexPatternHooks from '../../../../hooks/useAPMIndexPattern'; +import * as coreHoooks from '../../../../hooks/useCore'; import { ISavedObject } from '../../../../services/rest/savedObjects'; +import { InternalCoreStart } from 'src/core/public'; jest.mock('ui/kfetch'); @@ -27,9 +29,18 @@ const renderTransaction = async (transaction: Record) => { describe('TransactionActionMenu component', () => { beforeEach(() => { + const coreMock = ({ + http: { + basePath: { + prepend: (path: string) => `/basepath${path}` + } + } + } as unknown) as InternalCoreStart; + jest - .spyOn(hooks, 'useAPMIndexPattern') + .spyOn(apmIndexPatternHooks, 'useAPMIndexPattern') .mockReturnValue({ id: 'foo' } as ISavedObject); + jest.spyOn(coreHoooks, 'useCore').mockReturnValue(coreMock); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap index 10439a4a019c7..63f56b8db5c50 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/__snapshots__/TransactionActionMenu.test.tsx.snap @@ -25,15 +25,9 @@ exports[`TransactionActionMenu component should match the snapshot 1`] = ` width="16" xmlns="http://www.w3.org/2000/svg" > - - - -
({} as InternalCoreStart); +const CoreProvider: React.SFC<{ core: InternalCoreStart }> = props => { + const { core, ...restProps } = props; + return ; +}; + +export { CoreContext, CoreProvider }; diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx index 40d8f59cd9249..ffd85412be0f4 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/InvalidLicenseNotification.tsx @@ -6,11 +6,14 @@ import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import chrome from 'ui/chrome'; - -const MANAGE_LICENSE_URL = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/license_management`; +import { useCore } from '../../hooks/useCore'; export function InvalidLicenseNotification() { + const core = useCore(); + const manageLicenseURL = core.http.basePath.prepend( + '/app/kibana#/management/elasticsearch/license_management' + ); + return ( } actions={[ - + {i18n.translate('xpack.apm.invalidLicense.licenseManagementLink', { defaultMessage: 'Manage your license' })} diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx index a88d3f2dd33f6..2604a3a122574 100644 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx @@ -42,7 +42,9 @@ describe('UrlParamsContext', () => { }); it('should have default params', () => { - const location = { pathname: '/test/pathname' } as Location; + const location = { + pathname: '/services/opbeans-node/transactions' + } as Location; jest .spyOn(Date, 'now') @@ -52,7 +54,7 @@ describe('UrlParamsContext', () => { expect(params).toEqual({ start: '2000-06-14T12:00:00.000Z', - serviceName: 'test', + serviceName: 'opbeans-node', end: '2000-06-15T12:00:00.000Z', page: 0, processorEvent: 'transaction', diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts index 12c58d8ad54cc..b1c6f6d526637 100644 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/helpers.ts @@ -58,38 +58,41 @@ export function removeUndefinedProps(obj: T): Partial { export function getPathParams(pathname: string = '') { const paths = getPathAsArray(pathname); - const pageName = paths.length > 1 ? paths[1] : paths[0]; + const pageName = paths[0]; // TODO: use react router's real match params instead of guessing the path order switch (pageName) { - case 'transactions': - return { - processorEvent: 'transaction', - serviceName: paths[0], - transactionType: paths[2], - transactionName: paths[3] - }; - case 'errors': - return { - processorEvent: 'error', - serviceName: paths[0], - errorGroupId: paths[2] - }; - case 'metrics': - return { - processorEvent: 'metric', - serviceName: paths[0] - }; - case 'services': // fall thru since services and traces share path params + case 'services': + const servicePageName = paths[2]; + const serviceName = paths[1]; + switch (servicePageName) { + case 'transactions': + return { + processorEvent: 'transaction', + serviceName + }; + case 'errors': + return { + processorEvent: 'error', + serviceName, + errorGroupId: paths[3] + }; + case 'metrics': + return { + processorEvent: 'metric', + serviceName + }; + default: + return { + processorEvent: 'transaction' + }; + } + case 'traces': return { - processorEvent: 'transaction', - serviceName: undefined + processorEvent: 'transaction' }; default: - return { - processorEvent: 'transaction', - serviceName: paths[0] - }; + return {}; } } diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx index 383163e8e239b..8cf326d6cfd6a 100644 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/index.tsx @@ -39,17 +39,19 @@ const UrlParamsProvider: React.ComponentClass<{}> = withRouter( ({ location, children }) => { const refUrlParams = useRef(resolveUrlParams(location, {})); + const { start, end, rangeFrom, rangeTo } = refUrlParams.current; + const [, forceUpdate] = useState(''); const urlParams = useMemo( () => resolveUrlParams(location, { - start: refUrlParams.current.start, - end: refUrlParams.current.end, - rangeFrom: refUrlParams.current.rangeFrom, - rangeTo: refUrlParams.current.rangeTo + start, + end, + rangeFrom, + rangeTo }), - [location, refUrlParams.current] + [location, start, end, rangeFrom, rangeTo] ); refUrlParams.current = urlParams; diff --git a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts index 02fca270066bd..234bbc55a1069 100644 --- a/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts +++ b/x-pack/legacy/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts @@ -15,10 +15,7 @@ import { toNumber, toString } from './helpers'; -import { - toQuery, - legacyDecodeURIComponent -} from '../../components/shared/Links/url_helpers'; +import { toQuery } from '../../components/shared/Links/url_helpers'; import { TIMEPICKER_DEFAULTS } from './constants'; type TimeUrlParams = Pick< @@ -27,17 +24,15 @@ type TimeUrlParams = Pick< >; export function resolveUrlParams(location: Location, state: TimeUrlParams) { - const { - processorEvent, - serviceName, - transactionName, - transactionType, - errorGroupId - } = getPathParams(location.pathname); + const { processorEvent, serviceName, errorGroupId } = getPathParams( + location.pathname + ); const { traceId, transactionId, + transactionName, + transactionType, detailTab, flyoutDetailTab, waterfallItemId, @@ -62,6 +57,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { rangeTo, refreshPaused: toBoolean(refreshPaused), refreshInterval: toNumber(refreshInterval), + // query params sortDirection, sortField, @@ -74,12 +70,14 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { flyoutDetailTab: toString(flyoutDetailTab), spanId: toNumber(spanId), kuery: kuery && decodeURIComponent(kuery), + transactionName, + transactionType, + // path params processorEvent, serviceName, - transactionType: legacyDecodeURIComponent(transactionType), - transactionName: legacyDecodeURIComponent(transactionName), errorGroupId, + // ui filters environment }); diff --git a/x-pack/legacy/plugins/apm/public/hacks/toggle_app_link_in_nav.ts b/x-pack/legacy/plugins/apm/public/hacks/toggle_app_link_in_nav.ts index 628c334863bc9..295b101777541 100644 --- a/x-pack/legacy/plugins/apm/public/hacks/toggle_app_link_in_nav.ts +++ b/x-pack/legacy/plugins/apm/public/hacks/toggle_app_link_in_nav.ts @@ -6,9 +6,9 @@ import { npStart } from 'ui/new_platform'; -const apmUiEnabled = npStart.core.injectedMetadata.getInjectedVar( - 'apmUiEnabled' -); +const { core } = npStart; +const apmUiEnabled = core.injectedMetadata.getInjectedVar('apmUiEnabled'); + if (apmUiEnabled === false) { - npStart.core.chrome.navLinks.update('apm', { hidden: true }); + core.chrome.navLinks.update('apm', { hidden: true }); } diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/app.ts b/x-pack/legacy/plugins/apm/public/hooks/useCore.tsx similarity index 61% rename from x-pack/legacy/plugins/snapshot_restore/common/types/app.ts rename to x-pack/legacy/plugins/apm/public/hooks/useCore.tsx index 3a48f115439a2..06942019d6530 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/app.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useCore.tsx @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface AppPermissions { - hasPermission: boolean; - missingClusterPrivileges: string[]; - missingIndexPrivileges: string[]; +import { useContext } from 'react'; +import { CoreContext } from '../context/CoreContext'; + +export function useCore() { + return useContext(CoreContext); } diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index 0ff770e25707b..6927d3359cd84 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useContext, useEffect, useState, useMemo } from 'react'; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { toastNotifications } from 'ui/notify'; +import { idx } from '@kbn/elastic-idx/target'; +import { i18n } from '@kbn/i18n'; import { LoadingIndicatorContext } from '../context/LoadingIndicatorContext'; import { useComponentId } from './useComponentId'; +import { KFetchError } from '../../../../../../src/legacy/ui/public/kfetch/kfetch_error'; export enum FETCH_STATUS { LOADING = 'loading', @@ -16,7 +20,7 @@ export enum FETCH_STATUS { export function useFetcher( fn: () => Promise | undefined, - effectKey: any[], + fnDeps: any[], options: { preservePreviousResponse?: boolean } = {} ) { const { preservePreviousResponse = true } = options; @@ -40,11 +44,11 @@ export function useFetcher( dispatchStatus({ id, isLoading: true }); - setResult({ - data: preservePreviousResponse ? result.data : undefined, // preserve data from previous state while loading next state + setResult(prevResult => ({ + data: preservePreviousResponse ? prevResult.data : undefined, // preserve data from previous state while loading next state status: FETCH_STATUS.LOADING, error: undefined - }); + })); try { const data = await promise; @@ -57,7 +61,30 @@ export function useFetcher( }); } } catch (e) { + const err = e as KFetchError; if (!didCancel) { + toastNotifications.addWarning({ + title: i18n.translate('xpack.apm.fetcher.error.title', { + defaultMessage: `Error while fetching resource` + }), + text: ( +
+
+ {i18n.translate('xpack.apm.fetcher.error.status', { + defaultMessage: `Error` + })} +
+ {idx(err.res, r => r.statusText)} ({idx(err.res, r => r.status)} + ) +
+ {i18n.translate('xpack.apm.fetcher.error.url', { + defaultMessage: `URL` + })} +
+ {idx(err.res, r => r.url)} +
+ ) + }); dispatchStatus({ id, isLoading: false }); setResult({ data: undefined, @@ -74,14 +101,22 @@ export function useFetcher( dispatchStatus({ id, isLoading: false }); didCancel = true; }; - }, [...effectKey, counter]); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ + counter, + id, + preservePreviousResponse, + dispatchStatus, + ...fnDeps + /* eslint-enable react-hooks/exhaustive-deps */ + ]); return useMemo( () => ({ ...result, refresh: () => { - // this will invalidate the effectKey and will result in a new request - setCounter(counter + 1); + // this will invalidate the deps to `useEffect` and will result in a new request + setCounter(count => count + 1); } }), [result] diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts index cc4be9c519ce9..0b1af3372b22a 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceMetricCharts.ts @@ -16,7 +16,7 @@ const INITIAL_DATA: MetricsChartsByAgentAPIResponse = { export function useServiceMetricCharts( urlParams: IUrlParams, - agentName?: string + agentName: string ) { const { serviceName, start, end } = urlParams; const uiFilters = useUiFilters(urlParams); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx b/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx new file mode 100644 index 0000000000000..8a144bb178b6f --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/hooks/useServiceTransactionTypes.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loadServiceTransactionTypes } from '../services/rest/apm/services'; +import { IUrlParams } from '../context/UrlParamsContext/types'; +import { useFetcher } from './useFetcher'; + +export function useServiceTransactionTypes(urlParams: IUrlParams) { + const { serviceName, start, end } = urlParams; + const { data: transactionTypes = [] } = useFetcher(() => { + if (serviceName && start && end) { + return loadServiceTransactionTypes({ serviceName, start, end }); + } + }, [serviceName, start, end]); + + return transactionTypes; +} diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts index 9d6befabbeadf..22bfb1a1bc233 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionBreakdown.ts @@ -30,7 +30,7 @@ export function useTransactionBreakdown() { uiFilters }); } - }, [serviceName, start, end, uiFilters]); + }, [serviceName, start, end, transactionType, transactionName, uiFilters]); const receivedDataDuringLifetime = useRef(false); diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts index c684d8d4c7564..629f6bb60e1f8 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionCharts.ts @@ -31,7 +31,7 @@ export function useTransactionCharts() { const memoizedData = useMemo( () => getTransactionCharts({ transactionType }, data), - [data] + [data, transactionType] ); return { diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts index 0d5a1ffe3477b..500595dbf44b1 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -12,8 +12,7 @@ import { useUiFilters } from '../context/UrlParamsContext'; const INITIAL_DATA = { buckets: [], totalHits: 0, - bucketSize: 0, - defaultSample: undefined + bucketSize: 0 }; export function useTransactionDistribution(urlParams: IUrlParams) { diff --git a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts index cda9213cf6190..fc3a828dfdf77 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/legacy/plugins/apm/public/hooks/useTransactionList.ts @@ -5,11 +5,11 @@ */ import { useMemo } from 'react'; -import { TransactionListAPIResponse } from '../../server/lib/transactions/get_top_transactions'; import { loadTransactionList } from '../services/rest/apm/transaction_groups'; import { IUrlParams } from '../context/UrlParamsContext/types'; import { useUiFilters } from '../context/UrlParamsContext'; import { useFetcher } from './useFetcher'; +import { TransactionGroupListAPIResponse } from '../../server/lib/transaction_groups'; const getRelativeImpact = ( impact: number, @@ -21,7 +21,7 @@ const getRelativeImpact = ( 1 ); -function getWithRelativeImpact(items: TransactionListAPIResponse) { +function getWithRelativeImpact(items: TransactionGroupListAPIResponse) { const impacts = items .map(({ impact }) => impact) .filter(impact => impact !== null) as number[]; diff --git a/x-pack/legacy/plugins/apm/public/index.tsx b/x-pack/legacy/plugins/apm/public/index.tsx index 86b9a5f69a8ad..20d111333e4e1 100644 --- a/x-pack/legacy/plugins/apm/public/index.tsx +++ b/x-pack/legacy/plugins/apm/public/index.tsx @@ -6,12 +6,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { npStart } from 'ui/new_platform'; import 'react-vis/dist/style.css'; -import { CoreStart } from 'src/core/public'; import 'ui/autoload/all'; import 'ui/autoload/styles'; import chrome from 'ui/chrome'; -import { I18nContext } from 'ui/i18n'; // @ts-ignore import { uiModules } from 'ui/modules'; import 'uiExports/autocompleteProviders'; @@ -20,10 +19,18 @@ import { plugin } from './new-platform'; import { REACT_APP_ROOT_ID } from './new-platform/plugin'; import './style/global_overrides.css'; import template from './templates/index.html'; +import { CoreProvider } from './context/CoreContext'; + +const { core } = npStart; // render APM feedback link in global help menu -chrome.helpExtension.set(domElement => { - ReactDOM.render(, domElement); +core.chrome.setHelpExtension(domElement => { + ReactDOM.render( + + + , + domElement + ); return () => { ReactDOM.unmountComponentAtNode(domElement); }; @@ -42,12 +49,6 @@ const checkForRoot = () => { } }); }; - checkForRoot().then(() => { - const core = { - i18n: { - Context: I18nContext - } - } as CoreStart; plugin().start(core); }); diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index 64f240f3dc4f7..abd793245cbb6 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -8,8 +8,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Router, Route, Switch } from 'react-router-dom'; import styled from 'styled-components'; -import { CoreStart } from 'src/core/public'; +import { InternalCoreStart } from 'src/core/public'; import { history } from '../utils/history'; +import { CoreProvider } from '../context/CoreContext'; import { LocationProvider } from '../context/LocationContext'; import { UrlParamsProvider } from '../context/UrlParamsContext'; import { px, unit, units } from '../style/variables'; @@ -53,16 +54,18 @@ const App = () => { }; export class Plugin { - public start(core: CoreStart) { + public start(core: InternalCoreStart) { const { i18n } = core; ReactDOM.render( - - - - - - - , + + + + + + + + + , document.getElementById(REACT_APP_ROOT_ID) ); } diff --git a/x-pack/legacy/plugins/apm/public/register_feature.js b/x-pack/legacy/plugins/apm/public/register_feature.js index 0e27d1427f691..8994fac17e914 100644 --- a/x-pack/legacy/plugins/apm/public/register_feature.js +++ b/x-pack/legacy/plugins/apm/public/register_feature.js @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { i18n } from '@kbn/i18n'; import { FeatureCatalogueRegistryProvider, FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; -if (chrome.getInjected('apmUiEnabled')) { +const { core } = npStart; +const apmUiEnabled = core.injectedMetadata.getInjectedVar('apmUiEnabled'); + +if (apmUiEnabled) { FeatureCatalogueRegistryProvider.register(() => { return { id: 'apm', diff --git a/x-pack/legacy/plugins/apm/public/services/kuery.ts b/x-pack/legacy/plugins/apm/public/services/kuery.ts deleted file mode 100644 index 6e325599f7d83..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/kuery.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; -import { getAutocompleteProvider } from 'ui/autocomplete_providers'; -import { StaticIndexPattern } from 'ui/index_patterns'; -import { getFromSavedObject } from 'ui/index_patterns/static_utils'; -import { getAPMIndexPattern } from './rest/savedObjects'; - -export function convertKueryToEsQuery( - kuery: string, - indexPattern: StaticIndexPattern -) { - const ast = fromKueryExpression(kuery); - return toElasticsearchQuery(ast, indexPattern); -} - -export async function getSuggestions( - query: string, - selectionStart: number, - apmIndexPattern: StaticIndexPattern, - boolFilter: unknown -) { - const autocompleteProvider = getAutocompleteProvider('kuery'); - if (!autocompleteProvider) { - return []; - } - const config = { - get: () => true - }; - - const getAutocompleteSuggestions = autocompleteProvider({ - config, - indexPatterns: [apmIndexPattern], - boolFilter - }); - return getAutocompleteSuggestions({ - query, - selectionStart, - selectionEnd: selectionStart - }); -} - -export async function getAPMIndexPatternForKuery(): Promise< - StaticIndexPattern | undefined -> { - const apmIndexPattern = await getAPMIndexPattern(); - if (!apmIndexPattern) { - return; - } - return getFromSavedObject(apmIndexPattern); -} diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts index e4fca3c220647..2799e89070f35 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/error_groups.ts @@ -8,7 +8,6 @@ import { ErrorDistributionAPIResponse } from '../../../../server/lib/errors/dist import { ErrorGroupAPIResponse } from '../../../../server/lib/errors/get_error_group'; import { ErrorGroupListAPIResponse } from '../../../../server/lib/errors/get_error_groups'; import { callApi } from '../callApi'; -import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es'; import { UIFilters } from '../../../../typings/ui-filters'; export async function loadErrorGroupList({ @@ -33,7 +32,7 @@ export async function loadErrorGroupList({ end, sortField, sortDirection, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } @@ -56,7 +55,7 @@ export async function loadErrorGroupDetails({ query: { start, end, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } @@ -80,7 +79,7 @@ export async function loadErrorDistribution({ start, end, groupId: errorGroupId, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts index 393e844ab6245..a62f36478e084 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/metrics.ts @@ -6,7 +6,6 @@ import { MetricsChartsByAgentAPIResponse } from '../../../../server/lib/metrics/get_metrics_chart_data_by_agent'; import { callApi } from '../callApi'; -import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es'; import { UIFilters } from '../../../../typings/ui-filters'; export async function loadMetricsChartData({ @@ -28,7 +27,7 @@ export async function loadMetricsChartData({ start, end, agentName, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts index 538cdfa79fdb2..045993d4fbeae 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/services.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ServiceAPIResponse } from '../../../../server/lib/services/get_service'; +import { ServiceAgentNameAPIResponse } from '../../../../server/lib/services/get_service_agent_name'; import { ServiceListAPIResponse } from '../../../../server/lib/services/get_services'; import { callApi } from '../callApi'; -import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es'; import { UIFilters } from '../../../../typings/ui-filters'; +import { ServiceTransactionTypesAPIResponse } from '../../../../server/lib/services/get_service_transaction_types'; export async function loadServiceList({ start, @@ -24,28 +24,48 @@ export async function loadServiceList({ query: { start, end, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } -export async function loadServiceDetails({ +export async function loadServiceAgentName({ serviceName, start, - end, - uiFilters + end }: { serviceName: string; start: string; end: string; - uiFilters: UIFilters; }) { - return callApi({ - pathname: `/api/apm/services/${serviceName}`, + const { agentName } = await callApi({ + pathname: `/api/apm/services/${serviceName}/agent_name`, query: { start, - end, - uiFiltersES: await getUiFiltersES(uiFilters) + end + } + }); + + return agentName; +} + +export async function loadServiceTransactionTypes({ + serviceName, + start, + end +}: { + serviceName: string; + start: string; + end: string; +}) { + const { transactionTypes } = await callApi< + ServiceTransactionTypesAPIResponse + >({ + pathname: `/api/apm/services/${serviceName}/transaction_types`, + query: { + start, + end } }); + return transactionTypes; } diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts index d2c4710fa2eca..4bcb379550279 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/traces.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TraceListAPIResponse } from '../../../../server/lib/traces/get_top_traces'; import { TraceAPIResponse } from '../../../../server/lib/traces/get_trace'; import { callApi } from '../callApi'; -import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es'; import { UIFilters } from '../../../../typings/ui-filters'; +import { TransactionGroupListAPIResponse } from '../../../../server/lib/transaction_groups'; export async function loadTrace({ traceId, @@ -37,12 +36,12 @@ export async function loadTraceList({ end: string; uiFilters: UIFilters; }) { - return callApi({ + return callApi({ pathname: '/api/apm/traces', query: { start, end, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } diff --git a/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts b/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts index 7ae1b0b2ee981..9791487609ff6 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/apm/transaction_groups.ts @@ -6,11 +6,10 @@ import { TransactionBreakdownAPIResponse } from '../../../../server/lib/transactions/breakdown'; import { TimeSeriesAPIResponse } from '../../../../server/lib/transactions/charts'; -import { ITransactionDistributionAPIResponse } from '../../../../server/lib/transactions/distribution'; -import { TransactionListAPIResponse } from '../../../../server/lib/transactions/get_top_transactions'; +import { TransactionDistributionAPIResponse } from '../../../../server/lib/transactions/distribution'; import { callApi } from '../callApi'; -import { getUiFiltersES } from '../../ui_filters/get_ui_filters_es'; import { UIFilters } from '../../../../typings/ui-filters'; +import { TransactionGroupListAPIResponse } from '../../../../server/lib/transaction_groups'; export async function loadTransactionList({ serviceName, @@ -25,13 +24,13 @@ export async function loadTransactionList({ transactionType: string; uiFilters: UIFilters; }) { - return await callApi({ + return await callApi({ pathname: `/api/apm/services/${serviceName}/transaction_groups`, query: { start, end, transactionType, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } @@ -55,7 +54,7 @@ export async function loadTransactionDistribution({ traceId?: string; uiFilters: UIFilters; }) { - return callApi({ + return callApi({ pathname: `/api/apm/services/${serviceName}/transaction_groups/distribution`, query: { start, @@ -64,7 +63,7 @@ export async function loadTransactionDistribution({ transactionName, transactionId, traceId, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } @@ -91,7 +90,7 @@ export async function loadTransactionCharts({ end, transactionType, transactionName, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } @@ -118,7 +117,7 @@ export async function loadTransactionBreakdown({ end, transactionName, transactionType, - uiFiltersES: await getUiFiltersES(uiFilters) + uiFilters: JSON.stringify(uiFilters) } }); } diff --git a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts index 6ce6ee36fdda1..c327a3bb0e5c0 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/ml.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { npStart } from 'ui/new_platform'; import { ESFilter } from 'elasticsearch'; -import chrome from 'ui/chrome'; import { PROCESSOR_EVENT, SERVICE_NAME, @@ -31,6 +31,8 @@ interface StartedMLJobApiResponse { jobs: MlResponseItem[]; } +const { core } = npStart; + export async function startMLJob({ serviceName, transactionType @@ -38,7 +40,9 @@ export async function startMLJob({ serviceName: string; transactionType: string; }) { - const indexPatternName = chrome.getInjected('apmIndexPatternTitle'); + const indexPatternName = core.injectedMetadata.getInjectedVar( + 'apmIndexPatternTitle' + ); const groups = ['apm', serviceName.toLowerCase()]; const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, diff --git a/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts b/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts index 7a80918eabacd..ec0dfc12a7522 100644 --- a/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts +++ b/x-pack/legacy/plugins/apm/public/services/rest/savedObjects.ts @@ -10,6 +10,7 @@ import { callApi } from './callApi'; export interface ISavedObject { attributes: { title: string; + fields: string; }; id: string; type: string; diff --git a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_kuery_ui_filter_es.ts b/x-pack/legacy/plugins/apm/public/services/ui_filters/get_kuery_ui_filter_es.ts deleted file mode 100644 index f2b07cfd65238..0000000000000 --- a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_kuery_ui_filter_es.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ESFilter } from 'elasticsearch'; -import { convertKueryToEsQuery, getAPMIndexPatternForKuery } from '../kuery'; - -export async function getKueryUiFilterES( - kuery?: string -): Promise { - if (!kuery) { - return; - } - - const indexPattern = await getAPMIndexPatternForKuery(); - if (!indexPattern) { - return; - } - - return convertKueryToEsQuery(kuery, indexPattern) as ESFilter; -} diff --git a/x-pack/legacy/plugins/apm/public/utils/__test__/formatters.test.ts b/x-pack/legacy/plugins/apm/public/utils/__test__/formatters.test.ts index 678ab9009edd7..093624240565f 100644 --- a/x-pack/legacy/plugins/apm/public/utils/__test__/formatters.test.ts +++ b/x-pack/legacy/plugins/apm/public/utils/__test__/formatters.test.ts @@ -22,6 +22,8 @@ describe('formatters', () => { expect(asTime(1000 * 1000)).toEqual('1,000 ms'); expect(asTime(1000 * 1000 * 10)).toEqual('10,000 ms'); expect(asTime(1000 * 1000 * 20)).toEqual('20.0 s'); + expect(asTime(60000000 * 10)).toEqual('10.0 min'); + expect(asTime(3600000000 * 1.5)).toEqual('1.5 h'); }); it('formats without unit', () => { diff --git a/x-pack/legacy/plugins/apm/public/utils/formatters.ts b/x-pack/legacy/plugins/apm/public/utils/formatters.ts index 60712bc761581..fa144d2f64276 100644 --- a/x-pack/legacy/plugins/apm/public/utils/formatters.ts +++ b/x-pack/legacy/plugins/apm/public/utils/formatters.ts @@ -9,6 +9,8 @@ import { i18n } from '@kbn/i18n'; import { memoize } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../common/i18n'; +const HOURS_CUT_OFF = 3600000000; // 1 hour (in microseconds) +const MINUTES_CUT_OFF = 60000000; // 1 minute (in microseconds) const SECONDS_CUT_OFF = 10 * 1000000; // 10 seconds (in microseconds) const MILLISECONDS_CUT_OFF = 10 * 1000; // 10 milliseconds (in microseconds) const SPACE = ' '; @@ -24,6 +26,38 @@ interface FormatterOptions { defaultValue?: string; } +export function asHours( + value: FormatterValue, + { withUnit = true, defaultValue = NOT_AVAILABLE_LABEL }: FormatterOptions = {} +) { + if (value == null) { + return defaultValue; + } + const hoursLabel = + SPACE + + i18n.translate('xpack.apm.formatters.hoursTimeUnitLabel', { + defaultMessage: 'h' + }); + const formatted = asDecimal(value / 3600000000); + return `${formatted}${withUnit ? hoursLabel : ''}`; +} + +export function asMinutes( + value: FormatterValue, + { withUnit = true, defaultValue = NOT_AVAILABLE_LABEL }: FormatterOptions = {} +) { + if (value == null) { + return defaultValue; + } + const minutesLabel = + SPACE + + i18n.translate('xpack.apm.formatters.minutesTimeUnitLabel', { + defaultMessage: 'min' + }); + const formatted = asDecimal(value / 60000000); + return `${formatted}${withUnit ? minutesLabel : ''}`; +} + export function asSeconds( value: FormatterValue, { withUnit = true, defaultValue = NOT_AVAILABLE_LABEL }: FormatterOptions = {} @@ -81,6 +115,10 @@ type TimeFormatter = ( export const getTimeFormatter: TimeFormatter = memoize((max: number) => { const unit = timeUnit(max); switch (unit) { + case 'h': + return asHours; + case 'm': + return asMinutes; case 's': return asSeconds; case 'ms': @@ -91,7 +129,11 @@ export const getTimeFormatter: TimeFormatter = memoize((max: number) => { }); export function timeUnit(max: number) { - if (max > SECONDS_CUT_OFF) { + if (max > HOURS_CUT_OFF) { + return 'h'; + } else if (max > MINUTES_CUT_OFF) { + return 'm'; + } else if (max > SECONDS_CUT_OFF) { return 's'; } else if (max > MILLISECONDS_CUT_OFF) { return 'ms'; diff --git a/x-pack/legacy/plugins/apm/public/services/ui_filters/__test__/get_environment_ui_filter_es.test.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts similarity index 86% rename from x-pack/legacy/plugins/apm/public/services/ui_filters/__test__/get_environment_ui_filter_es.test.ts rename to x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts index fa026d5505cae..df471af4f5ee0 100644 --- a/x-pack/legacy/plugins/apm/public/services/ui_filters/__test__/get_environment_ui_filter_es.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts @@ -6,8 +6,8 @@ import { ESFilter } from 'elasticsearch'; import { getEnvironmentUiFilterES } from '../get_environment_ui_filter_es'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; -import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; describe('getEnvironmentUiFilterES', () => { it('should return undefined, when environment is undefined', () => { diff --git a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_environment_ui_filter_es.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts similarity index 78% rename from x-pack/legacy/plugins/apm/public/services/ui_filters/get_environment_ui_filter_es.ts rename to x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts index 0049437ab012a..8a94e42a40b21 100644 --- a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_environment_ui_filter_es.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts @@ -5,8 +5,8 @@ */ import { ESFilter } from 'elasticsearch'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; -import { SERVICE_ENVIRONMENT } from '../../../common/elasticsearch_fieldnames'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; +import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; export function getEnvironmentUiFilterES( environment?: string diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_kuery_ui_filter_es.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_kuery_ui_filter_es.ts new file mode 100644 index 0000000000000..38e29d74e249a --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_kuery_ui_filter_es.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESFilter } from 'elasticsearch'; +import { Server } from 'hapi'; +import { idx } from '@kbn/elastic-idx/target'; +import { toElasticsearchQuery, fromKueryExpression } from '@kbn/es-query'; +import { ISavedObject } from '../../../../public/services/rest/savedObjects'; +import { StaticIndexPattern } from '../../../../../../../../src/legacy/core_plugins/data/public'; +import { getAPMIndexPattern } from '../../../lib/index_pattern'; + +export async function getKueryUiFilterES( + server: Server, + kuery?: string +): Promise { + if (!kuery) { + return; + } + + const apmIndexPattern = await getAPMIndexPattern(server); + const formattedIndexPattern = getFromSavedObject(apmIndexPattern); + + if (!formattedIndexPattern) { + return; + } + + return convertKueryToEsQuery(kuery, formattedIndexPattern) as ESFilter; +} + +// lifted from src/legacy/ui/public/index_patterns/static_utils/index.js +export function getFromSavedObject(apmIndexPattern: ISavedObject) { + if (idx(apmIndexPattern, _ => _.attributes.fields) === undefined) { + return; + } + + return { + id: apmIndexPattern.id, + fields: JSON.parse(apmIndexPattern.attributes.fields), + title: apmIndexPattern.attributes.title + }; +} + +function convertKueryToEsQuery( + kuery: string, + indexPattern: StaticIndexPattern +) { + const ast = fromKueryExpression(kuery); + return toElasticsearchQuery(ast, indexPattern); +} diff --git a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_ui_filters_es.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts similarity index 60% rename from x-pack/legacy/plugins/apm/public/services/ui_filters/get_ui_filters_es.ts rename to x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts index 44944736514a5..8a27799cdfe68 100644 --- a/x-pack/legacy/plugins/apm/public/services/ui_filters/get_ui_filters_es.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UIFilters } from '../../../typings/ui-filters'; +import { Server } from 'hapi'; +import { ESFilter } from 'elasticsearch'; +import { UIFilters } from '../../../../typings/ui-filters'; import { getEnvironmentUiFilterES } from './get_environment_ui_filter_es'; import { getKueryUiFilterES } from './get_kuery_ui_filter_es'; -export async function getUiFiltersES(uiFilters: UIFilters): Promise { - const kuery = await getKueryUiFilterES(uiFilters.kuery); +export async function getUiFiltersES(server: Server, uiFilters: UIFilters) { + const kuery = await getKueryUiFilterES(server, uiFilters.kuery); const environment = getEnvironmentUiFilterES(uiFilters.environment); - const filters = [kuery, environment].filter(filter => !!filter); - - return encodeURIComponent(JSON.stringify(filters)); + // remove undefined items from list + const filters = [kuery, environment].filter(filter => !!filter) as ESFilter[]; + return filters; } diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index 420c7087c8032..896c558121992 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts @@ -15,7 +15,7 @@ import { import { Legacy } from 'kibana'; import { cloneDeep, has, isString, set } from 'lodash'; import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames'; -import { APMRequestQuery } from './setup_request'; +import { StringMap } from '../../../typings/common'; function getApmIndices(config: Legacy.KibanaConfig) { return [ @@ -87,7 +87,7 @@ interface APMOptions { export function getESClient(req: Legacy.Request) { const cluster = req.server.plugins.elasticsearch.getCluster('data'); - const query = (req.query as unknown) as APMRequestQuery; + const query = req.query as StringMap; return { search: async ( diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/input_validation.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/input_validation.ts index aa73dd8d035c5..983b569fc2929 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/input_validation.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/input_validation.ts @@ -16,7 +16,7 @@ export const withDefaultValidators = ( _debug: Joi.bool(), start: dateValidation, end: dateValidation, - uiFiltersES: Joi.string(), + uiFilters: Joi.string(), ...validators }); }; diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts index 2dc686c896be2..bd45cec316dfc 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -29,7 +29,7 @@ function getMockRequest() { describe('setupRequest', () => { it('should call callWithRequest with default args', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({ index: 'apm-*', body: { foo: 'bar' } }); expect(callWithRequestSpy).toHaveBeenCalledWith(mockRequest, 'search', { index: 'apm-*', @@ -50,7 +50,7 @@ describe('setupRequest', () => { describe('if index is apm-*', () => { it('should merge `observer.version_major` filter with existing boolean filters', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({ index: 'apm-*', body: { query: { bool: { filter: [{ term: 'someTerm' }] } } } @@ -70,7 +70,7 @@ describe('setupRequest', () => { it('should add `observer.version_major` filter if none exists', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({ index: 'apm-*' }); const params = callWithRequestSpy.mock.calls[0][2]; expect(params.body).toEqual({ @@ -84,7 +84,7 @@ describe('setupRequest', () => { it('should not add `observer.version_major` filter if `includeLegacyData=true`', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search( { index: 'apm-*', @@ -103,7 +103,7 @@ describe('setupRequest', () => { it('if index is not an APM index, it should not add `observer.version_major` filter', async () => { const { mockRequest, callWithRequestSpy } = getMockRequest(); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({ index: '.ml-*', body: { @@ -127,7 +127,7 @@ describe('setupRequest', () => { // mock includeFrozen to return false mockRequest.getUiSettingsService = () => ({ get: async () => false }); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({}); const params = callWithRequestSpy.mock.calls[0][2]; expect(params.ignore_throttled).toBe(true); @@ -138,7 +138,7 @@ describe('setupRequest', () => { // mock includeFrozen to return true mockRequest.getUiSettingsService = () => ({ get: async () => true }); - const { client } = setupRequest(mockRequest); + const { client } = await setupRequest(mockRequest); await client.search({}); const params = callWithRequestSpy.mock.calls[0][2]; expect(params.ignore_throttled).toBe(false); diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts index 20db3fc6bd526..4054537e04b40 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/setup_request.ts @@ -5,29 +5,37 @@ */ import { Legacy } from 'kibana'; +import { Server } from 'hapi'; import moment from 'moment'; import { getESClient } from './es_client'; +import { getUiFiltersES } from './convert_ui_filters/get_ui_filters_es'; +import { PromiseReturnType } from '../../../typings/common'; -function decodeUiFiltersES(esQuery?: string) { - return esQuery ? JSON.parse(decodeURIComponent(esQuery)) : null; +function decodeUiFilters(server: Server, uiFiltersEncoded?: string) { + if (!uiFiltersEncoded) { + return []; + } + const uiFilters = JSON.parse(uiFiltersEncoded); + return getUiFiltersES(server, uiFilters); } export interface APMRequestQuery { - _debug: string; - start: string; - end: string; - uiFiltersES?: string; + _debug?: string; + start?: string; + end?: string; + uiFilters?: string; } -export type Setup = ReturnType; -export function setupRequest(req: Legacy.Request) { +export type Setup = PromiseReturnType; +export async function setupRequest(req: Legacy.Request) { const query = (req.query as unknown) as APMRequestQuery; - const config = req.server.config(); + const { server } = req; + const config = server.config(); return { start: moment.utc(query.start).valueOf(), end: moment.utc(query.end).valueOf(), - uiFiltersES: decodeUiFiltersES(query.uiFiltersES) || [], + uiFiltersES: await decodeUiFilters(server, query.uiFilters), client: getESClient(req), config }; diff --git a/x-pack/legacy/plugins/apm/server/lib/index_pattern/index.ts b/x-pack/legacy/plugins/apm/server/lib/index_pattern/index.ts index aa9e27ed2442a..0b9407b288b1d 100644 --- a/x-pack/legacy/plugins/apm/server/lib/index_pattern/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/index_pattern/index.ts @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { InternalCoreSetup } from 'src/core/server'; +import { Server } from 'hapi'; import { getSavedObjectsClient } from '../helpers/saved_objects_client'; import apmIndexPattern from '../../../../../../../src/legacy/core_plugins/kibana/server/tutorials/apm/index_pattern.json'; -export async function getIndexPattern(core: InternalCoreSetup) { - const { server } = core.http; +export async function getAPMIndexPattern(server: Server) { const config = server.config(); const apmIndexPatternTitle = config.get('apm_oss.indexPattern'); const savedObjectsClient = getSavedObjectsClient(server); diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_service.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_service_agent_name.ts similarity index 59% rename from x-pack/legacy/plugins/apm/server/lib/services/get_service.ts rename to x-pack/legacy/plugins/apm/server/lib/services/get_service_agent_name.ts index d39397fedd154..ebe0ba9827b53 100644 --- a/x-pack/legacy/plugins/apm/server/lib/services/get_service.ts +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_service_agent_name.ts @@ -7,21 +7,24 @@ import { idx } from '@kbn/elastic-idx'; import { PROCESSOR_EVENT, SERVICE_AGENT_NAME, - SERVICE_NAME, - TRANSACTION_TYPE + SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; import { PromiseReturnType } from '../../../typings/common'; import { rangeFilter } from '../helpers/range_filter'; import { Setup } from '../helpers/setup_request'; -export type ServiceAPIResponse = PromiseReturnType; -export async function getService(serviceName: string, setup: Setup) { - const { start, end, uiFiltersES, client, config } = setup; +export type ServiceAgentNameAPIResponse = PromiseReturnType< + typeof getServiceAgentName +>; +export async function getServiceAgentName(serviceName: string, setup: Setup) { + const { start, end, client, config } = setup; const params = { + terminate_after: 1, index: [ config.get('apm_oss.errorIndices'), - config.get('apm_oss.transactionIndices') + config.get('apm_oss.transactionIndices'), + config.get('apm_oss.metricsIndices') ], body: { size: 0, @@ -29,16 +32,14 @@ export async function getService(serviceName: string, setup: Setup) { bool: { filter: [ { term: { [SERVICE_NAME]: serviceName } }, - { terms: { [PROCESSOR_EVENT]: ['error', 'transaction'] } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES + { + terms: { [PROCESSOR_EVENT]: ['error', 'transaction', 'metric'] } + }, + { range: rangeFilter(start, end) } ] } }, aggs: { - types: { - terms: { field: TRANSACTION_TYPE, size: 100 } - }, agents: { terms: { field: SERVICE_AGENT_NAME, size: 1 } } @@ -47,13 +48,6 @@ export async function getService(serviceName: string, setup: Setup) { }; const { aggregations } = await client.search(params); - const buckets = idx(aggregations, _ => _.types.buckets) || []; - const types = buckets.map(bucket => bucket.key); - const agentName = idx(aggregations, _ => _.agents.buckets[0].key) || ''; - - return { - serviceName, - types, - agentName - }; + const agentName = idx(aggregations, _ => _.agents.buckets[0].key); + return { agentName }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/services/get_service_transaction_types.ts b/x-pack/legacy/plugins/apm/server/lib/services/get_service_transaction_types.ts new file mode 100644 index 0000000000000..c8053c57776db --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/services/get_service_transaction_types.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { idx } from '@kbn/elastic-idx'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_TYPE +} from '../../../common/elasticsearch_fieldnames'; +import { PromiseReturnType } from '../../../typings/common'; +import { rangeFilter } from '../helpers/range_filter'; +import { Setup } from '../helpers/setup_request'; + +export type ServiceTransactionTypesAPIResponse = PromiseReturnType< + typeof getServiceTransactionTypes +>; +export async function getServiceTransactionTypes( + serviceName: string, + setup: Setup +) { + const { start, end, client, config } = setup; + + const params = { + index: [config.get('apm_oss.transactionIndices')], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { terms: { [PROCESSOR_EVENT]: ['transaction'] } }, + { range: rangeFilter(start, end) } + ] + } + }, + aggs: { + types: { + terms: { field: TRANSACTION_TYPE, size: 100 } + } + } + } + }; + + const { aggregations } = await client.search(params); + const buckets = idx(aggregations, _ => _.types.buckets) || []; + const transactionTypes = buckets.map(bucket => bucket.key); + return { transactionTypes }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/traces/get_top_traces.ts b/x-pack/legacy/plugins/apm/server/lib/traces/get_top_traces.ts deleted file mode 100644 index 17a8b11759726..0000000000000 --- a/x-pack/legacy/plugins/apm/server/lib/traces/get_top_traces.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - PARENT_ID, - PROCESSOR_EVENT, - TRANSACTION_SAMPLED -} from '../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType } from '../../../typings/common'; -import { rangeFilter } from '../helpers/range_filter'; -import { Setup } from '../helpers/setup_request'; -import { getTransactionGroups } from '../transaction_groups'; - -export type TraceListAPIResponse = PromiseReturnType; -export async function getTopTraces(setup: Setup) { - const { start, end, uiFiltersES } = setup; - - const bodyQuery = { - bool: { - // no parent ID means this transaction is a "root" transaction, i.e. a trace - must_not: { exists: { field: PARENT_ID } }, - filter: [ - { range: rangeFilter(start, end) }, - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - ...uiFiltersES - ], - should: [{ term: { [TRANSACTION_SAMPLED]: true } }] - } - }; - - return getTransactionGroups(setup, bodyQuery); -} diff --git a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap index e11d0e90db0db..c55e54938aaba 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap +++ b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/__snapshots__/fetcher.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`transactionGroupsFetcher should call client with correct query 1`] = ` +exports[`transactionGroupsFetcher type: top_traces should call client.search with correct query 1`] = ` Array [ Array [ Object { @@ -52,7 +52,145 @@ Array [ }, }, "query": Object { - "my": "bodyQuery", + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "processor.event": "transaction", + }, + }, + Object { + "term": Object { + "service.environment": "test", + }, + }, + ], + "must_not": Array [ + Object { + "exists": Object { + "field": "parent.id", + }, + }, + ], + "should": Array [ + Object { + "term": Object { + "transaction.sampled": true, + }, + }, + ], + }, + }, + "size": 0, + }, + "index": "myIndex", + }, + ], +] +`; + +exports[`transactionGroupsFetcher type: top_transactions should call client.search with correct query 1`] = ` +Array [ + Array [ + Object { + "body": Object { + "aggs": Object { + "transactions": Object { + "aggs": Object { + "avg": Object { + "avg": Object { + "field": "transaction.duration.us", + }, + }, + "p95": Object { + "percentiles": Object { + "field": "transaction.duration.us", + "percents": Array [ + 95, + ], + }, + }, + "sample": Object { + "top_hits": Object { + "size": 1, + "sort": Array [ + Object { + "_score": "desc", + }, + Object { + "@timestamp": Object { + "order": "desc", + }, + }, + ], + }, + }, + "sum": Object { + "sum": Object { + "field": "transaction.duration.us", + }, + }, + }, + "terms": Object { + "field": "transaction.name", + "order": Object { + "sum": "desc", + }, + "size": 100, + }, + }, + }, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, + Object { + "term": Object { + "processor.event": "transaction", + }, + }, + Object { + "term": Object { + "service.environment": "test", + }, + }, + Object { + "term": Object { + "service.name": "opbeans-node", + }, + }, + Object { + "term": Object { + "transaction.type": "request", + }, + }, + ], + "must_not": Array [], + "should": Array [ + Object { + "term": Object { + "transaction.sampled": true, + }, + }, + ], + }, }, "size": 0, }, diff --git a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.test.ts b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.test.ts index 0f21b3b0206d5..fc7b1b4127892 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.test.ts @@ -4,42 +4,51 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESResponse, transactionGroupsFetcher } from './fetcher'; +import { transactionGroupsFetcher } from './fetcher'; -describe('transactionGroupsFetcher', () => { - let res: ESResponse; - let clientSpy: jest.Mock; - beforeEach(async () => { - clientSpy = jest.fn().mockResolvedValue('ES response'); - - const setup = { - start: 1528113600000, - end: 1528977600000, - client: { - search: clientSpy - } as any, - config: { - get: jest.fn((key: string) => { - switch (key) { - case 'apm_oss.transactionIndices': - return 'myIndex'; - case 'xpack.apm.ui.transactionGroupBucketSize': - return 100; - } - }), - has: () => true - }, - uiFiltersES: [{ term: { 'service.environment': 'test' } }] - }; - const bodyQuery = { my: 'bodyQuery' }; - res = await transactionGroupsFetcher(setup, bodyQuery); - }); +function getSetup() { + return { + start: 1528113600000, + end: 1528977600000, + client: { + search: jest.fn() + } as any, + config: { + get: jest.fn((key: string) => { + switch (key) { + case 'apm_oss.transactionIndices': + return 'myIndex'; + case 'xpack.apm.ui.transactionGroupBucketSize': + return 100; + } + }), + has: () => true + }, + uiFiltersES: [{ term: { 'service.environment': 'test' } }] + }; +} - it('should call client with correct query', () => { - expect(clientSpy.mock.calls).toMatchSnapshot(); +describe('transactionGroupsFetcher', () => { + describe('type: top_traces', () => { + it('should call client.search with correct query', async () => { + const setup = getSetup(); + await transactionGroupsFetcher({ type: 'top_traces' }, setup); + expect(setup.client.search.mock.calls).toMatchSnapshot(); + }); }); - it('should return correct response', () => { - expect(res).toBe('ES response'); + describe('type: top_transactions', () => { + it('should call client.search with correct query', async () => { + const setup = getSetup(); + await transactionGroupsFetcher( + { + type: 'top_transactions', + serviceName: 'opbeans-node', + transactionType: 'request' + }, + setup + ); + expect(setup.client.search.mock.calls).toMatchSnapshot(); + }); }); }); diff --git a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.ts index b909d5ff62a74..3b32776739c81 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -6,19 +6,60 @@ import { TRANSACTION_DURATION, - TRANSACTION_NAME + TRANSACTION_NAME, + PROCESSOR_EVENT, + PARENT_ID, + TRANSACTION_SAMPLED, + SERVICE_NAME, + TRANSACTION_TYPE } from '../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType, StringMap } from '../../../typings/common'; +import { PromiseReturnType } from '../../../typings/common'; import { Setup } from '../helpers/setup_request'; +import { rangeFilter } from '../helpers/range_filter'; +import { BoolQuery } from '../../../typings/elasticsearch'; + +interface TopTransactionOptions { + type: 'top_transactions'; + serviceName: string; + transactionType: string; +} + +interface TopTraceOptions { + type: 'top_traces'; +} + +export type Options = TopTransactionOptions | TopTraceOptions; export type ESResponse = PromiseReturnType; -export function transactionGroupsFetcher(setup: Setup, bodyQuery: StringMap) { - const { client, config } = setup; +export function transactionGroupsFetcher(options: Options, setup: Setup) { + const { client, config, start, end, uiFiltersES } = setup; + + const bool: BoolQuery = { + must_not: [], + // prefer sampled transactions + should: [{ term: { [TRANSACTION_SAMPLED]: true } }], + filter: [ + { range: rangeFilter(start, end) }, + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + ...uiFiltersES + ] + }; + + if (options.type === 'top_traces') { + // A transaction without `parent.id` is considered a "root" transaction, i.e. a trace + bool.must_not.push({ exists: { field: PARENT_ID } }); + } else { + bool.filter.push({ term: { [SERVICE_NAME]: options.serviceName } }); + bool.filter.push({ term: { [TRANSACTION_TYPE]: options.transactionType } }); + } + const params = { index: config.get('apm_oss.transactionIndices'), body: { size: 0, - query: bodyQuery, + query: { + bool + }, aggs: { transactions: { terms: { diff --git a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/index.ts b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/index.ts index 934495ea83c25..73e30f28c4206 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transaction_groups/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transaction_groups/index.ts @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { StringMap } from '../../../typings/common'; import { Setup } from '../helpers/setup_request'; -import { transactionGroupsFetcher } from './fetcher'; +import { transactionGroupsFetcher, Options } from './fetcher'; import { transactionGroupsTransformer } from './transform'; +import { PromiseReturnType } from '../../../typings/common'; -export async function getTransactionGroups(setup: Setup, bodyQuery: StringMap) { +export type TransactionGroupListAPIResponse = PromiseReturnType< + typeof getTransactionGroupList +>; +export async function getTransactionGroupList(options: Options, setup: Setup) { const { start, end } = setup; - const response = await transactionGroupsFetcher(setup, bodyQuery); + const response = await transactionGroupsFetcher(options, setup); return transactionGroupsTransformer({ response, start, diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.ts index 3f09aa55e3f0e..bebe208243604 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -79,26 +79,14 @@ export async function getTransactionBreakdown({ }; const filters = [ - { - term: { - [SERVICE_NAME]: { - value: serviceName - } - } - }, - { - term: { - [TRANSACTION_TYPE]: { - value: transactionType - } - } - }, + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, { range: rangeFilter(start, end) }, ...uiFiltersES ]; if (transactionName) { - filters.push({ term: { [TRANSACTION_NAME]: { value: transactionName } } }); + filters.push({ term: { [TRANSACTION_NAME]: transactionName } }); } const params = { @@ -107,7 +95,7 @@ export async function getTransactionBreakdown({ size: 0, query: { bool: { - must: filters + filter: filters } }, aggs: { diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts index 79ae39c693b77..07f1d96618198 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.test.ts @@ -20,6 +20,7 @@ describe('getAnomalySeries', () => { avgAnomalies = await getAnomalySeries({ serviceName: 'myServiceName', transactionType: 'myTransactionType', + transactionName: undefined, timeSeriesDates: [100, 100000], setup: { start: 0, diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts index 7b5b77e2a2ddc..b55d264cfbbfe 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts @@ -18,8 +18,8 @@ export async function getAnomalySeries({ setup }: { serviceName: string; - transactionType?: string; - transactionName?: string; + transactionType: string | undefined; + transactionName: string | undefined; timeSeriesDates: number[]; setup: Setup; }) { diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts index 9ce664fa21e79..dc5cb925b7202 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/fetcher.ts @@ -26,8 +26,8 @@ export function timeseriesFetcher({ setup }: { serviceName: string; - transactionType?: string; - transactionName?: string; + transactionType: string | undefined; + transactionName: string | undefined; setup: Setup; }) { const { start, end, uiFiltersES, client, config } = setup; diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts index e2a292d1bf12b..6c18ab84cdfab 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/get_timeseries_data/index.ts @@ -11,8 +11,8 @@ import { timeseriesTransformer } from './transform'; export async function getApmTimeseriesData(options: { serviceName: string; - transactionType?: string; - transactionName?: string; + transactionType: string | undefined; + transactionName: string | undefined; setup: Setup; }) { const { start, end } = options.setup; diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/index.ts index f1ded4dbaffab..c297f3a050f0c 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/charts/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/charts/index.ts @@ -19,8 +19,8 @@ export type TimeSeriesAPIResponse = PromiseReturnType< >; export async function getTransactionCharts(options: { serviceName: string; - transactionType?: string; - transactionName?: string; + transactionType: string | undefined; + transactionName: string | undefined; setup: Setup; }) { const apmTimeseries = await getApmTimeseriesData(options); diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts index 44ee59dcaa7c4..09992f08d0563 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/get_buckets/transform.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; import { idx } from '@kbn/elastic-idx'; import { PromiseReturnType } from '../../../../../typings/common'; import { Transaction } from '../../../../../typings/es_schemas/ui/Transaction'; @@ -12,19 +11,6 @@ import { bucketFetcher } from './fetcher'; type DistributionBucketResponse = PromiseReturnType; -function getDefaultSample(buckets: IBucket[]) { - const samples = buckets - .filter(bucket => bucket.count > 0 && bucket.sample) - .map(bucket => bucket.sample); - - if (isEmpty(samples)) { - return; - } - - const middleIndex = Math.floor(samples.length / 2); - return samples[middleIndex]; -} - export type IBucket = ReturnType; function getBucket( bucket: DistributionBucketResponse['aggregations']['distribution']['buckets'][0] @@ -50,7 +36,6 @@ export function bucketTransformer(response: DistributionBucketResponse) { return { totalHits: response.hits.total, - buckets, - defaultSample: getDefaultSample(buckets) + buckets }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts index e02d22d0d5301..719b0fb2dd984 100644 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/transactions/distribution/index.ts @@ -9,7 +9,7 @@ import { Setup } from '../../helpers/setup_request'; import { calculateBucketSize } from './calculate_bucket_size'; import { getBuckets } from './get_buckets'; -export type ITransactionDistributionAPIResponse = PromiseReturnType< +export type TransactionDistributionAPIResponse = PromiseReturnType< typeof getTransactionDistribution >; export async function getTransactionDistribution({ @@ -34,7 +34,7 @@ export async function getTransactionDistribution({ setup ); - const { defaultSample, buckets, totalHits } = await getBuckets( + const { buckets, totalHits } = await getBuckets( serviceName, transactionName, transactionType, @@ -47,7 +47,6 @@ export async function getTransactionDistribution({ return { totalHits, buckets, - bucketSize, - defaultSample + bucketSize }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/transactions/get_top_transactions/index.ts b/x-pack/legacy/plugins/apm/server/lib/transactions/get_top_transactions/index.ts deleted file mode 100644 index f2b15ed924cf6..0000000000000 --- a/x-pack/legacy/plugins/apm/server/lib/transactions/get_top_transactions/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_TYPE -} from '../../../../common/elasticsearch_fieldnames'; -import { PromiseReturnType } from '../../../../typings/common'; -import { rangeFilter } from '../../helpers/range_filter'; -import { Setup } from '../../helpers/setup_request'; -import { getTransactionGroups } from '../../transaction_groups'; - -export interface IOptions { - setup: Setup; - transactionType?: string; - serviceName: string; -} - -export type TransactionListAPIResponse = PromiseReturnType< - typeof getTopTransactions ->; -export async function getTopTransactions({ - setup, - transactionType, - serviceName -}: IOptions) { - const { start, end, uiFiltersES } = setup; - - const bodyQuery = { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES - ] - } - }; - - if (transactionType) { - bodyQuery.bool.filter.push({ - term: { [TRANSACTION_TYPE]: transactionType } - }); - } - - return getTransactionGroups(setup, bodyQuery); -} diff --git a/x-pack/legacy/plugins/apm/server/routes/errors.ts b/x-pack/legacy/plugins/apm/server/routes/errors.ts index b4b7f3fdc1a8d..1afdca73299fd 100644 --- a/x-pack/legacy/plugins/apm/server/routes/errors.ts +++ b/x-pack/legacy/plugins/apm/server/routes/errors.ts @@ -33,8 +33,8 @@ export function initErrorsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; const { sortField, sortDirection } = req.query as { sortField: string; @@ -59,8 +59,8 @@ export function initErrorsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName, groupId } = req.params; return getErrorGroup({ serviceName, groupId, setup }).catch( defaultErrorHandler @@ -79,8 +79,8 @@ export function initErrorsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; const { groupId } = req.query as { groupId?: string }; return getErrorDistribution({ serviceName, groupId, setup }).catch( diff --git a/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts b/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts index 20ae3c3363663..12499b833173e 100644 --- a/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/legacy/plugins/apm/server/routes/index_pattern.ts @@ -6,9 +6,8 @@ import Boom from 'boom'; import { InternalCoreSetup } from 'src/core/server'; -import { getIndexPattern } from '../lib/index_pattern'; +import { getAPMIndexPattern } from '../lib/index_pattern'; -const ROOT = '/api/apm/index_pattern'; const defaultErrorHandler = (err: Error & { status?: number }) => { // eslint-disable-next-line console.error(err.stack); @@ -19,12 +18,12 @@ export function initIndexPatternApi(core: InternalCoreSetup) { const { server } = core.http; server.route({ method: 'GET', - path: ROOT, + path: '/api/apm/index_pattern', options: { tags: ['access:apm'] }, handler: async req => { - return await getIndexPattern(core).catch(defaultErrorHandler); + return await getAPMIndexPattern(server).catch(defaultErrorHandler); } }); } diff --git a/x-pack/legacy/plugins/apm/server/routes/metrics.ts b/x-pack/legacy/plugins/apm/server/routes/metrics.ts index 16cc8b8a652f2..55b6bdbae3b37 100644 --- a/x-pack/legacy/plugins/apm/server/routes/metrics.ts +++ b/x-pack/legacy/plugins/apm/server/routes/metrics.ts @@ -32,7 +32,7 @@ export function initMetricsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const { serviceName } = req.params; // casting approach recommended here: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/25605 const { agentName } = req.query as { agentName: string }; diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 063bf0556daf5..c6143f522f382 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -10,8 +10,9 @@ import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, storeApmTelemetry } from '../lib/apm_telemetry'; import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getService } from '../lib/services/get_service'; +import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; +import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; const ROOT = '/api/apm/services'; const defaultErrorHandler = (err: Error) => { @@ -32,7 +33,7 @@ export function initServicesApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const services = await getServices(setup).catch(defaultErrorHandler); // Store telemetry data derived from services @@ -48,17 +49,35 @@ export function initServicesApi(core: InternalCoreSetup) { server.route({ method: 'GET', - path: `${ROOT}/{serviceName}`, + path: `${ROOT}/{serviceName}/agent_name`, options: { validate: { query: withDefaultValidators() }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; - return getService(serviceName, setup).catch(defaultErrorHandler); + return getServiceAgentName(serviceName, setup).catch(defaultErrorHandler); + } + }); + + server.route({ + method: 'GET', + path: `${ROOT}/{serviceName}/transaction_types`, + options: { + validate: { + query: withDefaultValidators() + }, + tags: ['access:apm'] + }, + handler: async req => { + const setup = await setupRequest(req); + const { serviceName } = req.params; + return getServiceTransactionTypes(serviceName, setup).catch( + defaultErrorHandler + ); } }); } diff --git a/x-pack/legacy/plugins/apm/server/routes/settings.ts b/x-pack/legacy/plugins/apm/server/routes/settings.ts index 570583e05088c..c23ead9d4498c 100644 --- a/x-pack/legacy/plugins/apm/server/routes/settings.ts +++ b/x-pack/legacy/plugins/apm/server/routes/settings.ts @@ -42,7 +42,7 @@ export function initSettingsApi(core: InternalCoreSetup) { handler: async req => { await createApmAgentConfigurationIndex(server); - const setup = setupRequest(req); + const setup = await setupRequest(req); return await listConfigurations({ setup }).catch(defaultErrorHandler); @@ -62,7 +62,7 @@ export function initSettingsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const { configurationId } = req.params; return await deleteConfiguration({ configurationId, @@ -84,7 +84,7 @@ export function initSettingsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); return await getServiceNames({ setup }).catch(defaultErrorHandler); @@ -104,7 +104,7 @@ export function initSettingsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const { serviceName } = req.params; return await getEnvironments({ serviceName, @@ -142,7 +142,7 @@ export function initSettingsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const configuration = req.payload as AgentConfigurationIntake; return await createConfiguration({ configuration, @@ -165,7 +165,7 @@ export function initSettingsApi(core: InternalCoreSetup) { tags: ['access:apm'] }, handler: async req => { - const setup = setupRequest(req); + const setup = await setupRequest(req); const { configurationId } = req.params; const configuration = req.payload as AgentConfigurationIntake; return await updateConfiguration({ @@ -202,7 +202,7 @@ export function initSettingsApi(core: InternalCoreSetup) { await createApmAgentConfigurationIndex(server); - const setup = setupRequest(req); + const setup = await setupRequest(req); const payload = req.payload as Payload; const serviceName = payload.service.name; const environment = payload.service.environment; diff --git a/x-pack/legacy/plugins/apm/server/routes/traces.ts b/x-pack/legacy/plugins/apm/server/routes/traces.ts index a69361903e6bc..3b32179650e62 100644 --- a/x-pack/legacy/plugins/apm/server/routes/traces.ts +++ b/x-pack/legacy/plugins/apm/server/routes/traces.ts @@ -5,12 +5,11 @@ */ import Boom from 'boom'; - import { InternalCoreSetup } from 'src/core/server'; import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getTopTraces } from '../lib/traces/get_top_traces'; import { getTrace } from '../lib/traces/get_trace'; +import { getTransactionGroupList } from '../lib/transaction_groups'; const ROOT = '/api/apm/traces'; const defaultErrorHandler = (err: Error) => { @@ -32,10 +31,11 @@ export function initTracesApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); - - return getTopTraces(setup).catch(defaultErrorHandler); + handler: async req => { + const setup = await setupRequest(req); + return getTransactionGroupList({ type: 'top_traces' }, setup).catch( + defaultErrorHandler + ); } }); @@ -49,9 +49,9 @@ export function initTracesApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { + handler: async req => { const { traceId } = req.params; - const setup = setupRequest(req); + const setup = await setupRequest(req); return getTrace(traceId, setup).catch(defaultErrorHandler); } }); diff --git a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts index 0d93fd895c764..7fab69be1af6b 100644 --- a/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/legacy/plugins/apm/server/routes/transaction_groups.ts @@ -11,8 +11,8 @@ import { withDefaultValidators } from '../lib/helpers/input_validation'; import { setupRequest } from '../lib/helpers/setup_request'; import { getTransactionCharts } from '../lib/transactions/charts'; import { getTransactionDistribution } from '../lib/transactions/distribution'; -import { getTopTransactions } from '../lib/transactions/get_top_transactions'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; +import { getTransactionGroupList } from '../lib/transaction_groups'; const defaultErrorHandler = (err: Error) => { // eslint-disable-next-line @@ -34,16 +34,19 @@ export function initTransactionGroupsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { + handler: async req => { const { serviceName } = req.params; - const { transactionType } = req.query as { transactionType?: string }; - const setup = setupRequest(req); + const { transactionType } = req.query as { transactionType: string }; + const setup = await setupRequest(req); - return getTopTransactions({ - serviceName, - transactionType, + return getTransactionGroupList( + { + type: 'top_transactions', + serviceName, + transactionType + }, setup - }).catch(defaultErrorHandler); + ).catch(defaultErrorHandler); } }); @@ -59,8 +62,8 @@ export function initTransactionGroupsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; const { transactionType, transactionName } = req.query as { transactionType?: string; @@ -90,8 +93,8 @@ export function initTransactionGroupsApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; const { transactionType, @@ -127,8 +130,8 @@ export function initTransactionGroupsApi(core: InternalCoreSetup) { }) } }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.params; const { transactionName, transactionType } = req.query as { transactionName?: string; diff --git a/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts b/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts index 72c9e72286c08..9f6903c9840a6 100644 --- a/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts +++ b/x-pack/legacy/plugins/apm/server/routes/ui_filters.ts @@ -30,8 +30,8 @@ export function initUIFiltersApi(core: InternalCoreSetup) { }, tags: ['access:apm'] }, - handler: req => { - const setup = setupRequest(req); + handler: async req => { + const setup = await setupRequest(req); const { serviceName } = req.query as { serviceName?: string; }; diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts index 9b6aa0fa957a7..d8a0ff18ba66b 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch.ts @@ -6,6 +6,12 @@ import { StringMap, IndexAsString } from './common'; +export interface BoolQuery { + must_not: Array>; + should: Array>; + filter: Array>; +} + declare module 'elasticsearch' { // extending SearchResponse to be able to have typed aggregations diff --git a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js index f9ce4ad89c084..b6c8d95b05b26 100644 --- a/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/legacy/plugins/canvas/.storybook/storyshots.test.js @@ -15,6 +15,10 @@ import { addSerializer } from 'jest-specific-snapshot'; // Set our default timezone to UTC for tests so we can generate predictable snapshots moment.tz.setDefault('UTC'); +// Freeze time for the tests for predictable snapshots +const testTime = new Date(Date.UTC(2019, 5, 1)); // June 1 2019 +Date.now = jest.fn(() => testTime); + // Mock EUI generated ids to be consistently predictable for snapshots. jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); @@ -31,22 +35,12 @@ jest.mock('../canvas_plugin_src/renderers/shape/shapes', () => ({ }, })); -// Mock datetime parsing so we can get stable results for tests (even while using the `now` format) -jest.mock('@elastic/datemath', () => { - return { - parse: (d, opts) => { - const dateMath = jest.requireActual('@elastic/datemath'); - return dateMath.parse(d, {...opts, forceNow: new Date(Date.UTC(2019, 5, 1))}); // June 1 2019 - } - } -}); - // Mock react-datepicker dep used by eui to avoid rendering the entire large component jest.mock('@elastic/eui/packages/react-datepicker', () => { return { __esModule: true, default: 'ReactDatePicker', - } + }; }); addSerializer(styleSheetSerializer); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/__snapshots__/pretty_duration.examples.storyshot b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/__snapshots__/pretty_duration.examples.storyshot index 9a761ea731cad..3730cfb5f4e5c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/__snapshots__/pretty_duration.examples.storyshot +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/__snapshots__/pretty_duration.examples.storyshot @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Storyshots renderers/TimeFilter/components/PrettyDuration with absolute dates 1`] = ` + + ~ 5 months ago to ~ 4 months ago + +`; + exports[`Storyshots renderers/TimeFilter/components/PrettyDuration with relative dates 1`] = ` Last 7 days diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/pretty_duration.examples.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/pretty_duration.examples.tsx index 3e6126320041e..951776f8a9558 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/pretty_duration.examples.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/pretty_duration/__examples__/pretty_duration.examples.tsx @@ -8,13 +8,6 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { PrettyDuration } from '..'; -storiesOf('renderers/TimeFilter/components/PrettyDuration', module).add( - 'with relative dates', - () => -); - -/** - * Disabling this test due to https://github.com/elastic/kibana/issues/41217 - * Re-enable when we have a better solution for mocking time used in format_duration - */ -// .add('with absolute dates', () => ); +storiesOf('renderers/TimeFilter/components/PrettyDuration', module) + .add('with relative dates', () => ) + .add('with absolute dates', () => ); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/__snapshots__/time_picker_popover.examples.storyshot b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/__snapshots__/time_picker_popover.examples.storyshot new file mode 100644 index 0000000000000..030386122c0c2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/__snapshots__/time_picker_popover.examples.storyshot @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots renderers/TimeFilter/components/TimePickerPopover default 1`] = ` +
+
+ +
+
+`; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/time_picker_popover.examples.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/time_picker_popover.examples.tsx index e6b6a1db1b422..7555de1336b3e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/time_picker_popover.examples.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/time_filter/components/time_picker_popover/__examples__/time_picker_popover.examples.tsx @@ -4,20 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -/** - * Disabling this test due to https://github.com/elastic/kibana/issues/41217 - * Re-enable when we have a better solution for mocking time used in format_duration - */ - -// import { action } from '@storybook/addon-actions'; -// import { storiesOf } from '@storybook/react'; -// import moment from 'moment'; -// import React from 'react'; -// import { TimePickerPopover } from '..'; +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import moment from 'moment'; +import React from 'react'; +import { TimePickerPopover } from '..'; -// const startDate = moment.utc('2019-05-04').toISOString(); -// const endDate = moment.utc('2019-06-04').toISOString(); +const startDate = moment.utc('2019-05-04').toISOString(); +const endDate = moment.utc('2019-06-04').toISOString(); -// storiesOf('renderers/TimeFilter/components/TimePickerPopover', module).add('default', () => ( -// -// )); +storiesOf('renderers/TimeFilter/components/TimePickerPopover', module).add('default', () => ( + +)); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/index.js index d551db843686f..d67c47ed1b518 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/index.js @@ -8,7 +8,7 @@ const darkTemplate = require('./theme_dark.json'); const lightTemplate = require('./theme_light.json'); const pitchTemplate = require('./pitch_presentation.json'); const statusTemplate = require('./status_report.json'); -const dashboardTemplate = require('./dashboard_report.json'); +const summaryTemplate = require('./summary_report.json'); // Registry expects a function that returns a spec object export const templateSpecs = [ @@ -16,5 +16,5 @@ export const templateSpecs = [ lightTemplate, pitchTemplate, statusTemplate, - dashboardTemplate, + summaryTemplate, ].map(template => () => template); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/dashboard_report.json b/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/summary_report.json similarity index 99% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/dashboard_report.json rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/summary_report.json index 31c0d2076ea5a..6e4c2b2d71e92 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/dashboard_report.json +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/templates/summary_report.json @@ -1,8 +1,8 @@ { - "name": "Dashboard", + "name": "Summary", "id": "workpad-6181471b-147d-4397-a0d3-1c0f1600fa12", - "displayName": "Dashboard", - "help": "Infographic-style dashboard with live charts", + "displayName": "Summary", + "help": "Infographic-style report with live charts", "tags": ["report"], "width": 1100, "height": 2570, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/extended_template.examples.storyshot b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/extended_template.examples.storyshot new file mode 100644 index 0000000000000..c256ff8e23502 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/extended_template.examples.storyshot @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/AxisConfig extended 1`] = ` +
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+`; + +exports[`Storyshots arguments/AxisConfig/components extended 1`] = ` +
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+`; + +exports[`Storyshots arguments/AxisConfig/components extended disabled 1`] = ` +
+
+
+ The axis is disabled +
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/simple_template.examples.storyshot new file mode 100644 index 0000000000000..ccf2083a9c656 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/__snapshots__/simple_template.examples.storyshot @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/AxisConfig simple 1`] = ` +
+
+ + + + + + + + +
+
+`; + +exports[`Storyshots arguments/AxisConfig/components simple template 1`] = ` +
+
+ + + + + + + + +
+
+`; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/extended_template.examples.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/extended_template.examples.tsx new file mode 100644 index 0000000000000..55f58efa37bf4 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/extended_template.examples.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ExpressionAST } from '../../../../../types'; + +import { ExtendedTemplate } from '../extended_template'; + +const defaultExpression: ExpressionAST = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'axisConfig', + arguments: {}, + }, + ], +}; + +const defaultValues = { + argValue: defaultExpression, +}; + +class Interactive extends React.Component<{}, typeof defaultValues> { + public state = defaultValues; + + _onValueChange: (argValue: ExpressionAST) => void = argValue => { + action('onValueChange')(argValue); + this.setState({ argValue }); + }; + + public render() { + return ( + + ); + } +} + +storiesOf('arguments/AxisConfig', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('extended', () => ); + +storiesOf('arguments/AxisConfig/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('extended disabled', () => ( + + )) + .add('extended', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/simple_template.examples.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/simple_template.examples.tsx new file mode 100644 index 0000000000000..1446fe2933f8a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__examples__/simple_template.examples.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { SimpleTemplate } from '../simple_template'; + +const defaultValues = { + argValue: false, +}; + +class Interactive extends React.Component<{}, typeof defaultValues> { + public state = defaultValues; + + public render() { + return ( + { + action('onValueChange')(argValue); + this.setState({ argValue }); + }} + argValue={this.state.argValue} + /> + ); + } +} + +storiesOf('arguments/AxisConfig', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple', () => ); + +storiesOf('arguments/AxisConfig/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple template', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.tsx similarity index 69% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.tsx index 1f7b09c66ae61..92d38013855c4 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/extended_template.tsx @@ -4,13 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, ChangeEvent, PureComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiSelect, EuiFormRow, EuiText } from '@elastic/eui'; -import { set } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; +import { ExpressionAST } from '../../../../types'; -const defaultExpression = { +const { set } = immutable; + +const defaultExpression: ExpressionAST = { type: 'expression', chain: [ { @@ -21,7 +24,15 @@ const defaultExpression = { ], }; -export class ExtendedTemplate extends React.PureComponent { +export interface Props { + onValueChange: (newValue: ExpressionAST) => void; + argValue: boolean | ExpressionAST; + typeInstance: { + name: 'xaxis' | 'yaxis'; + }; +} + +export class ExtendedTemplate extends PureComponent { static propTypes = { onValueChange: PropTypes.func.isRequired, argValue: PropTypes.oneOfType([ @@ -31,20 +42,25 @@ export class ExtendedTemplate extends React.PureComponent { }).isRequired, ]), typeInstance: PropTypes.object.isRequired, - argId: PropTypes.string.isRequired, }; + static displayName = 'AxisConfigExtendedInput'; + // TODO: this should be in a helper, it's the same code from container_style - getArgValue = (name, alt) => { - return get(this.props.argValue, ['chain', 0, 'arguments', name, 0], alt); + getArgValue = (name: string, alt: string) => { + return get(this.props.argValue, `chain.0.arguments.${name}.0`, alt); }; // TODO: this should be in a helper, it's the same code from container_style - setArgValue = name => ev => { + setArgValue = (name: string) => (ev: ChangeEvent) => { + if (!ev || !ev.target) { + return; + } + const val = ev.target.value; const { argValue, onValueChange } = this.props; const oldVal = typeof argValue === 'boolean' ? defaultExpression : argValue; - const newValue = set(oldVal, ['chain', 0, 'arguments', name, 0], val); + const newValue = set(oldVal, `chain.0.arguments.${name}.0`, val); onValueChange(newValue); }; @@ -73,5 +89,3 @@ export class ExtendedTemplate extends React.PureComponent { ); } } - -ExtendedTemplate.displayName = 'AxisConfigExtendedInput'; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.ts similarity index 100% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.js rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/index.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.tsx similarity index 60% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.tsx index 23bca236f4506..bd85d95556525 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/simple_template.tsx @@ -4,13 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiSwitch } from '@elastic/eui'; -export const SimpleTemplate = ({ onValueChange, argValue }) => ( - onValueChange(!Boolean(argValue))} /> -); +export interface Props { + onValueChange: (argValue: boolean) => void; + argValue: boolean; +} + +export const SimpleTemplate: FunctionComponent = ({ onValueChange, argValue }) => { + return ( + onValueChange(!Boolean(argValue))} /> + ); +}; SimpleTemplate.propTypes = { onValueChange: PropTypes.func.isRequired, diff --git a/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot b/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot index 6cdca7109222b..fb63022f341d6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot +++ b/x-pack/legacy/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.examples.storyshot @@ -75,7 +75,6 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = ` onMouseOver={[Function]} > +
+ + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+`; + +exports[`Storyshots arguments/ContainerStyle/components appearance form 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+`; + +exports[`Storyshots arguments/ContainerStyle/components border form 1`] = ` +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ + Select an option: +
+ , is selected + + +
+ + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+`; + +exports[`Storyshots arguments/ContainerStyle/components extended template 1`] = ` +
+
+
+ Appearance +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ Border +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ + Select an option: +
+ , is selected + + +
+ + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.examples.storyshot new file mode 100644 index 0000000000000..f8d460a634214 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/__snapshots__/simple_template.examples.storyshot @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/ContainerStyle simple 1`] = ` +
+
+
+
+ +
+
+
+
+`; + +exports[`Storyshots arguments/ContainerStyle/components simple template 1`] = ` +
+
+
+
+ +
+
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx new file mode 100644 index 0000000000000..51a1608df67ae --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/extended_template.examples.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +// @ts-ignore Untyped local +import { getDefaultWorkpad } from '../../../../state/defaults'; + +import { Arguments, ArgumentTypes, BorderStyle, ExtendedTemplate } from '../extended_template'; +import { BorderForm } from '../border_form'; +import { AppearanceForm } from '../appearance_form'; + +const defaultValues: Arguments = { + padding: 0, + opacity: 1, + overflow: 'visible', + borderRadius: 0, + borderStyle: BorderStyle.SOLID, + borderWidth: 1, + border: '1px solid #fff', +}; + +class Interactive extends React.Component<{}, Arguments> { + public state = defaultValues; + + _getArgValue: (arg: T) => Arguments[T] = arg => { + return this.state[arg]; + }; + + _setArgValue: (arg: T, val: ArgumentTypes[T]) => void = ( + arg, + val + ) => { + action('setArgValue')(arg, val); + this.setState({ ...this.state, [arg]: val }); + }; + + public render() { + return ( + + ); + } +} + +const getArgValue: (arg: T) => Arguments[T] = arg => { + return defaultValues[arg]; +}; + +storiesOf('arguments/ContainerStyle', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('extended', () => ); + +storiesOf('arguments/ContainerStyle/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('appearance form', () => ( + + )) + .add('border form', () => ( + + )) + .add('extended template', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx new file mode 100644 index 0000000000000..71d95603cfebd --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/__examples__/simple_template.examples.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +// @ts-ignore Untyped local +import { getDefaultWorkpad } from '../../../../state/defaults'; + +import { Argument, Arguments, SimpleTemplate } from '../simple_template'; + +const defaultValues: Arguments = { + backgroundColor: '#fff', +}; + +class Interactive extends React.Component<{}, Arguments> { + public state = defaultValues; + + _getArgValue: (arg: T) => Arguments[T] = arg => { + return this.state[arg]; + }; + + _setArgValue: (arg: T, val: Arguments[T]) => void = (arg, val) => { + action('setArgValue')(arg, val); + this.setState({ ...this.state, [arg]: val }); + }; + + public render() { + return ( + + ); + } +} + +const getArgValue: (arg: T) => Arguments[T] = arg => { + return defaultValues[arg]; +}; + +storiesOf('arguments/ContainerStyle', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple', () => ); + +storiesOf('arguments/ContainerStyle/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple template', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.tsx similarity index 63% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.tsx index 5a8b3ce96a114..518c4a0256b2e 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/appearance_form.tsx @@ -4,10 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent, ChangeEvent } from 'react'; import PropTypes from 'prop-types'; import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +type Overflow = 'hidden' | 'visible'; + +export interface Arguments { + padding: string | number; + opacity: string | number; + overflow: Overflow; +} +export type ArgumentTypes = Arguments; +export type Argument = keyof Arguments; + +interface Props extends Arguments { + onChange: (arg: T, val: ArgumentTypes[T]) => void; +} + +const overflows: Array<{ value: Overflow; text: string }> = [ + { value: 'hidden', text: 'Hidden' }, + { value: 'visible', text: 'Visible' }, +]; + const opacities = [ { value: 1, text: '100%' }, { value: 0.9, text: '90%' }, @@ -17,12 +36,19 @@ const opacities = [ { value: 0.1, text: '10%' }, ]; -const overflows = [{ value: 'hidden', text: 'Hidden' }, { value: 'visible', text: 'Visible' }]; - -export const AppearanceForm = ({ padding, opacity, overflow, onChange }) => { - const paddingVal = padding ? padding.replace('px', '') : ''; +export const AppearanceForm: FunctionComponent = ({ + padding = '', + opacity = 1, + overflow = 'hidden', + onChange, +}) => { + if (typeof padding === 'string') { + padding = padding.replace('px', ''); + } - const namedChange = name => ev => { + const namedChange = (name: keyof Arguments) => ( + ev: ChangeEvent + ) => { if (name === 'padding') { return onChange(name, `${ev.target.value}px`); } @@ -34,7 +60,7 @@ export const AppearanceForm = ({ padding, opacity, overflow, onChange }) => { - + diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.tsx similarity index 63% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.tsx index 00169c8654588..55e2c6ecda93c 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/border_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGroup, @@ -14,26 +14,39 @@ import { EuiSuperSelect, } from '@elastic/eui'; import { ColorPickerPopover } from '../../../components/color_picker_popover'; +import { BorderStyle, isBorderStyle } from '../../../../types'; -const styles = [ - 'none', - 'solid', - 'dotted', - 'dashed', - 'double', - 'groove', - 'ridge', - 'inset', - 'outset', -]; +export { BorderStyle } from '../../../../types'; -export const BorderForm = ({ value, radius, onChange, colors }) => { - const border = value || ''; - const [borderWidth = '', borderStyle = '', borderColor = ''] = border.split(' '); +export interface Arguments { + borderRadius: string | number; + borderStyle: BorderStyle; + borderWidth: number; + border: string; +} +export type ArgumentTypes = Arguments; +export type Argument = keyof Arguments; + +interface Props { + onChange: (arg: T, val: ArgumentTypes[T]) => void; + value: string; + radius: string | number; + colors: string[]; +} + +export const BorderForm: FunctionComponent = ({ + value = '', + radius = '', + onChange, + colors, +}) => { + const [borderWidth = '', borderStyle = '', borderColor = ''] = value.split(' '); + + const borderStyleVal = isBorderStyle(borderStyle) ? borderStyle : BorderStyle.NONE; const borderWidthVal = borderWidth ? borderWidth.replace('px', '') : ''; - const radiusVal = radius ? radius.replace('px', '') : ''; + const radiusVal = typeof radius === 'string' ? radius.replace('px', '') : radius; - const namedChange = name => val => { + const namedChange = (name: T) => (val: Arguments[T]) => { if (name === 'borderWidth') { return onChange('border', `${val}px ${borderStyle} ${borderColor}`); } @@ -44,13 +57,17 @@ export const BorderForm = ({ value, radius, onChange, colors }) => { return onChange('border', `${borderWidth} ${val} ${borderColor}`); } if (name === 'borderRadius') { + if (val === '') { + return onChange('borderRadius', `0px`); + } return onChange('borderRadius', `${val}px`); } onChange(name, val); }; - const borderColorChange = color => onChange('border', `${borderWidth} ${borderStyle} ${color}`); + const borderColorChange = (color: string) => + onChange('border', `${borderWidth} ${borderStyle} ${color}`); return ( @@ -58,7 +75,7 @@ export const BorderForm = ({ value, radius, onChange, colors }) => { namedChange('borderWidth')(e.target.value)} + onChange={e => namedChange('borderWidth')(Number(e.target.value))} /> @@ -66,8 +83,8 @@ export const BorderForm = ({ value, radius, onChange, colors }) => { ({ + valueOfSelected={borderStyleVal || 'none'} + options={Object.values(BorderStyle).map(style => ({ value: style, inputDisplay:
, }))} @@ -101,7 +118,7 @@ export const BorderForm = ({ value, radius, onChange, colors }) => { BorderForm.propTypes = { value: PropTypes.string, - radius: PropTypes.string, + radius: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), onChange: PropTypes.func.isRequired, colors: PropTypes.array.isRequired, }; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.tsx similarity index 62% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.tsx index bfe0522476f8b..88890e551eae6 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/extended_template.tsx @@ -4,13 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; import { BorderForm } from './border_form'; import { AppearanceForm } from './appearance_form'; +import { CanvasWorkpad } from '../.../../../../../types'; +import { Arguments as AppearanceArguments } from './appearance_form'; +import { Arguments as BorderArguments } from './border_form'; -export const ExtendedTemplate = ({ getArgValue, setArgValue, workpad }) => ( +export { BorderStyle } from './border_form'; + +export interface Arguments extends BorderArguments, AppearanceArguments {} +export type ArgumentTypes = Partial; +export type Argument = keyof ArgumentTypes; + +interface Props { + getArgValue: (arg: T) => Arguments[T]; + setArgValue: (arg: T, val: ArgumentTypes[T]) => void; + workpad: CanvasWorkpad; +} + +export const ExtendedTemplate: FunctionComponent = ({ + getArgValue, + setArgValue, + workpad, +}) => (
Appearance
@@ -18,25 +37,22 @@ export const ExtendedTemplate = ({ getArgValue, setArgValue, workpad }) => ( - -
Border
); @@ -44,8 +60,6 @@ export const ExtendedTemplate = ({ getArgValue, setArgValue, workpad }) => ( ExtendedTemplate.displayName = 'ContainerStyleArgExtendedInput'; ExtendedTemplate.propTypes = { - onValueChange: PropTypes.func.isRequired, - argValue: PropTypes.any.isRequired, getArgValue: PropTypes.func.isRequired, setArgValue: PropTypes.func.isRequired, workpad: PropTypes.shape({ diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.ts similarity index 52% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.ts index d2a3bf59f6b40..c465c3fa35c3f 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/index.ts @@ -4,22 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ComponentType } from 'react'; import { withHandlers } from 'recompose'; -import { set } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; import { templateFromReactComponent } from '../../../lib/template_from_react_component'; -import { SimpleTemplate } from './simple_template'; -import { ExtendedTemplate } from './extended_template'; +import { Arguments as SimpleArguments, SimpleTemplate } from './simple_template'; +import { Arguments as ExtendedArguments, ExtendedTemplate } from './extended_template'; -const wrap = Component => +const { set } = immutable; + +interface Arguments extends SimpleArguments, ExtendedArguments {} +type ArgumentTypes = Partial; +type Argument = keyof ArgumentTypes; + +interface Handlers { + getArgValue: (name: T, alt: Arguments[T]) => Arguments[T]; + setArgValue: (name: T, val: ArgumentTypes[T]) => void; +} + +interface OuterProps { + argValue: keyof Arguments; + onValueChange: Function; +} + +const wrap = (Component: ComponentType) => // TODO: this should be in a helper - withHandlers({ + withHandlers({ getArgValue: ({ argValue }) => (name, alt) => { const args = get(argValue, 'chain.0.arguments', {}); - return get(args, [name, 0], alt); + return get(args, `${name}.0`, alt); }, setArgValue: ({ argValue, onValueChange }) => (name, val) => { - const newValue = set(argValue, ['chain', 0, 'arguments', name, 0], val); + const newValue = set(argValue, `chain.0.arguments.${name}.0`, val); onValueChange(newValue); }, })(Component); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.tsx similarity index 63% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.tsx index 96a29b220a3c8..11e000e08481f 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/container_style/simple_template.tsx @@ -4,11 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { ColorPickerPopover } from '../../../components/color_picker_popover'; +import { CanvasWorkpad } from '../.../../../../../types'; -export const SimpleTemplate = ({ getArgValue, setArgValue, workpad }) => ( +export interface Arguments { + backgroundColor: string; +} +export type Argument = keyof Arguments; + +interface Props { + getArgValue: (key: T) => Arguments[T]; + setArgValue: (key: T, val: Arguments[T]) => void; + workpad: CanvasWorkpad; +} + +export const SimpleTemplate: FunctionComponent = ({ getArgValue, setArgValue, workpad }) => (
( SimpleTemplate.displayName = 'ContainerStyleArgSimpleInput'; SimpleTemplate.propTypes = { - onValueChange: PropTypes.func.isRequired, - argValue: PropTypes.any.isRequired, getArgValue: PropTypes.func.isRequired, setArgValue: PropTypes.func.isRequired, workpad: PropTypes.shape({ diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.examples.storyshot b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.examples.storyshot new file mode 100644 index 0000000000000..1486fe7cde14e --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/extended_template.examples.storyshot @@ -0,0 +1,347 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/SeriesStyle extended 1`] = ` +
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+`; + +exports[`Storyshots arguments/SeriesStyle/components extended: defaults 1`] = ` +
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot new file mode 100644 index 0000000000000..ed6161b8ff6da --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/__snapshots__/simple_template.examples.storyshot @@ -0,0 +1,249 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots arguments/SeriesStyle simple 1`] = ` +
+
+
+ + Color  + +
+
+ +
+
+
+`; + +exports[`Storyshots arguments/SeriesStyle/components simple: defaults 1`] = ` +
+
+
+ + Color  + +
+
+ +
+
+
+`; + +exports[`Storyshots arguments/SeriesStyle/components simple: no labels 1`] = ` +
+
+
+ + Color  + +
+
+ +
+
+
+`; + +exports[`Storyshots arguments/SeriesStyle/components simple: no series 1`] = ` +
+
+
+ + Color  + +
+
+ +
+
+ + + +
+
+
+`; + +exports[`Storyshots arguments/SeriesStyle/components simple: with series 1`] = ` +
+
+
+ + Color  + +
+
+ +
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx new file mode 100644 index 0000000000000..58af29463c3eb --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/extended_template.examples.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import { withKnobs, array, radios, boolean } from '@storybook/addon-knobs'; +import React from 'react'; + +import { ExtendedTemplate } from '../extended_template'; +import { ExpressionAST } from '../../../../../types'; + +const defaultExpression: ExpressionAST = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'seriesStyle', + arguments: {}, + }, + ], +}; + +const defaultValues = { + argValue: defaultExpression, +}; + +class Interactive extends React.Component<{}, { argValue: ExpressionAST }> { + public state = defaultValues; + + public render() { + const include = []; + if (boolean('Lines', true)) { + include.push('lines'); + } + if (boolean('Bars', true)) { + include.push('bars'); + } + if (boolean('Points', true)) { + include.push('points'); + } + return ( + { + action('onValueChange')(argValue); + this.setState({ argValue }); + }} + labels={array('Series Labels', ['label1', 'label2'])} + typeInstance={{ + name: radios('Type Instance', { default: 'defaultStyle', custom: 'custom' }, 'custom'), + options: { + include, + }, + }} + /> + ); + } +} + +storiesOf('arguments/SeriesStyle', module) + .addDecorator(story => ( +
{story()}
+ )) + .addDecorator(withKnobs) + .add('extended', () => ); + +storiesOf('arguments/SeriesStyle/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('extended: defaults', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx new file mode 100644 index 0000000000000..7a35f4de79809 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/__examples__/simple_template.examples.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +// @ts-ignore Untyped local +import { getDefaultWorkpad } from '../../../../state/defaults'; + +import { SimpleTemplate } from '../simple_template'; +import { ExpressionAST } from '../../../../../types'; + +const defaultExpression: ExpressionAST = { + type: 'expression', + chain: [ + { + type: 'function', + function: 'seriesStyle', + arguments: {}, + }, + ], +}; + +const defaultValues = { + argValue: defaultExpression, +}; + +class Interactive extends React.Component<{}, { argValue: ExpressionAST }> { + public state = defaultValues; + + public render() { + return ( + { + action('onValueChange')(argValue); + this.setState({ argValue }); + }} + workpad={getDefaultWorkpad()} + typeInstance={{ + name: 'defaultStyle', + }} + /> + ); + } +} + +storiesOf('arguments/SeriesStyle', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple', () => ); + +storiesOf('arguments/SeriesStyle/components', module) + .addDecorator(story => ( +
{story()}
+ )) + .add('simple: no labels', () => ( + + )) + .add('simple: defaults', () => ( + + )) + .add('simple: no series', () => ( + + )) + .add('simple: with series', () => ( + + )); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.tsx similarity index 75% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.tsx index 8a28f00cfba89..7b625dadbbf69 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/extended_template.tsx @@ -4,25 +4,55 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FunctionComponent, ChangeEvent } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; +import { ExpressionAST } from '../../../../types'; -export const ExtendedTemplate = props => { +const { set, del } = immutable; + +export interface Arguments { + label: string; + lines: number; + bars: number; + points: number; +} +export type Argument = keyof Arguments; + +export interface Props { + argValue: ExpressionAST; + labels: string[]; + onValueChange: (argValue: ExpressionAST) => void; + typeInstance?: { + name: string; + options: { + include: string[]; + }; + }; +} + +export const ExtendedTemplate: FunctionComponent = props => { const { typeInstance, onValueChange, labels, argValue } = props; const chain = get(argValue, 'chain.0', {}); const chainArgs = get(chain, 'arguments', {}); const selectedSeries = get(chainArgs, 'label.0', ''); - const { name } = typeInstance; - const fields = get(typeInstance, 'options.include', []); + + let name = ''; + if (typeInstance) { + name = typeInstance.name; + } + + const fields = get(typeInstance, 'options.include', []); const hasPropFields = fields.some(field => ['lines', 'bars', 'points'].indexOf(field) !== -1); - const handleChange = (argName, ev) => { + const handleChange: (key: T, val: ChangeEvent) => void = ( + argName, + ev + ) => { const fn = ev.target.value === '' ? del : set; - - const newValue = fn(argValue, ['chain', 0, 'arguments', argName], [ev.target.value]); + const newValue = fn(argValue, `chain.0.arguments.${argName}`, [ev.target.value]); return onValueChange(newValue); }; @@ -99,5 +129,4 @@ ExtendedTemplate.propTypes = { argValue: PropTypes.any.isRequired, typeInstance: PropTypes.object, labels: PropTypes.array.isRequired, - renderError: PropTypes.func, }; diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.js deleted file mode 100644 index da751fb5bc59b..0000000000000 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import PropTypes from 'prop-types'; -import { lifecycle } from 'recompose'; -import { get } from 'lodash'; -import { templateFromReactComponent } from '../../../lib/template_from_react_component'; -import { SimpleTemplate } from './simple_template'; -import { ExtendedTemplate } from './extended_template'; - -const EnhancedExtendedTemplate = lifecycle({ - formatLabel(label) { - if (typeof label !== 'string') { - this.props.renderError(); - } - return `Style: ${label}`; - }, - componentWillMount() { - const label = get(this.props.argValue, 'chain.0.arguments.label.0', ''); - if (label) { - this.props.setLabel(this.formatLabel(label)); - } - }, - componentWillReceiveProps(newProps) { - const newLabel = get(newProps.argValue, 'chain.0.arguments.label.0', ''); - if (newLabel && this.props.label !== this.formatLabel(newLabel)) { - this.props.setLabel(this.formatLabel(newLabel)); - } - }, -})(ExtendedTemplate); - -EnhancedExtendedTemplate.propTypes = { - argValue: PropTypes.any.isRequired, - setLabel: PropTypes.func.isRequired, - label: PropTypes.string, -}; - -export const seriesStyle = () => ({ - name: 'seriesStyle', - displayName: 'Series style', - help: 'Set the style for a selected named series', - template: templateFromReactComponent(EnhancedExtendedTemplate), - simpleTemplate: templateFromReactComponent(SimpleTemplate), - default: '{seriesStyle}', -}); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.ts b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.ts new file mode 100644 index 0000000000000..d13729568fdee --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import { lifecycle, compose } from 'recompose'; +import { get } from 'lodash'; +import { templateFromReactComponent } from '../../../lib/template_from_react_component'; +import { SimpleTemplate } from './simple_template'; +import { ExtendedTemplate, Props as ExtendedTemplateProps } from './extended_template'; +import { ExpressionAST } from '../../../../types'; + +interface Props { + argValue: ExpressionAST; + renderError: Function; + setLabel: Function; + label: string; +} + +const formatLabel = (label: string, props: Props) => { + if (typeof label !== 'string') { + props.renderError(); + } + return `Style: ${label}`; +}; + +const EnhancedExtendedTemplate = compose( + lifecycle({ + componentWillMount() { + const label = get(this.props.argValue, 'chain.0.arguments.label.0', ''); + if (label) { + this.props.setLabel(formatLabel(label, this.props)); + } + }, + componentWillReceiveProps(newProps) { + const newLabel = get(newProps.argValue, 'chain.0.arguments.label.0', ''); + if (newLabel && this.props.label !== formatLabel(newLabel, this.props)) { + this.props.setLabel(formatLabel(newLabel, this.props)); + } + }, + }) +)(ExtendedTemplate); + +EnhancedExtendedTemplate.propTypes = { + argValue: PropTypes.any.isRequired, + setLabel: PropTypes.func.isRequired, + label: PropTypes.string, +}; + +export const seriesStyle = () => ({ + name: 'seriesStyle', + displayName: 'Series style', + help: 'Set the style for a selected named series', + template: templateFromReactComponent(EnhancedExtendedTemplate), + simpleTemplate: templateFromReactComponent(SimpleTemplate), + default: '{seriesStyle}', +}); diff --git a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.tsx similarity index 70% rename from x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js rename to x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.tsx index be35184941144..8bcf9d73daa50 100644 --- a/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.js +++ b/x-pack/legacy/plugins/canvas/public/expression_types/arg_types/series_style/simple_template.tsx @@ -4,30 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, FunctionComponent } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiButtonIcon } from '@elastic/eui'; -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; import { ColorPickerPopover } from '../../../components/color_picker_popover'; +// @ts-ignore Untyped local import { TooltipIcon } from '../../../components/tooltip_icon'; +import { ExpressionAST, CanvasWorkpad } from '../../../../types'; -export const SimpleTemplate = props => { +const { set, del } = immutable; + +interface Arguments { + color: string; +} +type Argument = keyof Arguments; + +interface Props { + argValue: ExpressionAST; + labels?: string[]; + onValueChange: (argValue: ExpressionAST) => void; + typeInstance: { + name: string; + }; + workpad: CanvasWorkpad; +} + +export const SimpleTemplate: FunctionComponent = props => { const { typeInstance, argValue, onValueChange, labels, workpad } = props; const { name } = typeInstance; const chain = get(argValue, 'chain.0', {}); const chainArgs = get(chain, 'arguments', {}); - const color = get(chainArgs, 'color.0', ''); - - const handleChange = (argName, ev) => { - const fn = ev.target.value === '' ? del : set; + const color: string = get(chainArgs, 'color.0', ''); - const newValue = fn(argValue, ['chain', 0, 'arguments', argName], [ev.target.value]); + const handleChange: (key: T, val: string) => void = (argName, val) => { + const fn = val === '' ? del : set; + const newValue = fn(argValue, `chain.0.arguments.${argName}`, [val]); return onValueChange(newValue); }; - const handlePlain = (argName, val) => handleChange(argName, { target: { value: val } }); - return ( {!color || color.length === 0 ? ( @@ -36,7 +52,7 @@ export const SimpleTemplate = props => { Color  - handlePlain('color', '#000000')}> + handleChange('color', '#000000')}> Auto @@ -48,18 +64,17 @@ export const SimpleTemplate = props => { handlePlain('color', val)} + anchorPosition="leftCenter" colors={workpad.colors} - placement="leftCenter" + onChange={val => handleChange('color', val)} + value={color} /> handlePlain('color', '')} + onClick={() => handleChange('color', '')} aria-label="Remove Series Color" /> @@ -81,11 +96,10 @@ export const SimpleTemplate = props => { SimpleTemplate.displayName = 'SeriesStyleArgSimpleInput'; SimpleTemplate.propTypes = { - onValueChange: PropTypes.func.isRequired, argValue: PropTypes.any.isRequired, labels: PropTypes.array, + onValueChange: PropTypes.func.isRequired, workpad: PropTypes.shape({ colors: PropTypes.array.isRequired, }).isRequired, - typeInstance: PropTypes.shape({ name: PropTypes.string.isRequired }).isRequired, }; diff --git a/x-pack/legacy/plugins/canvas/public/lib/sync_filter_expression.ts b/x-pack/legacy/plugins/canvas/public/lib/sync_filter_expression.ts index 8252fe948bd68..dc70f778f0e52 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/sync_filter_expression.ts +++ b/x-pack/legacy/plugins/canvas/public/lib/sync_filter_expression.ts @@ -6,10 +6,11 @@ // @ts-ignore internal untyped import { fromExpression } from '@kbn/interpreter/common'; -// @ts-ignore external untyped -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; +const { set, del } = immutable; + export function syncFilterExpression( config: Record, filterExpression: string, diff --git a/x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.js b/x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.tsx similarity index 59% rename from x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.js rename to x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.tsx index d4462e94320b1..5144da587fa75 100644 --- a/x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.js +++ b/x-pack/legacy/plugins/canvas/public/lib/template_from_react_component.tsx @@ -4,15 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import ReactDom from 'react-dom'; +import React, { ComponentType, FunctionComponent } from 'react'; +import { unmountComponentAtNode, render } from 'react-dom'; import PropTypes from 'prop-types'; import { ErrorBoundary } from '../components/enhance/error_boundary'; -export const templateFromReactComponent = Component => { - const WrappedComponent = props => ( +interface Props { + renderError: Function; +} + +interface Handlers { + done: () => void; + onDestroy: (fn: () => void) => void; +} + +export const templateFromReactComponent = (Component: ComponentType) => { + const WrappedComponent: FunctionComponent = props => ( - {({ error }) => { + {({ error }: { error: Error }) => { if (error) { props.renderError(); return null; @@ -27,15 +36,15 @@ export const templateFromReactComponent = Component => { renderError: PropTypes.func, }; - return (domNode, config, handlers) => { + return (domNode: Element, config: Props, handlers: Handlers) => { try { const el = React.createElement(WrappedComponent, config); - ReactDom.render(el, domNode, () => { + render(el, domNode, () => { handlers.done(); }); handlers.onDestroy(() => { - ReactDom.unmountComponentAtNode(domNode); + unmountComponentAtNode(domNode); }); } catch (err) { handlers.done(); diff --git a/x-pack/legacy/plugins/canvas/public/state/actions/elements.js b/x-pack/legacy/plugins/canvas/public/state/actions/elements.js index cad58aa781f43..f683ae9caa080 100644 --- a/x-pack/legacy/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/actions/elements.js @@ -6,7 +6,7 @@ import { createAction } from 'redux-actions'; import { createThunk } from 'redux-thunks'; -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; import { interpretAst } from 'plugins/interpreter/interpreter'; @@ -19,6 +19,8 @@ import { subMultitree } from '../../lib/aeroelastic/functional'; import { selectToplevelNodes } from './transient'; import * as args from './resolved_args'; +const { set, del } = immutable; + export function getSiblingContext(state, elementId, checkIndex) { const prevContextPath = [elementId, 'expressionContext', checkIndex]; const prevContextValue = getResolvedArgsValue(state, prevContextPath); @@ -61,7 +63,7 @@ export const flushContextAfterIndex = createAction('flushContextAfterIndex'); export const fetchContext = createThunk( 'fetchContext', ({ dispatch, getState }, index, element, fullRefresh = false) => { - const chain = get(element, ['ast', 'chain']); + const chain = get(element, 'ast.chain'); const invalidIndex = chain ? index >= chain.length : true; if (!element || !chain || invalidIndex) { @@ -301,7 +303,7 @@ export const setAstAtIndex = createThunk( // invalidate cached context for elements after this index dispatch(flushContextAfterIndex({ elementId: element.id, index })); - const newElement = set(element, ['ast', 'chain', index], ast); + const newElement = set(element, `ast.chain.${index}`, ast); const newAst = get(newElement, 'ast'); // fetch renderable using existing context, if available (value is null if not cached) @@ -340,9 +342,9 @@ export const setAstAtIndex = createThunk( // the entire argument from be set to the passed value export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch }, args) => { const { index, argName, value, valueIndex, element, pageId } = args; - const selector = ['ast', 'chain', index, 'arguments', argName]; + let selector = `ast.chain.${index}.arguments.${argName}`; if (valueIndex != null) { - selector.push(valueIndex); + selector += '.' + valueIndex; } const newElement = set(element, selector, value); @@ -380,9 +382,9 @@ export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dis const newElement = argIndex != null && curVal.length > 1 ? // if more than one val, remove the specified val - del(element, ['ast', 'chain', index, 'arguments', argName, argIndex]) + del(element, `ast.chain.${index}.arguments.${argName}.${argIndex}`) : // otherwise, remove the entire key - del(element, ['ast', 'chain', index, 'arguments', argName]); + del(element, `ast.chain.${index}.arguments.${argName}`); dispatch(setAstAtIndex(index, get(newElement, ['ast', 'chain', index]), element, pageId)); }); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/assets.js b/x-pack/legacy/plugins/canvas/public/state/reducers/assets.js index 9aa3cd225ac2c..ee8f81d8bd9ff 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/assets.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/assets.js @@ -5,11 +5,13 @@ */ import { handleActions, combineActions } from 'redux-actions'; -import { set, assign, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; import { createAsset, setAssetValue, removeAsset, setAssets, resetAssets } from '../actions/assets'; import { getId } from '../../lib/get_id'; +const { set, assign, del } = immutable; + export const assetsReducer = handleActions( { [createAsset]: (assetState, { payload }) => { @@ -34,7 +36,7 @@ export const assetsReducer = handleActions( return del(assetState, assetId); }, - [combineActions(setAssets, resetAssets)]: (assetState, { payload }) => payload || {}, + [combineActions(setAssets, resetAssets)]: (_assetState, { payload }) => payload || {}, }, {} ); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js index 4175d2bcdf33d..10a5bdb5998ea 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js @@ -5,10 +5,12 @@ */ import { handleActions } from 'redux-actions'; -import { assign, push, del, set } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; import * as actions from '../actions/elements'; +const { assign, push, del, set } = immutable; + const getLocation = type => (type === 'group' ? 'groups' : 'elements'); const firstOccurrence = (element, index, array) => array.indexOf(element) === index; @@ -29,14 +31,14 @@ function getNodeIndexById(page, nodeId, location) { function assignNodeProperties(workpadState, pageId, nodeId, props) { const pageIndex = getPageIndexById(workpadState, pageId); const location = getLocationFromIds(workpadState, pageId, nodeId); - const nodesPath = ['pages', pageIndex, location]; + const nodesPath = `pages.${pageIndex}.${location}`; const nodeIndex = get(workpadState, nodesPath, []).findIndex(node => node.id === nodeId); if (pageIndex === -1 || nodeIndex === -1) { return workpadState; } - return assign(workpadState, nodesPath.concat(nodeIndex), props); + return assign(workpadState, `${nodesPath}.${nodeIndex}`, props); } function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { @@ -66,7 +68,7 @@ function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { const newNodes = nodes.slice(0); newNodes.splice(to, 0, newNodes.splice(from, 1)[0]); - return set(workpadState, ['pages', pageIndex, location], newNodes); + return set(workpadState, `pages.${pageIndex}.${location}`, newNodes); } const trimPosition = ({ left, top, width, height, angle, parent }) => ({ @@ -123,7 +125,7 @@ export const elementsReducer = handleActions( } return push( workpadState, - ['pages', pageIndex, getLocation(element.position.type)], + `pages.${pageIndex}.${getLocation(element.position.type)}`, trimElement(element) ); }, @@ -136,7 +138,7 @@ export const elementsReducer = handleActions( (state, element) => push( state, - ['pages', pageIndex, getLocation(element.position.type)], + `pages.${pageIndex}.${getLocation(element.position.type)}`, trimElement(element) ), workpadState @@ -160,7 +162,7 @@ export const elementsReducer = handleActions( .sort((a, b) => b.index - a.index); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops! return nodeIndices.reduce((state, { location, index }) => { - return del(state, ['pages', pageIndex, location, index]); + return del(state, `pages.${pageIndex}.${location}.${index}`); }, workpadState); }, }, diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/pages.js b/x-pack/legacy/plugins/canvas/public/state/reducers/pages.js index c2bcb5b939483..224d0a3c03795 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/pages.js @@ -5,7 +5,7 @@ */ import { handleActions } from 'redux-actions'; -import { set, del, insert } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import { getId } from '../../lib/get_id'; import { routerProvider } from '../../lib/router_provider'; @@ -14,6 +14,8 @@ import * as actions from '../actions/pages'; import { getSelectedPageIndex } from '../selectors/workpad'; import { isGroupId } from '../../components/workpad_page/integration_utils'; +const { set, del, insert } = immutable; + const setPageIndex = (workpadState, index) => index < 0 || !workpadState.pages[index] || getSelectedPageIndex(workpadState) === index ? workpadState @@ -143,12 +145,12 @@ export const pagesReducer = handleActions( [actions.stylePage]: (workpadState, { payload }) => { const pageIndex = workpadState.pages.findIndex(page => page.id === payload.pageId); - return set(workpadState, ['pages', pageIndex, 'style'], payload.style); + return set(workpadState, `pages.${pageIndex}.style`, payload.style); }, [actions.setPageTransition]: (workpadState, { payload }) => { const pageIndex = workpadState.pages.findIndex(page => page.id === payload.pageId); - return set(workpadState, ['pages', pageIndex, 'transition'], payload.transition); + return set(workpadState, `pages.${pageIndex}.transition`, payload.transition); }, }, {} diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/resolved_args.js b/x-pack/legacy/plugins/canvas/public/state/reducers/resolved_args.js index f458a3b572330..fa0be7702765d 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/resolved_args.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/resolved_args.js @@ -5,13 +5,14 @@ */ import { handleActions } from 'redux-actions'; -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { get } from 'lodash'; import { prepend } from '../../lib/modify_path'; import * as actions from '../actions/resolved_args'; import { flushContext, flushContextAfterIndex } from '../actions/elements'; import { setWorkpad } from '../actions/workpad'; +const { set, del } = immutable; /* Resolved args are a way to handle async values. They track the status, value, and error state thgouh the lifecycle of the request, and are an object that looks like this: diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/transient.js b/x-pack/legacy/plugins/canvas/public/state/reducers/transient.js index e059e6439d864..0b89dfd3c9564 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/transient.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/transient.js @@ -5,13 +5,15 @@ */ import { handleActions } from 'redux-actions'; -import { set, del } from 'object-path-immutable'; +import immutable from 'object-path-immutable'; import { restoreHistory } from '../actions/history'; import * as pageActions from '../actions/pages'; import * as transientActions from '../actions/transient'; import { removeElements } from '../actions/elements'; import { setRefreshInterval, enableAutoplay, setAutoplayInterval } from '../actions/workpad'; +const { set, del } = immutable; + export const transientReducer = handleActions( { // clear all the resolved args when restoring the history diff --git a/x-pack/legacy/plugins/canvas/types/canvas.ts b/x-pack/legacy/plugins/canvas/types/canvas.ts new file mode 100644 index 0000000000000..97f8917d50725 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/types/canvas.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElementPosition } from './elements'; + +export interface CanvasElement { + id: string; + position: ElementPosition; + type: 'element'; + expression: string; + filter: string; +} + +export interface CanvasPage { + id: string; + style: { + background: string; + }; + transition: {}; // Fix + elements: CanvasElement[]; + groups: CanvasElement[][]; +} + +export interface CanvasWorkpad { + name: string; + id: string; + width: number; + height: number; + css: string; + page: number; + pages: CanvasPage[]; + colors: string[]; + isWriteable: boolean; +} diff --git a/x-pack/legacy/plugins/canvas/types/elements.ts b/x-pack/legacy/plugins/canvas/types/elements.ts index ee98b4b91e40a..35b3a28e1ae0f 100644 --- a/x-pack/legacy/plugins/canvas/types/elements.ts +++ b/x-pack/legacy/plugins/canvas/types/elements.ts @@ -59,7 +59,7 @@ export interface AST { }>; } -interface Position { +export interface ElementPosition { /** * distance from the left edge of the page */ @@ -94,7 +94,7 @@ export interface PositionedElement { /** * layout engine settings */ - position: Position; + position: ElementPosition; /** * Canvas expression used to generate the element */ diff --git a/x-pack/legacy/plugins/canvas/types/functions.ts b/x-pack/legacy/plugins/canvas/types/functions.ts index ee0c17e10a19b..cd1947443b23e 100644 --- a/x-pack/legacy/plugins/canvas/types/functions.ts +++ b/x-pack/legacy/plugins/canvas/types/functions.ts @@ -116,28 +116,6 @@ export type CanvasFunction = FunctionFactory; */ export type CanvasFunctionName = CanvasFunction['name']; -/** - * Represents an object that is intended to be rendered. - */ -export interface Render { - type: 'render'; - as: string; - value: T; -} - -/** - * Represents an object that is a Filter. - */ -export interface Filter { - type?: string; - value?: string; - column?: string; - and: Filter[]; - to?: string; - from?: string; - query?: string | null; -} - /** * Represents a function called by the `case` Function. */ @@ -147,43 +125,6 @@ export interface Case { result: any; } -// DATATABLES -// ---------- - -/** - * A Utility function that Typescript can use to determine if an object is a Datatable. - * @param datatable - */ -export const isDatatable = (datatable: any): datatable is Datatable => - !!datatable && datatable.type === 'datatable'; - -/** - * This type represents the `type` of any `DatatableColumn` in a `Datatable`. - */ -export type DatatableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'null'; - -/** - * This type represents a `DatatableRow` in a `Datatable`. - */ -export type DatatableRow = Record; - -/** - * This type represents the shape of a column in a `Datatable`. - */ -export interface DatatableColumn { - name: string; - type: DatatableColumnType; -} - -/** - * A `Datatable` in Canvas is a unique structure that represents tabulated data. - */ -export interface Datatable { - type: 'datatable'; - columns: DatatableColumn[]; - rows: DatatableRow[]; -} - export enum Legend { NORTH_WEST = 'nw', SOUTH_WEST = 'sw', @@ -198,34 +139,6 @@ export enum Position { RIGHT = 'right', } -/** - * Allowed column names in a PointSeries - */ -export type PointSeriesColumnName = 'x' | 'y' | 'color' | 'size' | 'text'; - -/** - * Column in a PointSeries - */ -export interface PointSeriesColumn { - type: 'number' | 'string'; - role: 'measure' | 'dimension'; - expression: string; -} - -/** - * Represents a collection of valid Columns in a PointSeries - */ -export type PointSeriesColumns = { [key in PointSeriesColumnName]: PointSeriesColumn }; - -/** - * A `PointSeries` in Canvas is a unique structure that represents dots on a chart. - */ -export interface PointSeries { - type: 'pointseries'; - columns: PointSeriesColumns; - rows: Array>; -} - export interface SeriesStyle { type: 'seriesStyle'; bars: number; diff --git a/x-pack/legacy/plugins/canvas/types/index.ts b/x-pack/legacy/plugins/canvas/types/index.ts index d253322055af7..99d3ea9b85b42 100644 --- a/x-pack/legacy/plugins/canvas/types/index.ts +++ b/x-pack/legacy/plugins/canvas/types/index.ts @@ -4,11 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from '../../../../../src/legacy/core_plugins/interpreter/public/types/style'; +export { + ContainerStyle, + Overflow, + BackgroundRepeat, + BackgroundSize, +} from '../../../../../src/legacy/core_plugins/interpreter/public/types/style'; +export * from '../../../../../src/plugins/data/common/expressions/types'; export * from './assets'; +export * from './canvas'; export * from './elements'; export * from './functions'; export * from './renderers'; export * from './shortcuts'; export * from './state'; +export * from './style'; export * from './telemetry'; diff --git a/x-pack/legacy/plugins/canvas/types/style.ts b/x-pack/legacy/plugins/canvas/types/style.ts new file mode 100644 index 0000000000000..8484c506e28a9 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/types/style.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum BorderStyle { + NONE = 'none', + SOLID = 'solid', + DOTTED = 'dotted', + DASHED = 'dashed', + DOUBLE = 'double', + GROOVE = 'groove', + RIDGE = 'ridge', + INSET = 'inset', + OUTSET = 'outset', +} + +export const isBorderStyle = (style: any): style is BorderStyle => + !!style && Object.values(BorderStyle).includes(style); diff --git a/x-pack/legacy/plugins/code/common/git_url_utils.ts b/x-pack/legacy/plugins/code/common/git_url_utils.ts index 27159cf91cc42..7ae90805dbdcc 100644 --- a/x-pack/legacy/plugins/code/common/git_url_utils.ts +++ b/x-pack/legacy/plugins/code/common/git_url_utils.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import GitUrlParse from 'git-url-parse'; // return true if the git url is valid, otherwise throw Error with @@ -18,14 +19,22 @@ export function validateGitUrl( if (hostWhitelist && hostWhitelist.length > 0) { const hostSet = new Set(hostWhitelist); if (!hostSet.has(repo.source)) { - throw new Error('Git url host is not whitelisted.'); + throw new Error( + i18n.translate('xpack.code.gitUrlUtil.urlNotWhitelistedMessage', { + defaultMessage: 'Git url host is not whitelisted.', + }) + ); } } if (protocolWhitelist && protocolWhitelist.length > 0) { const protocolSet = new Set(protocolWhitelist); if (!protocolSet.has(repo.protocol)) { - throw new Error('Git url protocol is not whitelisted.'); + throw new Error( + i18n.translate('xpack.code.gitUrlUtil.protocolNotWhitelistedMessage', { + defaultMessage: 'Git url protocol is not whitelisted.', + }) + ); } } return true; diff --git a/x-pack/legacy/plugins/code/common/language_server.ts b/x-pack/legacy/plugins/code/common/language_server.ts index b07d413336ca9..31b15102e3a19 100644 --- a/x-pack/legacy/plugins/code/common/language_server.ts +++ b/x-pack/legacy/plugins/code/common/language_server.ts @@ -38,4 +38,6 @@ export const CTAGS_SUPPORT_LANGS = [ 'shell', 'sql', 'tcl', + 'java', + 'javascript', ]; diff --git a/x-pack/legacy/plugins/code/common/lsp_client.ts b/x-pack/legacy/plugins/code/common/lsp_client.ts index be99b0d641b5e..f76d5ea808891 100644 --- a/x-pack/legacy/plugins/code/common/lsp_client.ts +++ b/x-pack/legacy/plugins/code/common/lsp_client.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { ResponseError, ResponseMessage } from './jsonrpc'; @@ -27,9 +27,7 @@ export class LspRestClient implements LspClient { signal?: AbortSignal ): Promise { try { - const response = await kfetch({ - pathname: `${this.baseUri}/${method}`, - method: 'POST', + const response = await npStart.core.http.post(`${this.baseUri}/${method}`, { body: JSON.stringify(params), signal, }); diff --git a/x-pack/legacy/plugins/code/common/repo_file_status.ts b/x-pack/legacy/plugins/code/common/repo_file_status.ts index e898e3ef2b932..d6901d6c53ba2 100644 --- a/x-pack/legacy/plugins/code/common/repo_file_status.ts +++ b/x-pack/legacy/plugins/code/common/repo_file_status.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export enum RepoFileStatus { LANG_SERVER_IS_INITIALIZING = 'Language server is initializing.', LANG_SERVER_INITIALIZED = 'Language server initialized.', @@ -27,6 +29,63 @@ export enum LangServerType { DEDICATED = 'Current file is covered by dedicated language server', } +export const RepoFileStatusText = { + [RepoFileStatus.LANG_SERVER_IS_INITIALIZING]: i18n.translate( + 'xpack.code.repoFileStatus.langugageServerIsInitializitingMessage', + { + defaultMessage: 'Language server is initializing.', + } + ), + [RepoFileStatus.LANG_SERVER_INITIALIZED]: i18n.translate( + 'xpack.code.repoFileStatus.languageServerInitializedMessage', + { + defaultMessage: 'Language server initialized.', + } + ), + [RepoFileStatus.INDEXING]: i18n.translate('xpack.code.repoFileStatus.IndexingInProgressMessage', { + defaultMessage: 'Indexing in progress.', + }), + [RepoFileStatus.FILE_NOT_SUPPORTED]: i18n.translate( + 'xpack.code.repoFileStatus.fileNotSupportedMessage', + { + defaultMessage: 'Current file is not of a supported language.', + } + ), + [RepoFileStatus.REVISION_NOT_INDEXED]: i18n.translate( + 'xpack.code.repoFileStatus.revisionNotIndexedMessage', + { + defaultMessage: 'Current revision is not indexed.', + } + ), + [RepoFileStatus.LANG_SERVER_NOT_INSTALLED]: i18n.translate( + 'xpack.code.repoFileStatus.langServerNotInstalledMessage', + { + defaultMessage: 'Install additional language server to support current file.', + } + ), + [RepoFileStatus.FILE_IS_TOO_BIG]: i18n.translate( + 'xpack.code.repoFileStatus.fileIsTooBigMessage', + { + defaultMessage: 'Current file is too big.', + } + ), + [LangServerType.NONE]: i18n.translate('xpack.code.repoFileStatus.langserverType.noneMessage', { + defaultMessage: 'Current file is not supported by any language server.', + }), + [LangServerType.GENERIC]: i18n.translate( + 'xpack.code.repoFileStatus.langserverType.genericMessage', + { + defaultMessage: 'Current file is only covered by generic language server.', + } + ), + [LangServerType.DEDICATED]: i18n.translate( + 'xpack.code.repoFileStatus.langserverType.dedicatedMessage', + { + defaultMessage: 'Current file is covered by dedicated language server.', + } + ), +}; + export enum CTA { SWITCH_TO_HEAD, GOTO_LANG_MANAGE_PAGE, diff --git a/x-pack/legacy/plugins/code/index.ts b/x-pack/legacy/plugins/code/index.ts index 79fc741d863ba..5dc045aa59b12 100644 --- a/x-pack/legacy/plugins/code/index.ts +++ b/x-pack/legacy/plugins/code/index.ts @@ -4,14 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; +import { RequestQuery, ResponseToolkit, RouteOptions, ServerRoute } from 'hapi'; import JoiNamespace from 'joi'; +import { Legacy } from 'kibana'; import moment from 'moment'; import { resolve } from 'path'; -import { init } from './server/init'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; import { APP_TITLE } from './common/constants'; import { LanguageServers, LanguageServersDeveloping } from './server/lsp/language_servers'; +import { codePlugin } from './server'; + +export type RequestFacade = Legacy.Request; +export type RequestQueryFacade = RequestQuery; +export type ResponseToolkitFacade = ResponseToolkit; +export type RouteOptionsFacade = RouteOptions; +export type ServerFacade = Legacy.Server; +export type ServerRouteFacade = ServerRoute; export const code = (kibana: any) => new kibana.Plugin({ @@ -23,11 +32,11 @@ export const code = (kibana: any) => uiExports: { app: { title: APP_TITLE, - main: 'plugins/code/app', + main: 'plugins/code/index', euiIconType: 'codeApp', }, styleSheetPaths: resolve(__dirname, 'public/index.scss'), - injectDefaultVars(server: Server) { + injectDefaultVars(server: ServerFacade) { const config = server.config(); return { codeUiEnabled: config.get('xpack.code.ui.enabled'), @@ -93,11 +102,37 @@ export const code = (kibana: any) => .default(['https', 'git', 'ssh']), enableGitCertCheck: Joi.boolean().default(true), }).default(), + disk: Joi.object({ + thresholdEnabled: Joi.bool().default(true), + watermarkLowMb: Joi.number().default(2048), + }).default(), maxWorkspace: Joi.number().default(5), // max workspace folder for each language server - disableIndexScheduler: Joi.boolean().default(false), enableGlobalReference: Joi.boolean().default(false), // Global reference as optional feature for now codeNodeUrl: Joi.string(), }).default(); }, - init, + init(server: ServerFacade, options: any) { + if (!options.ui.enabled) { + return; + } + + const initializerContext = {} as PluginInitializerContext; + const coreSetup = ({ + http: { server }, + } as any) as CoreSetup; + + // Set up with the new platform plugin lifecycle API. + const plugin = codePlugin(initializerContext); + plugin.setup(coreSetup, options); + + // @ts-ignore + const kbnServer = this.kbnServer; + kbnServer.ready().then(async () => { + await plugin.start(coreSetup); + }); + + server.events.on('stop', async () => { + await plugin.stop(); + }); + }, }); diff --git a/x-pack/legacy/plugins/code/kibana.json b/x-pack/legacy/plugins/code/kibana.json new file mode 100644 index 0000000000000..03fd3694df532 --- /dev/null +++ b/x-pack/legacy/plugins/code/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "code", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": [ + "elasticsearch", + "xpack_main" + ] +} diff --git a/x-pack/legacy/plugins/code/public/actions/file.ts b/x-pack/legacy/plugins/code/public/actions/file.ts index da6db39620957..c1f27cdc0a9f8 100644 --- a/x-pack/legacy/plugins/code/public/actions/file.ts +++ b/x-pack/legacy/plugins/code/public/actions/file.ts @@ -58,10 +58,6 @@ export const fetchRepoTree = createAction('FETCH REPO TREE export const fetchRepoTreeSuccess = createAction('FETCH REPO TREE SUCCESS'); export const fetchRepoTreeFailed = createAction('FETCH REPO TREE FAILED'); -export const resetRepoTree = createAction('CLEAR REPO TREE'); -export const closeTreePath = createAction('CLOSE TREE PATH'); -export const openTreePath = createAction('OPEN TREE PATH'); - export const fetchRepoBranches = createAction('FETCH REPO BRANCHES'); export const fetchRepoBranchesSuccess = createAction( 'FETCH REPO BRANCHES SUCCESS' diff --git a/x-pack/legacy/plugins/code/public/actions/route.ts b/x-pack/legacy/plugins/code/public/actions/route.ts index f2d9708cc09e5..3adcdc6111d17 100644 --- a/x-pack/legacy/plugins/code/public/actions/route.ts +++ b/x-pack/legacy/plugins/code/public/actions/route.ts @@ -7,6 +7,6 @@ import { createAction } from 'redux-actions'; export const routePathChange = createAction('ROUTE PATH CHANGE'); -export const repoChange = createAction('REPOSITORY CHANGE'); +export const repoChange = createAction('REPOSITORY CHANGE'); export const revisionChange = createAction('REVISION CHANGE'); export const filePathChange = createAction('FILE PATH CHANGE'); diff --git a/x-pack/legacy/plugins/code/public/app.tsx b/x-pack/legacy/plugins/code/public/app.tsx index 994d0120f9bdd..d0e84bd843d92 100644 --- a/x-pack/legacy/plugins/code/public/app.tsx +++ b/x-pack/legacy/plugins/code/public/app.tsx @@ -7,57 +7,71 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n/react'; +import moment from 'moment'; +import { CoreStart } from 'src/core/public'; import 'ui/autoload/all'; import 'ui/autoload/styles'; import chrome from 'ui/chrome'; // @ts-ignore import { uiModules } from 'ui/modules'; + import { APP_TITLE } from '../common/constants'; import { App } from './components/app'; import { HelpMenu } from './components/help_menu'; import { store } from './stores'; -if (chrome.getInjected('codeUiEnabled')) { - const app = uiModules.get('apps/code'); +export function startApp(coreStart: CoreStart) { + // `getInjected` is not currently available in new platform `coreStart.chrome` + if (chrome.getInjected('codeUiEnabled')) { + // TODO the entire Kibana uses moment, we might need to move it to a more common place + moment.locale(i18n.getLocale()); - app.config(($locationProvider: any) => { - $locationProvider.html5Mode({ - enabled: false, - requireBase: false, - rewriteLinks: false, + const app = uiModules.get('apps/code'); + app.config(($locationProvider: any) => { + $locationProvider.html5Mode({ + enabled: false, + requireBase: false, + rewriteLinks: false, + }); }); - }); - app.config((stateManagementConfigProvider: any) => stateManagementConfigProvider.disable()); - - function RootController($scope: any, $element: any, $http: any) { - const domNode = $element[0]; - - // render react to DOM - render( - - - , - domNode - ); - - // unmount react on controller destroy - $scope.$on('$destroy', () => { - unmountComponentAtNode(domNode); + app.config((stateManagementConfigProvider: any) => stateManagementConfigProvider.disable()); + + function RootController($scope: any, $element: any, $http: any) { + const domNode = $element[0]; + + // render react to DOM + render( + + + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); + } + + // `setRootController` is not available now in `coreStart.chrome` + chrome.setRootController('code', RootController); + coreStart.chrome.setBreadcrumbs([ + { + text: APP_TITLE, + href: '#/', + }, + ]); + + coreStart.chrome.setHelpExtension(domNode => { + render(, domNode); + return () => { + unmountComponentAtNode(domNode); + }; }); } - - chrome.setRootController('code', RootController); - chrome.breadcrumbs.set([ - { - text: APP_TITLE, - href: '#/', - }, - ]); - - chrome.helpExtension.set(domNode => { - render(, domNode); - return () => { - unmountComponentAtNode(domNode); - }; - }); } diff --git a/x-pack/legacy/plugins/code/public/common/types.ts b/x-pack/legacy/plugins/code/public/common/types.ts index c2122022b9d84..e131c231562c7 100644 --- a/x-pack/legacy/plugins/code/public/common/types.ts +++ b/x-pack/legacy/plugins/code/public/common/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { ReactNode } from 'react'; import { SearchScope } from '../../model'; @@ -15,17 +16,33 @@ export enum PathTypes { } export const SearchScopeText = { - [SearchScope.DEFAULT]: 'Search Everything', - [SearchScope.REPOSITORY]: 'Search Repositories', - [SearchScope.SYMBOL]: 'Search Symbols', - [SearchScope.FILE]: 'Search Files', + [SearchScope.DEFAULT]: i18n.translate('xpack.code.searchScope.defaultDropDownOptionLabel', { + defaultMessage: 'Search Everything', + }), + [SearchScope.REPOSITORY]: i18n.translate('xpack.code.searchScope.repositoryDropDownOptionLabel', { + defaultMessage: 'Search Repositories', + }), + [SearchScope.SYMBOL]: i18n.translate('xpack.code.searchScope.symbolDropDownOptionLabel', { + defaultMessage: 'Search Symbols', + }), + [SearchScope.FILE]: i18n.translate('xpack.code.searchScope.fileDropDownOptionLabel', { + defaultMessage: 'Search Files', + }), }; export const SearchScopePlaceholderText = { - [SearchScope.DEFAULT]: 'Type to find anything', - [SearchScope.REPOSITORY]: 'Type to find repositories', - [SearchScope.SYMBOL]: 'Type to find symbols', - [SearchScope.FILE]: 'Type to find files', + [SearchScope.DEFAULT]: i18n.translate('xpack.code.searchScope.defaultPlaceholder', { + defaultMessage: 'Type to find anything', + }), + [SearchScope.REPOSITORY]: i18n.translate('xpack.code.searchScope.repositoryPlaceholder', { + defaultMessage: 'Type to find repositories', + }), + [SearchScope.SYMBOL]: i18n.translate('xpack.code.searchScope.symbolPlaceholder', { + defaultMessage: 'Type to find symbols', + }), + [SearchScope.FILE]: i18n.translate('xpack.code.searchScope.filePlaceholder', { + defaultMessage: 'Type to find files', + }), }; export interface MainRouteParams { diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/admin.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/admin.tsx index bcaba5cc2d6e3..bbdf92b9d54f2 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/admin.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/admin.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { parse as parseQuery } from 'querystring'; import React from 'react'; import { connect } from 'react-redux'; @@ -19,9 +20,8 @@ import { LanguageSeverTab } from './language_server_tab'; import { ProjectTab } from './project_tab'; enum AdminTabs { - projects = 'Repos', - roles = 'Roles', - languageServers = 'LanguageServers', + projects = '0', + languageServers = '1', } interface Props extends RouteComponentProps { @@ -56,12 +56,14 @@ class AdminPage extends React.PureComponent { public tabs = [ { id: AdminTabs.projects, - name: AdminTabs.projects, + name: i18n.translate('xpack.code.adminPage.repoTabLabel', { defaultMessage: 'Repositories' }), disabled: false, }, { id: AdminTabs.languageServers, - name: 'Language servers', + name: i18n.translate('xpack.code.adminPage.langserverTabLabel', { + defaultMessage: 'Language servers', + }), disabled: false, }, ]; diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/empty_project.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/empty_project.tsx index e34c932d6a2ac..b574dc94805c7 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/empty_project.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/empty_project.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { EuiButton, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { capabilities } from 'ui/capabilities'; import { ImportProject } from './import_project'; @@ -19,15 +20,34 @@ export const EmptyProject = () => {
-

You don't have any repos yet

+

+ +

+
+ + {isAdmin && ( +

+ +

+ )}
- {isAdmin &&

Let's import your first one

}
{isAdmin && } - View the Setup Guide + + +
diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/import_project.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/import_project.tsx index 6742ba95a6633..3238d549faab7 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/import_project.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/import_project.tsx @@ -13,6 +13,9 @@ import { EuiGlobalToastList, EuiSpacer, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import React, { ChangeEvent } from 'react'; import { connect } from 'react-redux'; import { closeToast, importRepo } from '../../actions'; @@ -71,11 +74,15 @@ class CodeImportProject extends React.PureComponent< - Import + diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/language_server_tab.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/language_server_tab.tsx index 44689603bc106..5c33aa513c839 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/language_server_tab.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/language_server_tab.tsx @@ -20,6 +20,7 @@ import { EuiTabbedContent, EuiText, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { connect } from 'react-redux'; import { InstallationType } from '../../../common/installation'; @@ -51,24 +52,40 @@ const LanguageServerLi = (props: { let button = null; let state = null; if (status === LanguageServerStatus.RUNNING) { - state = Running ...; + state = ( + + + + ); } else if (status === LanguageServerStatus.NOT_INSTALLED) { state = ( - Not Installed + ); } else if (status === LanguageServerStatus.READY) { state = ( - Installed + ); } if (props.languageServer.installationType === InstallationType.Plugin) { button = ( - Setup + ); } @@ -139,12 +156,13 @@ class AdminLanguageSever extends React.PureComponent {

- {this.props.languageServers.length} - {this.props.languageServers.length > 1 ? ( - Language servers - ) : ( - Language server - )} + + +

@@ -186,16 +204,48 @@ const LanguageServerInstruction = (props: {
-

Install

+

+ +

    -
  1. Stop your kibana Code node.
  2. -
  3. Use the following command to install the {props.name} language server.
  4. +
  5. + +
  6. +
  7. + +
{installCode} -

Uninstall

+

+ +

    -
  1. Stop your kibana Code node.
  2. -
  3. Use the following command to remove the {props.name} language server.
  4. +
  5. + +
  6. +
  7. + +
bin/kibana-plugin remove {props.pluginName} @@ -213,14 +263,22 @@ const LanguageServerInstruction = (props: { - Installation Instructions + + + - Close + diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/project_item.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/project_item.tsx index 62fbd6926992c..c19c6c91f3eac 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/project_item.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/project_item.tsx @@ -18,10 +18,12 @@ import { EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; import { Repository, WorkerReservedProgress } from '../../../model'; import { deleteRepo, indexRepo, initRepoCommand } from '../../actions'; import { RepoState, RepoStatus } from '../../actions/status'; @@ -88,33 +90,70 @@ class CodeProjectItem extends React.PureComponent< let disableRepoLink = false; let hasError = false; if (!status) { - footer =
INIT...
; + footer = ( +
+ +
+ ); } else if (status.state === RepoState.READY) { footer = (
- LAST UPDATED: {moment(status.timestamp).fromNow()} + + :{' '} + {moment(status.timestamp) + .locale(i18n.getLocale()) + .fromNow()}
); } else if (status.state === RepoState.DELETING) { - footer =
DELETING...
; + footer = ( +
+ +
+ ); } else if (status.state === RepoState.INDEXING) { footer = (
- INDEXING... +
); } else if (status.state === RepoState.CLONING) { - footer =
CLONING...
; + footer = ( +
+ +
+ ); } else if (status.state === RepoState.DELETE_ERROR) { - footer =
ERROR DELETE REPO
; + footer = ( +
+ +
+ ); hasError = true; } else if (status.state === RepoState.INDEX_ERROR) { - footer =
ERROR INDEX REPO
; + footer = ( +
+ +
+ ); hasError = true; } else if (status.state === RepoState.CLONE_ERROR) { footer = (
- ERROR CLONING REPO  + +   @@ -161,7 +200,10 @@ class CodeProjectItem extends React.PureComponent< > - Settings +
@@ -177,7 +219,10 @@ class CodeProjectItem extends React.PureComponent< > - Reindex +
@@ -193,7 +238,10 @@ class CodeProjectItem extends React.PureComponent< > - Delete +
@@ -244,11 +292,17 @@ class CodeProjectItem extends React.PureComponent< return ( @@ -259,11 +313,17 @@ class CodeProjectItem extends React.PureComponent< return ( diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/project_tab.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/project_tab.tsx index 5957cd4d5627a..d5e0f4a52d12d 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/project_tab.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/project_tab.tsx @@ -24,6 +24,8 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { ChangeEvent } from 'react'; import { connect } from 'react-redux'; @@ -62,10 +64,36 @@ const sortFunctionsFactory = (status: { [key: string]: RepoStatus }) => { }; const sortOptions = [ - { value: SortOptionsValue.AlphabeticalAsc, inputDisplay: 'A to Z' }, - { value: SortOptionsValue.AlphabeticalDesc, inputDisplay: 'Z to A' }, - { value: SortOptionsValue.UpdatedAsc, inputDisplay: 'Last Updated ASC' }, - { value: SortOptionsValue.UpdatedDesc, inputDisplay: 'Last Updated DESC' }, + { + value: SortOptionsValue.AlphabeticalAsc, + inputDisplay: i18n.translate('xpack.code.adminPage.repoTab.sort.aToZDropDownOptionLabel', { + defaultMessage: 'A to Z', + }), + }, + { + value: SortOptionsValue.AlphabeticalDesc, + inputDisplay: i18n.translate('xpack.code.adminPage.repoTab.sort.zToADropDownOptionLabel', { + defaultMessage: 'Z to A', + }), + }, + { + value: SortOptionsValue.UpdatedAsc, + inputDisplay: i18n.translate( + 'xpack.code.adminPage.repoTab.sort.updatedAscDropDownOptionLabel', + { + defaultMessage: 'Last Updated ASC', + } + ), + }, + { + value: SortOptionsValue.UpdatedDesc, + inputDisplay: i18n.translate( + 'xpack.code.adminPage.repoTab.sort.updatedDescDropDownOptionLabel', + { + defaultMessage: 'Last Updated DESC', + } + ), + }, // { value: SortOptionsValue.recently_added, inputDisplay: 'Recently Added' }, ]; @@ -148,14 +176,29 @@ class CodeProjectTab extends React.PureComponent { - Import a new repo + + + -

Repository URL

+

+ +

- + {
- Cancel + + + - Import project +
@@ -226,7 +277,11 @@ class CodeProjectTab extends React.PureComponent { - + { onClick={this.openModal} data-test-subj="newProjectButton" > - Import a new repo + )} @@ -252,8 +310,11 @@ class CodeProjectTab extends React.PureComponent {

- {projectsCount} - {projectsCount === 1 ? Repo : Repos} +

diff --git a/x-pack/legacy/plugins/code/public/components/admin_page/setup_guide.tsx b/x-pack/legacy/plugins/code/public/components/admin_page/setup_guide.tsx index ac13d54bdf1d0..29d2cdad9a466 100644 --- a/x-pack/legacy/plugins/code/public/components/admin_page/setup_guide.tsx +++ b/x-pack/legacy/plugins/code/public/components/admin_page/setup_guide.tsx @@ -15,6 +15,8 @@ import { EuiTitle, EuiLink, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; @@ -23,80 +25,149 @@ import { RootState } from '../../reducers'; const steps = [ { - title: 'Check if multiple Kibana instances are used as a cluster', + title: i18n.translate('xpack.code.adminPage.setupGuide.checkMultiInstanceTitle', { + defaultMessage: 'Check if multiple Kibana instances are used as a clusterURL', + }), children: ( -

If you are using single Kibana instance, you can skip this step.

- If you are using multiple Kibana instances, you need to assign one Kibana instance as - `Code node`. To do this, add the following line of code into your kibana.yml file of every - Kibana instance and restart the instances: + +

+ +

+

           xpack.code.codeNodeUrl: 'http://$YourCodeNodeAddress'
         

- Where `$YourCodeNoteAddress` is the URL of your assigned Code node accessible by other - Kibana instances. +

), }, { - title: 'Install extra language support optionally', + title: i18n.translate('xpack.code.adminPage.setupGuide.installExtraLangSupportTitle', { + defaultMessage: 'Install extra language support optionally', + }), children: (

- Look{' '} - - here - {' '} - to learn more about supported languages and language server installation. + + + + ), + }} + />

- If you need Java language support, you can manage language server installation{' '} - here + + + + ), + }} + />

), }, { - title: 'Add a repository to Code', + title: i18n.translate('xpack.code.adminPage.setupGuide.addRepositoryTitle', { + defaultMessage: 'Add a repository to Code', + }), children: (

- Import{' '} - - {' '} - a sample repo - {' '} - or{' '} - - your own repo - - . It is as easy as copy and paste git clone URLs to Code. + + + + ), + ownRepoLink: ( + + + + ), + }} + />

), }, { - title: 'Verify the repo is successfully imported', + title: i18n.translate('xpack.code.adminPage.setupGuide.verifyImportTitle', { + defaultMessage: 'Verify the repo is successfully imported', + }), children: (

- You can verify your repo is successfully imported by{' '} - - searching - {' '} - and{' '} - - navigating - {' '} - the repo. If language support is available to the repo, make sure{' '} - - semantic navigation - {' '} - is available as well. + + + + ), + navigatingLink: ( + + + + ), + semanticNavigationLink: ( + + + + ), + }} + />

), @@ -106,11 +177,16 @@ const steps = [ const toastMessage = (

- We’ve made some changes to roles and permissions in Kibana. Read more about how these changes - affect your Code implementation below.{' '} +

- Learn more +
); @@ -133,7 +209,9 @@ class SetupGuidePage extends React.PureComponent<{ setupOk?: boolean }, { hideTo - - Back To project dashboard - + + + + + )} -

Getting started in Elastic Code

+

+ +

diff --git a/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.test.tsx b/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.test.tsx index a3af6f3c144da..77146b827fae2 100644 --- a/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.test.tsx +++ b/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { match } from 'react-router-dom'; import renderer from 'react-test-renderer'; import { MainRouteParams, PathTypes } from '../../common/types'; -import { createHistory, createLocation, createMatch, mockFunction } from '../../utils/test_utils'; +import { createHistory, createLocation, createMatch } from '../../utils/test_utils'; import props from './__fixtures__/props.json'; import { CodeFileTree } from './file_tree'; @@ -38,12 +38,9 @@ test('render correctly', () => { .create( ) diff --git a/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.tsx b/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.tsx index b48bcb9a4a7aa..418311d6028bb 100644 --- a/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.tsx +++ b/x-pack/legacy/plugins/code/public/components/file_tree/file_tree.tsx @@ -11,28 +11,55 @@ import classes from 'classnames'; import { connect } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { FileTree as Tree, FileTreeItemType } from '../../../model'; -import { closeTreePath, openTreePath } from '../../actions'; import { EuiSideNavItem, MainRouteParams, PathTypes } from '../../common/types'; import { RootState } from '../../reducers'; import { encodeRevisionString } from '../../../common/uri_util'; interface Props extends RouteComponentProps { node?: Tree; - closeTreePath: (paths: string) => void; - openTreePath: (paths: string) => void; - openedPaths: string[]; isNotFound: boolean; } -export class CodeFileTree extends React.Component { +export class CodeFileTree extends React.Component { constructor(props: Props) { super(props); const { path } = props.match.params; if (path) { - props.openTreePath(path); + this.state = { + openPaths: CodeFileTree.getOpenPaths(path, []), + }; + } else { + this.state = { + openPaths: [], + }; } } + static getOpenPaths = (path: string, openPaths: string[]) => { + let p = path; + const newOpenPaths = [...openPaths]; + const pathSegs = p.split('/'); + while (!openPaths.includes(p)) { + newOpenPaths.push(p); + pathSegs.pop(); + if (pathSegs.length <= 0) { + break; + } + p = pathSegs.join('/'); + } + return newOpenPaths; + }; + + openTreePath = (path: string) => { + this.setState({ openPaths: CodeFileTree.getOpenPaths(path, this.state.openPaths) }); + }; + + closeTreePath = (path: string) => { + const isSubFolder = (p: string) => p.startsWith(path + '/'); + const newOpenPaths = this.state.openPaths.filter(p => !(p === path || isSubFolder(p))); + this.setState({ openPaths: newOpenPaths }); + }; + public onClick = (node: Tree) => { const { resource, org, repo, revision, path } = this.props.match.params; if (!(path === node.path)) { @@ -50,9 +77,9 @@ export class CodeFileTree extends React.Component { public toggleTree = (path: string) => { if (this.isPathOpen(path)) { - this.props.closeTreePath(path); + this.closeTreePath(path); } else { - this.props.openTreePath(path); + this.openTreePath(path); } }; @@ -241,24 +268,13 @@ export class CodeFileTree extends React.Component { private isPathOpen(path: string) { if (this.props.isNotFound) return false; - return this.props.openedPaths.includes(path); + return this.state.openPaths.includes(path); } } const mapStateToProps = (state: RootState) => ({ node: state.fileTree.tree, - openedPaths: state.fileTree.openedPaths, isNotFound: state.file.isNotFound, }); -const mapDispatchToProps = { - closeTreePath, - openTreePath, -}; - -export const FileTree = withRouter( - connect( - mapStateToProps, - mapDispatchToProps - )(CodeFileTree) -); +export const FileTree = withRouter(connect(mapStateToProps)(CodeFileTree)); diff --git a/x-pack/legacy/plugins/code/public/components/help_menu/help_menu.tsx b/x-pack/legacy/plugins/code/public/components/help_menu/help_menu.tsx index 1a45cb34418a8..009db11fe2f70 100644 --- a/x-pack/legacy/plugins/code/public/components/help_menu/help_menu.tsx +++ b/x-pack/legacy/plugins/code/public/components/help_menu/help_menu.tsx @@ -5,8 +5,9 @@ */ import React from 'react'; -import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { EuiButton, EuiHorizontalRule, EuiText, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { documentationLinks } from '../../lib/documentation_links'; export class HelpMenu extends React.PureComponent { @@ -16,15 +17,30 @@ export class HelpMenu extends React.PureComponent { -

For Code specific information

+

+ +

- - Setup Guide + + - Code documentation + ); diff --git a/x-pack/legacy/plugins/code/public/components/hover/hover_buttons.tsx b/x-pack/legacy/plugins/code/public/components/hover/hover_buttons.tsx index 0946f41a4e59d..818a9570982b8 100644 --- a/x-pack/legacy/plugins/code/public/components/hover/hover_buttons.tsx +++ b/x-pack/legacy/plugins/code/public/components/hover/hover_buttons.tsx @@ -5,6 +5,8 @@ */ import { EuiButton, EuiFlexGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import React from 'react'; import { HoverState } from './hover_widget'; @@ -25,7 +27,10 @@ export class HoverButtons extends React.PureComponent { onClick={this.props.gotoDefinition} data-test-subj="codeGoToDefinitionButton" > - Goto Definition + { onClick={this.props.findReferences} data-test-subj="codeFindReferenceButton" > - Find Reference +
diff --git a/x-pack/legacy/plugins/code/public/components/hover/hover_widget.tsx b/x-pack/legacy/plugins/code/public/components/hover/hover_widget.tsx index b6a9ef31ebfc6..69dcc2ebea6b7 100644 --- a/x-pack/legacy/plugins/code/public/components/hover/hover_widget.tsx +++ b/x-pack/legacy/plugins/code/public/components/hover/hover_widget.tsx @@ -5,6 +5,7 @@ */ import { EuiText, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { MarkedString } from 'vscode-languageserver-types'; import ReactMarkdown from 'react-markdown'; @@ -98,9 +99,19 @@ export class HoverWidget extends React.PureComponent { {/* // @ts-ignore */} -

Language Server is initializing…

+

+ +

-

Depending on the size of your repo, this could take a few minutes.

+

+ +

diff --git a/x-pack/legacy/plugins/code/public/components/main/clone_status.tsx b/x-pack/legacy/plugins/code/public/components/main/clone_status.tsx index beadc6c05bc33..195fd89422faa 100644 --- a/x-pack/legacy/plugins/code/public/components/main/clone_status.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/clone_status.tsx @@ -5,8 +5,11 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import theme from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { CloneProgress } from '../../../model'; interface Props { @@ -17,20 +20,38 @@ interface Props { export const CloneStatus = (props: Props) => { const { progress: progressRate, cloneProgress, repoName } = props; - let progress = `Receiving objects: ${progressRate.toFixed(2)}%`; + let progress = i18n.translate( + 'xpack.code.mainPage.content.cloneStatus.progress.receivingRateOnlyText', + { + defaultMessage: 'Receiving objects: {progressRate}%', + values: { progressRate: progressRate.toFixed(2) }, + } + ); if (progressRate < 0) { - progress = 'Clone Failed'; + progress = i18n.translate('xpack.code.mainPage.content.cloneStatus.progress.cloneFailedText', { + defaultMessage: 'Clone Failed', + }); } else if (cloneProgress) { const { receivedObjects, totalObjects, indexedObjects } = cloneProgress; if (receivedObjects === totalObjects) { - progress = `Indexing objects: ${((indexedObjects * 100) / totalObjects).toFixed( - 2 - )}% (${indexedObjects}/${totalObjects})`; + progress = i18n.translate('xpack.code.mainPage.content.cloneStatus.progress.indexingText', { + defaultMessage: 'Indexing objects: {progressRate}% {indexedObjects}/{totalObjects}', + values: { + progressRate: ((indexedObjects * 100) / totalObjects).toFixed(2), + indexedObjects, + totalObjects, + }, + }); } else { - progress = `Receiving objects: ${((receivedObjects * 100) / totalObjects).toFixed( - 2 - )}% (${receivedObjects}/${totalObjects})`; + progress = i18n.translate('xpack.code.mainPage.content.cloneStatus.progress.receivingText', { + defaultMessage: 'Receiving objects: {progressRate}% {receivedObjects}/{totalObjects}', + values: { + progressRate: ((receivedObjects * 100) / totalObjects).toFixed(2), + receivedObjects, + totalObjects, + }, + }); } } return ( @@ -39,13 +60,20 @@ export const CloneStatus = (props: Props) => { - {repoName} is cloning + {repoName}{' '} + - Your project will be available when this process is complete + diff --git a/x-pack/legacy/plugins/code/public/components/main/commit_history.tsx b/x-pack/legacy/plugins/code/public/components/main/commit_history.tsx index c60133d1a9d2c..e731c43fd439f 100644 --- a/x-pack/legacy/plugins/code/public/components/main/commit_history.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/commit_history.tsx @@ -13,10 +13,13 @@ import { EuiText, EuiTextColor, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import _ from 'lodash'; import moment from 'moment'; import React from 'react'; import { connect } from 'react-redux'; +import { i18n } from '@kbn/i18n'; import { CommitInfo } from '../../../model/commit'; import { CommitLink } from '../diff_page/commit_link'; import { RootState } from '../../reducers'; @@ -64,7 +67,14 @@ const CommitGroup = (props: { commits: CommitInfo[]; date: string; repoUri: stri

- Commits on {props.date} + + {' '} + {} +

@@ -94,12 +104,17 @@ export const PageButtons = (props: { isDisabled={props.disabled} size="s" > - More +
); +const commitDateFormatMap: { [key: string]: string } = { + en: 'MMMM Do, YYYY', + 'zh-cn': 'YYYY年MoDo', +}; + export const CommitHistoryComponent = (props: { commits: CommitInfo[]; repoUri: string; @@ -111,10 +126,13 @@ export const CommitHistoryComponent = (props: { }) => { const commits = _.groupBy(props.commits, commit => moment(commit.updated).format('YYYYMMDD')); const commitDates = Object.keys(commits).sort((a, b) => b.localeCompare(a)); // sort desc + const locale = i18n.getLocale(); + const commitDateFormat = + locale in commitDateFormatMap ? commitDateFormatMap[locale] : commitDateFormatMap.en; const commitList = commitDates.map(cd => ( diff --git a/x-pack/legacy/plugins/code/public/components/main/content.tsx b/x-pack/legacy/plugins/code/public/components/main/content.tsx index 3e3c3d67d8fa9..f9d21426051ff 100644 --- a/x-pack/legacy/plugins/code/public/components/main/content.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/content.tsx @@ -5,13 +5,16 @@ */ import { EuiButton, EuiButtonGroup, EuiFlexGroup, EuiTitle, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import 'github-markdown-css/github-markdown.css'; import React from 'react'; import ReactMarkdown from 'react-markdown'; import { connect } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; import { withRouter } from 'react-router-dom'; -import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { RepositoryUtils } from '../../../common/repository_utils'; import { @@ -69,13 +72,6 @@ enum ButtonOption { Folder = 'Directory', } -enum ButtonLabel { - Code = 'Code', - Content = 'Content', - Download = 'Download', - Raw = 'Raw', -} - class CodeContent extends React.PureComponent { public findNode = (pathSegments: string[], node: FileTree): FileTree | undefined => { if (!node) { @@ -126,7 +122,9 @@ class CodeContent extends React.PureComponent { const { path, resource, org, repo, revision } = this.props.match.params; const repoUri = `${resource}/${org}/${repo}`; window.open( - chrome.addBasePath(`/app/code/repo/${repoUri}/raw/${encodeRevisionString(revision)}/${path}`) + npStart.core.http.basePath.prepend( + `/app/code/repo/${repoUri}/raw/${encodeRevisionString(revision)}/${path}` + ) ); }; @@ -161,20 +159,40 @@ class CodeContent extends React.PureComponent { const buttonOptions = [ { id: ButtonOption.Code, - label: isText && !isMarkdown ? ButtonLabel.Code : ButtonLabel.Content, + label: + isText && !isMarkdown + ? i18n.translate('xpack.code.mainPage.content.buttons.codeButtonLabel', { + defaultMessage: 'Code', + }) + : i18n.translate('xpack.code.mainPage.content.buttons.contentButtonLabel', { + defaultMessage: 'content', + }), }, { id: ButtonOption.Blame, - label: ButtonOption.Blame, + label: i18n.translate('xpack.code.mainPage.content.buttons.blameButtonLabel', { + defaultMessage: 'Blame', + }), isDisabled: isUnsupported || isImage || isOversize, }, { id: ButtonOption.History, - label: ButtonOption.History, + label: i18n.translate('xpack.code.mainPage.content.buttons.historyButtonLabel', { + defaultMessage: 'History', + }), }, ]; const rawButtonOptions = [ - { id: 'Raw', label: isText ? ButtonLabel.Raw : ButtonLabel.Download }, + { + id: 'Raw', + label: isText + ? i18n.translate('xpack.code.mainPage.content.buttons.rawButtonLabel', { + defaultMessage: 'Raw', + }) + : i18n.translate('xpack.code.mainPage.content.buttons.downloadButtonLabel', { + defaultMessage: 'Download', + }), + }, ]; return ( @@ -210,11 +228,15 @@ class CodeContent extends React.PureComponent { options={[ { id: ButtonOption.Folder, - label: ButtonOption.Folder, + label: i18n.translate('xpack.code.mainPage.content.buttons.folderButtonLabel', { + defaultMessage: 'Directory', + }), }, { id: ButtonOption.History, - label: ButtonOption.History, + label: i18n.translate('xpack.code.mainPage.content.buttons.historyButtonLabel', { + defaultMessage: 'History', + }), }, ]} type="single" @@ -296,7 +318,12 @@ class CodeContent extends React.PureComponent { header={ -

Recent Commits

+

+ +

{ revision )}/${path || ''}`} > - View All +
} @@ -358,7 +388,9 @@ class CodeContent extends React.PureComponent {
); } else if (isImage) { - const rawUrl = chrome.addBasePath(`/app/code/repo/${repoUri}/raw/${revision}/${path}`); + const rawUrl = npStart.core.http.basePath.prepend( + `/app/code/repo/${repoUri}/raw/${revision}/${path}` + ); return (
{rawUrl} @@ -383,7 +415,12 @@ class CodeContent extends React.PureComponent { repoUri={repoUri} header={ -

Commit History

+

+ +

} showPagination={true} diff --git a/x-pack/legacy/plugins/code/public/components/main/directory.tsx b/x-pack/legacy/plugins/code/public/components/main/directory.tsx index d863bb0e84bfa..29667e6e44024 100644 --- a/x-pack/legacy/plugins/code/public/components/main/directory.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/directory.tsx @@ -14,6 +14,8 @@ import { EuiLoadingSpinner, EuiSpacer, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + import React from 'react'; import { Link, RouteComponentProps, withRouter } from 'react-router-dom'; import { FileTree, FileTreeItemType } from '../../../model'; @@ -84,9 +86,23 @@ export const Directory = withRouter((props: Props) => { const { resource, org, repo, revision } = props.match.params; const getUrl = (pathType: PathTypes) => (path: string) => `/${resource}/${org}/${repo}/${pathType}/${encodeRevisionString(revision)}/${path}`; - const fileList = ; + const fileList = ( + + ); const folderList = ( - + ); const children = props.loading ? (
diff --git a/x-pack/legacy/plugins/code/public/components/main/main.tsx b/x-pack/legacy/plugins/code/public/components/main/main.tsx index 4baf9201865dd..d852e0edceb4f 100644 --- a/x-pack/legacy/plugins/code/public/components/main/main.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/main.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { RouteComponentProps } from 'react-router-dom'; -import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { APP_TITLE } from '../../../common/constants'; import { MainRouteParams } from '../../common/types'; import { ShortcutsProvider } from '../shortcuts'; @@ -37,7 +37,7 @@ class CodeMain extends React.Component { public setBreadcrumbs() { const { resource, org, repo } = this.props.match.params; - chrome.breadcrumbs.set([ + npStart.core.chrome.setBreadcrumbs([ { text: APP_TITLE, href: '#/' }, { text: `${org} → ${repo}`, @@ -47,7 +47,7 @@ class CodeMain extends React.Component { } public componentWillUnmount() { - chrome.breadcrumbs.set([{ text: APP_TITLE, href: '#/' }]); + npStart.core.chrome.setBreadcrumbs([{ text: APP_TITLE, href: '#/' }]); } public render() { diff --git a/x-pack/legacy/plugins/code/public/components/main/side_tabs.tsx b/x-pack/legacy/plugins/code/public/components/main/side_tabs.tsx index fb8702da02a34..1d9fbf5110cc5 100644 --- a/x-pack/legacy/plugins/code/public/components/main/side_tabs.tsx +++ b/x-pack/legacy/plugins/code/public/components/main/side_tabs.tsx @@ -9,6 +9,7 @@ import { parse as parseQuery } from 'querystring'; import React from 'react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; import { QueryString } from 'ui/utils/query_string'; +import { i18n } from '@kbn/i18n'; import { MainRouteParams } from '../../common/types'; import { FileTree } from '../file_tree/file_tree'; import { Shortcut } from '../shortcuts'; @@ -56,28 +57,44 @@ class CodeSideTabs extends React.PureComponent { public get tabs() { const { languageServerInitializing, loadingFileTree, loadingStructureTree } = this.props; const fileTabContent = loadingFileTree ? ( - this.renderLoadingSpinner('Loading file tree') + this.renderLoadingSpinner( + i18n.translate('xpack.code.mainPage.sideTab.loadingFileTreeText', { + defaultMessage: 'Loading file tree', + }) + ) ) : (
{}
); let structureTabContent: React.ReactNode; if (languageServerInitializing) { - structureTabContent = this.renderLoadingSpinner('Language server is initializing'); + structureTabContent = this.renderLoadingSpinner( + i18n.translate('xpack.code.mainPage.sideTab.languageServerInitializingText', { + defaultMessage: 'Language server is initializing', + }) + ); } else if (loadingStructureTree) { - structureTabContent = this.renderLoadingSpinner('Loading structure tree'); + structureTabContent = this.renderLoadingSpinner( + i18n.translate('xpack.code.mainPage.sideTab.loadingStructureTreeText', { + defaultMessage: 'Loading structure tree', + }) + ); } else { structureTabContent = ; } return [ { id: Tabs.file, - name: 'Files', + name: i18n.translate('xpack.code.mainPage.sideTab.fileTabLabel', { + defaultMessage: 'Files', + }), content: fileTabContent, 'data-test-subj': `codeFileTreeTab${this.sideTab === Tabs.file ? 'Active' : ''}`, }, { id: Tabs.structure, - name: 'Structure', + name: i18n.translate('xpack.code.mainPage.sideTab.structureTabLabel', { + defaultMessage: 'Structure', + }), content: structureTabContent, disabled: !(this.props.currentTree && this.props.currentTree.type === FileTreeItemType.File) || diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap b/x-pack/legacy/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap index 47a18021ebab3..62f2cf332389b 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap +++ b/x-pack/legacy/plugins/code/public/components/query_bar/components/__snapshots__/query_bar.test.tsx.snap @@ -623,7 +623,19 @@ exports[`render correctly with empty query string 1`] = ` - Search Filters + + + Search Filters + + @@ -1332,7 +1344,19 @@ exports[`render correctly with input query string changed 1`] = ` - Search Filters + + + Search Filters + + diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/components/options.tsx b/x-pack/legacy/plugins/code/public/components/query_bar/components/options.tsx index 7ae3f8b4d1606..86f74a498ad5f 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/components/options.tsx +++ b/x-pack/legacy/plugins/code/public/components/query_bar/components/options.tsx @@ -20,6 +20,8 @@ import { EuiNotificationBadge, } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { unique } from 'lodash'; import React, { Component } from 'react'; import { SearchOptions as ISearchOptions, Repository } from '../../../../model'; @@ -114,6 +116,14 @@ export class SearchOptions extends Component { ); }); + const repoCandidates = this.state.query + ? this.props.repoSearchResults.map(repo => ({ + label: repo.name, + })) + : this.props.defaultRepoOptions.map(repo => ({ + label: repo.name, + })); + optionsFlyout = ( { {repoScope.length} - {' '} - Search Filters{' '} + -

Repo Scope

+

+ +

- Add indexed repos to your search scope + + + ({ - label: repo.name, - })) - : this.props.defaultRepoOptions.map(repo => ({ - label: repo.name, - })) - } + options={repoCandidates} selectedOptions={[]} isLoading={this.props.searchLoading} onChange={this.onRepoChange} @@ -163,7 +181,10 @@ export class SearchOptions extends Component { - Apply and Close +
@@ -178,7 +199,13 @@ export class SearchOptions extends Component { {repoScope.length} - Search Filters + + +
{optionsFlyout} diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap index 1d031aeff8eca..05d8aa604db4f 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap +++ b/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/__snapshots__/suggestions_component.test.tsx.snap @@ -195,8 +195,19 @@ exports[`render full suggestions component 1`] = `
- 1 - Result + + + 1 Result + +
@@ -347,8 +358,19 @@ exports[`render full suggestions component 1`] = `
- 1 - Result + + + 1 Result + +
@@ -463,9 +485,19 @@ exports[`render full suggestions component 1`] = `
- 2 - Result - s + + + 2 Results + +
@@ -568,7 +600,16 @@ exports[`render full suggestions component 1`] = ` href="/search?q=string" onClick={[Function]} > - View More + + + + View More + +
@@ -584,7 +625,15 @@ exports[`render full suggestions component 1`] = `
- Press ⮐ Return for Full Text Search + + + Press ⮐ Return for Full Text Search + +
diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx b/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx index 0125aa0113cfa..eed549b165464 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx +++ b/x-pack/legacy/plugins/code/public/components/query_bar/components/typeahead/suggestions_component.tsx @@ -5,6 +5,9 @@ */ import { EuiFlexGroup, EuiText, EuiToken, IconType } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + import { isEmpty } from 'lodash'; import React, { Component } from 'react'; import { Link } from 'react-router-dom'; @@ -53,7 +56,10 @@ export class SuggestionsComponent extends Component { {this.renderSuggestionGroups()}
- Press ⮐ Return for Full Text Search +
@@ -109,15 +115,24 @@ export class SuggestionsComponent extends Component {
- {total} Result - {total === 1 ? '' : 's'} +
); const viewMore = (
- View More + + {' '} + +
); @@ -153,11 +168,17 @@ export class SuggestionsComponent extends Component { private getGroupTitle(type: AutocompleteSuggestionType): string { switch (type) { case AutocompleteSuggestionType.FILE: - return 'Files'; + return i18n.translate('xpack.code.searchBar.fileGroupTitle', { + defaultMessage: 'Files', + }); case AutocompleteSuggestionType.REPOSITORY: - return 'Repos'; + return i18n.translate('xpack.code.searchBar.repositorylGroupTitle', { + defaultMessage: 'Repos', + }); case AutocompleteSuggestionType.SYMBOL: - return 'Symbols'; + return i18n.translate('xpack.code.searchBar.symbolGroupTitle', { + defaultMessage: 'Symbols', + }); } } diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts index 2e49e769f9632..873c22bb91f2c 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts +++ b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/file_suggestions_provider.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { AbstractSuggestionsProvider, @@ -28,9 +28,7 @@ export class FileSuggestionsProvider extends AbstractSuggestionsProvider { if (repoScope && repoScope.length > 0) { queryParams.repoScope = repoScope.join(','); } - const res = await kfetch({ - pathname: `/api/code/suggestions/doc`, - method: 'get', + const res = await npStart.core.http.get(`/api/code/suggestions/doc`, { query: queryParams, }); const suggestions = Array.from(res.results as SearchResultItem[]) diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts index 5c5a4129c1b49..4696c11e1b90c 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts +++ b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/repository_suggestions_provider.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { AbstractSuggestionsProvider, @@ -28,9 +28,7 @@ export class RepositorySuggestionsProvider extends AbstractSuggestionsProvider { if (repoScope && repoScope.length > 0) { queryParams.repoScope = repoScope.join(','); } - const res = await kfetch({ - pathname: `/api/code/suggestions/repo`, - method: 'get', + const res = await npStart.core.http.get(`/api/code/suggestions/repo`, { query: queryParams, }); const suggestions = Array.from(res.repositories as Repository[]) diff --git a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts index 0d72e41921cf9..0e321d6cf1ec7 100644 --- a/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts +++ b/x-pack/legacy/plugins/code/public/components/query_bar/suggestions/symbol_suggestions_provider.ts @@ -5,7 +5,7 @@ */ import { DetailSymbolInformation } from '@elastic/lsp-extension'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { Location } from 'vscode-languageserver'; import { @@ -32,9 +32,7 @@ export class SymbolSuggestionsProvider extends AbstractSuggestionsProvider { if (repoScope && repoScope.length > 0) { queryParams.repoScope = repoScope.join(','); } - const res = await kfetch({ - pathname: `/api/code/suggestions/symbol`, - method: 'get', + const res = await npStart.core.http.get(`/api/code/suggestions/symbol`, { query: queryParams, }); const suggestions = Array.from(res.symbols as DetailSymbolInformation[]) diff --git a/x-pack/legacy/plugins/code/public/components/search_page/code_result.tsx b/x-pack/legacy/plugins/code/public/components/search_page/code_result.tsx index 82aea7a91d525..632c221f0680b 100644 --- a/x-pack/legacy/plugins/code/public/components/search_page/code_result.tsx +++ b/x-pack/legacy/plugins/code/public/components/search_page/code_result.tsx @@ -5,6 +5,7 @@ */ import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { IPosition } from 'monaco-editor'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -63,7 +64,10 @@ export class CodeResult extends React.PureComponent { {hits} -  hits from  + {filePath} diff --git a/x-pack/legacy/plugins/code/public/components/search_page/empty_placeholder.tsx b/x-pack/legacy/plugins/code/public/components/search_page/empty_placeholder.tsx index 4db575438dbac..f5117d97471f8 100644 --- a/x-pack/legacy/plugins/code/public/components/search_page/empty_placeholder.tsx +++ b/x-pack/legacy/plugins/code/public/components/search_page/empty_placeholder.tsx @@ -5,6 +5,7 @@ */ import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; export const EmptyPlaceholder = (props: any) => { @@ -25,11 +26,17 @@ export const EmptyPlaceholder = (props: any) => { - Hmmm... we looked for that, but couldn’t find anything. + - You can search for something else or modify your search settings. + @@ -41,7 +48,10 @@ export const EmptyPlaceholder = (props: any) => { } }} > - Modify your search settings +
diff --git a/x-pack/legacy/plugins/code/public/components/search_page/scope_tabs.tsx b/x-pack/legacy/plugins/code/public/components/search_page/scope_tabs.tsx index cffa3e9e679aa..8370400bdef8f 100644 --- a/x-pack/legacy/plugins/code/public/components/search_page/scope_tabs.tsx +++ b/x-pack/legacy/plugins/code/public/components/search_page/scope_tabs.tsx @@ -5,6 +5,7 @@ */ import { EuiTab, EuiTabs } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import querystring from 'querystring'; import React from 'react'; import url from 'url'; @@ -44,14 +45,20 @@ export class ScopeTabs extends React.PureComponent { isSelected={this.props.scope !== SearchScope.REPOSITORY} onClick={this.onTabClicked(SearchScope.DEFAULT)} > - Code + - Repository +
diff --git a/x-pack/legacy/plugins/code/public/components/search_page/search.tsx b/x-pack/legacy/plugins/code/public/components/search_page/search.tsx index c52769b28e101..effb4da4c7f92 100644 --- a/x-pack/legacy/plugins/code/public/components/search_page/search.tsx +++ b/x-pack/legacy/plugins/code/public/components/search_page/search.tsx @@ -5,6 +5,7 @@ */ import { EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import querystring from 'querystring'; import React from 'react'; import { connect } from 'react-redux'; @@ -166,7 +167,11 @@ class SearchPage extends React.PureComponent { const statsComp = (

- Showing {total > 0 ? from : 0} - {to} of {total} results. +

); @@ -186,7 +191,11 @@ class SearchPage extends React.PureComponent { const statsComp = (

- Showing {total > 0 ? from : 0} - {to} of {total} results. +

); diff --git a/x-pack/legacy/plugins/code/public/components/search_page/side_bar.tsx b/x-pack/legacy/plugins/code/public/components/search_page/side_bar.tsx index 617315e1e12b3..7d263fbbf4a2e 100644 --- a/x-pack/legacy/plugins/code/public/components/search_page/side_bar.tsx +++ b/x-pack/legacy/plugins/code/public/components/search_page/side_bar.tsx @@ -13,6 +13,7 @@ import { EuiTitle, EuiToken, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { RepositoryUtils } from '../../../common/repository_utils'; @@ -116,7 +117,12 @@ export class SideBar extends React.PureComponent { -

Repositories

+

+ +

@@ -136,7 +142,12 @@ export class SideBar extends React.PureComponent { -

Languages

+

+ +

diff --git a/x-pack/legacy/plugins/code/public/components/status_indicator/status_indicator.tsx b/x-pack/legacy/plugins/code/public/components/status_indicator/status_indicator.tsx index 5c8ab208eb585..4c4454c2003ea 100644 --- a/x-pack/legacy/plugins/code/public/components/status_indicator/status_indicator.tsx +++ b/x-pack/legacy/plugins/code/public/components/status_indicator/status_indicator.tsx @@ -17,6 +17,7 @@ import { LangServerType, REPO_FILE_STATUS_SEVERITY, RepoFileStatus, + RepoFileStatusText as StatusText, Severity, StatusReport, } from '../../../common/repo_file_status'; @@ -63,7 +64,6 @@ export class StatusIndicatorComponent extends React.Component { const { statusReport } = this.props; let severity = Severity.NONE; const children: any[] = []; - const addError = (error: RepoFileStatus | LangServerType) => { // @ts-ignore const s: any = REPO_FILE_STATUS_SEVERITY[error]; @@ -80,7 +80,7 @@ export class StatusIndicatorComponent extends React.Component {

); } else { - children.push(

{error}

); + children.push(

{StatusText[error]}

); } } }; diff --git a/x-pack/legacy/plugins/code/public/index.ts b/x-pack/legacy/plugins/code/public/index.ts new file mode 100644 index 0000000000000..ddf7177796341 --- /dev/null +++ b/x-pack/legacy/plugins/code/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { npStart } from 'ui/new_platform'; +import { Plugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new Plugin(initializerContext); +} + +// This is the shim to legacy platform +const p = plugin({} as PluginInitializerContext); +p.start(npStart.core); diff --git a/x-pack/legacy/plugins/code/public/lib/documentation_links.ts b/x-pack/legacy/plugins/code/public/lib/documentation_links.ts index 53da0b46518b9..f64788b1518d9 100644 --- a/x-pack/legacy/plugins/code/public/lib/documentation_links.ts +++ b/x-pack/legacy/plugins/code/public/lib/documentation_links.ts @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; +import { npStart } from 'ui/new_platform'; // TODO make sure document links are right export const documentationLinks = { - code: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`, - codeIntelligence: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`, - gitFormat: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code.html`, - codeInstallLangServer: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-install-lang-server.html`, - codeGettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-getting-started.html`, - codeRepoManagement: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-repo-management.html`, - codeSearch: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-search.html`, - codeOtherFeatures: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-basic-nav.html`, - semanticNavigation: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/code-semantic-nav.html`, - kibanaRoleManagement: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/kibana-role-management.html`, + code: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code.html`, + codeIntelligence: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code.html`, + gitFormat: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code.html`, + codeInstallLangServer: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-install-lang-server.html`, + codeGettingStarted: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-getting-started.html`, + codeRepoManagement: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-repo-management.html`, + codeSearch: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-search.html`, + codeOtherFeatures: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-basic-nav.html`, + semanticNavigation: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/code-semantic-nav.html`, + kibanaRoleManagement: `${npStart.core.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${npStart.core.docLinks.DOC_LINK_VERSION}/kibana-role-management.html`, }; diff --git a/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts b/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts index 0807195549fe9..f03c914be3f31 100644 --- a/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts +++ b/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts @@ -6,7 +6,7 @@ import { DetailSymbolInformation } from '@elastic/lsp-extension'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { Location } from 'vscode-languageserver-types'; import { monaco } from '../monaco'; import { LspRestClient, TextDocumentMethods } from '../../../common/lsp_client'; @@ -32,7 +32,7 @@ export const definitionProvider: monaco.languages.DefinitionProvider = { } async function handleQname(qname: string): Promise { - const res: any = await kfetch({ pathname: `/api/code/lsp/symbol/${qname}` }); + const res = await npStart.core.http.get(`/api/code/lsp/symbol/${qname}`); if (res.symbols) { return res.symbols.map((s: DetailSymbolInformation) => handleLocation(s.symbolInformation.location) diff --git a/x-pack/legacy/plugins/code/public/monaco/editor_service.ts b/x-pack/legacy/plugins/code/public/monaco/editor_service.ts index f71c5df7efe25..2b3b5513a39a4 100644 --- a/x-pack/legacy/plugins/code/public/monaco/editor_service.ts +++ b/x-pack/legacy/plugins/code/public/monaco/editor_service.ts @@ -7,7 +7,7 @@ import { editor, IRange, Uri } from 'monaco-editor'; // @ts-ignore import { StandaloneCodeEditorServiceImpl } from 'monaco-editor/esm/vs/editor/standalone/browser/standaloneCodeServiceImpl.js'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { parseSchema } from '../../common/uri_util'; import { SymbolSearchResult } from '../../model'; import { history } from '../utils/url'; @@ -37,10 +37,7 @@ export class EditorService extends StandaloneCodeEditorServiceImpl { public static async findSymbolByQname(qname: string) { try { - const response = await kfetch({ - pathname: `/api/code/lsp/symbol/${qname}`, - method: 'GET', - }); + const response = await npStart.core.http.get(`/api/code/lsp/symbol/${qname}`); return response as SymbolSearchResult; } catch (e) { const error = e.body; diff --git a/x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.ts b/x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.tsx similarity index 96% rename from x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.ts rename to x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.tsx index 987c7d9477407..99556fa1d2b77 100644 --- a/x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.ts +++ b/x-pack/legacy/plugins/code/public/monaco/hover/content_hover_widget.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { I18nProvider } from '@kbn/i18n/react'; import { editor as Editor, languages, Range as EditorRange } from 'monaco-editor'; // @ts-ignore import { createCancelablePromise } from 'monaco-editor/esm/vs/base/common/async'; @@ -135,11 +136,20 @@ export class ContentHoverWidget extends ContentWidget { } this.showAt(new monaco.Position(renderRange.startLineNumber, startColumn), this.shouldFocus); - const element = React.createElement(HoverWidget, props, null); + + const element = ( + + + + ); // @ts-ignore ReactDOM.render(element, fragment); const buttonFragment = document.createDocumentFragment(); - const buttons = React.createElement(HoverButtons, props, null); + const buttons = ( + + + + ); // @ts-ignore ReactDOM.render(buttons, buttonFragment); this.updateContents(fragment, buttonFragment); diff --git a/x-pack/legacy/plugins/code/public/monaco/monaco.ts b/x-pack/legacy/plugins/code/public/monaco/monaco.ts index ca8bea4231753..06b55e4d74c57 100644 --- a/x-pack/legacy/plugins/code/public/monaco/monaco.ts +++ b/x-pack/legacy/plugins/code/public/monaco/monaco.ts @@ -96,11 +96,12 @@ import 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution. import 'monaco-editor/esm/vs/basic-languages/python/python.contribution.js'; import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'; import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { CTAGS_SUPPORT_LANGS } from '../../common/language_server'; import { definitionProvider } from './definition/definition_provider'; -const IS_DARK_THEME = chrome.getUiSettingsClient().get('theme:darkMode'); +const IS_DARK_THEME = npStart.core.uiSettings.get('theme:darkMode'); const themeName = IS_DARK_THEME ? darkTheme : lightTheme; diff --git a/x-pack/legacy/plugins/code/public/monaco/textmodel_resolver.ts b/x-pack/legacy/plugins/code/public/monaco/textmodel_resolver.ts index e33339bca56f3..06fae9b59154a 100644 --- a/x-pack/legacy/plugins/code/public/monaco/textmodel_resolver.ts +++ b/x-pack/legacy/plugins/code/public/monaco/textmodel_resolver.ts @@ -5,7 +5,7 @@ */ import { editor, IDisposable, Uri } from 'monaco-editor'; -import chrome from 'ui/chrome'; +import { npStart } from 'ui/new_platform'; import { ImmortalReference } from './immortal_reference'; @@ -55,7 +55,7 @@ export class TextModelResolverService implements ITextModelService { const revision = resource.query; const file = resource.fragment; const response = await fetch( - chrome.addBasePath(`/api/code/repo/${repo}/blob/${revision}/${file}`) + npStart.core.http.basePath.prepend(`/api/code/repo/${repo}/blob/${revision}/${file}`) ); if (response.status === 200) { const contentType = response.headers.get('Content-Type'); diff --git a/x-pack/legacy/plugins/code/public/plugin.ts b/x-pack/legacy/plugins/code/public/plugin.ts new file mode 100644 index 0000000000000..48b295ef67d9e --- /dev/null +++ b/x-pack/legacy/plugins/code/public/plugin.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart } from 'src/core/public'; +import { startApp } from './app'; + +export class Plugin { + constructor(initializerContext: PluginInitializerContext) {} + + public setup(core: CoreSetup) { + // called when plugin is setting up + } + + public start(core: CoreStart) { + // called after all plugins are set up + startApp(core); + } + + public stop() { + // called when plugin is torn down, aka window.onbeforeunload + } +} diff --git a/x-pack/legacy/plugins/code/public/reducers/file_tree.ts b/x-pack/legacy/plugins/code/public/reducers/file_tree.ts index 99c1883e772c1..467f04f8f22a9 100644 --- a/x-pack/legacy/plugins/code/public/reducers/file_tree.ts +++ b/x-pack/legacy/plugins/code/public/reducers/file_tree.ts @@ -8,13 +8,10 @@ import produce from 'immer'; import { Action, handleActions } from 'redux-actions'; import { FileTree, FileTreeItemType, sortFileTree } from '../../model'; import { - closeTreePath, fetchRepoTree, fetchRepoTreeFailed, fetchRepoTreeSuccess, - openTreePath, RepoTreePayload, - resetRepoTree, fetchRootRepoTreeSuccess, fetchRootRepoTreeFailed, dirNotFound, @@ -60,7 +57,6 @@ export function getPathOfTree(tree: FileTree, paths: string[]) { export interface FileTreeState { tree: FileTree; - openedPaths: string[]; fileTreeLoadingPaths: string[]; // store not found directory as an array to calculate `notFound` flag by finding whether path is in this array notFoundDirs: string[]; @@ -73,7 +69,6 @@ const initialState: FileTreeState = { path: '', type: FileTreeItemType.Directory, }, - openedPaths: [], fileTreeLoadingPaths: [''], notFoundDirs: [], revision: '', @@ -82,7 +77,6 @@ const initialState: FileTreeState = { const clearState = (state: FileTreeState) => produce(state, draft => { draft.tree = initialState.tree; - draft.openedPaths = initialState.openedPaths; draft.fileTreeLoadingPaths = initialState.fileTreeLoadingPaths; draft.notFoundDirs = initialState.notFoundDirs; draft.revision = initialState.revision; @@ -138,37 +132,12 @@ export const fileTree = handleActions( produce(state, draft => { draft.notFoundDirs.push(action.payload!); }), - [String(resetRepoTree)]: state => - produce(state, draft => { - draft.tree = initialState.tree; - draft.openedPaths = initialState.openedPaths; - }), [String(fetchRepoTreeFailed)]: (state, action: Action) => produce(state, draft => { draft.fileTreeLoadingPaths = draft.fileTreeLoadingPaths.filter( p => p !== action.payload!.path && p !== '' ); }), - [String(openTreePath)]: (state, action: Action) => - produce(state, draft => { - let path = action.payload!; - const openedPaths = state.openedPaths; - const pathSegs = path.split('/'); - while (!openedPaths.includes(path)) { - draft.openedPaths.push(path); - pathSegs.pop(); - if (pathSegs.length <= 0) { - break; - } - path = pathSegs.join('/'); - } - }), - [String(closeTreePath)]: (state, action: Action) => - produce(state, draft => { - const path = action.payload!; - const isSubFolder = (p: string) => p.startsWith(path + '/'); - draft.openedPaths = state.openedPaths.filter(p => !(p === path || isSubFolder(p))); - }), [String(routePathChange)]: clearState, [String(repoChange)]: clearState, [String(revisionChange)]: clearState, diff --git a/x-pack/legacy/plugins/code/public/reducers/repository_management.ts b/x-pack/legacy/plugins/code/public/reducers/repository_management.ts index 83576468f616a..d43cb1fe4785f 100644 --- a/x-pack/legacy/plugins/code/public/reducers/repository_management.ts +++ b/x-pack/legacy/plugins/code/public/reducers/repository_management.ts @@ -6,6 +6,7 @@ import produce from 'immer'; import { Action, handleActions } from 'redux-actions'; +import { i18n } from '@kbn/i18n'; import { Repository, RepoConfigs, RepositoryConfig } from '../../model'; import { @@ -89,22 +90,35 @@ export const repositoryManagement = handleActions< draft.importLoading = true; }), [String(importRepoSuccess)]: (state, action: Action) => + // TODO is it possible and how to deal with action.payload === undefined? produce(state, draft => { draft.importLoading = false; draft.showToast = true; draft.toastType = ToastType.success; - draft.toastMessage = `${action.payload!.name} has been successfully submitted!`; + draft.toastMessage = i18n.translate( + 'xpack.code.repositoryManagement.repoSubmittedMessage', + { + defaultMessage: '{name} has been successfully submitted!', + values: { name: action.payload!.name }, + } + ); draft.repositories = [...state.repositories, action.payload!]; }), [String(importRepoFailed)]: (state, action: Action) => produce(state, draft => { if (action.payload) { if (action.payload.res.status === 304) { - draft.toastMessage = 'This Repository has already been imported!'; + draft.toastMessage = i18n.translate( + 'xpack.code.repositoryManagement.repoImportedMessage', + { + defaultMessage: 'This Repository has already been imported!', + } + ); draft.showToast = true; draft.toastType = ToastType.warning; draft.importLoading = false; } else { + // TODO add localication for those messages draft.toastMessage = action.payload.body.message; draft.showToast = true; draft.toastType = ToastType.danger; diff --git a/x-pack/legacy/plugins/code/public/sagas/blame.ts b/x-pack/legacy/plugins/code/public/sagas/blame.ts index d79221f9c63ef..33b356a893d67 100644 --- a/x-pack/legacy/plugins/code/public/sagas/blame.ts +++ b/x-pack/legacy/plugins/code/public/sagas/blame.ts @@ -5,16 +5,16 @@ */ import { Action } from 'redux-actions'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { call, put, takeEvery } from 'redux-saga/effects'; import { Match } from '../actions'; import { loadBlame, loadBlameFailed, LoadBlamePayload, loadBlameSuccess } from '../actions/blame'; import { blamePattern } from './patterns'; function requestBlame(repoUri: string, revision: string, path: string) { - return kfetch({ - pathname: `/api/code/repo/${repoUri}/blame/${encodeURIComponent(revision)}/${path}`, - }); + return npStart.core.http.get( + `/api/code/repo/${repoUri}/blame/${encodeURIComponent(revision)}/${path}` + ); } function* handleFetchBlame(action: Action) { diff --git a/x-pack/legacy/plugins/code/public/sagas/commit.ts b/x-pack/legacy/plugins/code/public/sagas/commit.ts index 4235f047865fa..93a5c74e19c4a 100644 --- a/x-pack/legacy/plugins/code/public/sagas/commit.ts +++ b/x-pack/legacy/plugins/code/public/sagas/commit.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { Action } from 'redux-actions'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { call, put, takeEvery } from 'redux-saga/effects'; import { loadCommit, loadCommitFailed, loadCommitSuccess, Match } from '../actions'; import { commitRoutePattern } from './patterns'; function requestCommit(repo: string, commitId: string) { - return kfetch({ - pathname: `/api/code/repo/${repo}/diff/${commitId}`, - }); + return npStart.core.http.get(`/api/code/repo/${repo}/diff/${commitId}`); } function* handleLoadCommit(action: Action) { diff --git a/x-pack/legacy/plugins/code/public/sagas/editor.ts b/x-pack/legacy/plugins/code/public/sagas/editor.ts index b4a75e14e324f..437846139d273 100644 --- a/x-pack/legacy/plugins/code/public/sagas/editor.ts +++ b/x-pack/legacy/plugins/code/public/sagas/editor.ts @@ -6,7 +6,7 @@ import queryString from 'querystring'; import { Action } from 'redux-actions'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { TextDocumentPositionParams } from 'vscode-languageserver'; import Url from 'url'; import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; @@ -16,8 +16,6 @@ import { closeReferences, fetchFile, FetchFileResponse, - fetchRepoBranches, - fetchRepoCommits, fetchRepoTree, fetchTreeCommits, findReferences, @@ -25,11 +23,9 @@ import { findReferencesSuccess, loadStructure, Match, - resetRepoTree, revealPosition, fetchRepos, turnOnDefaultRepoScope, - fetchRootRepoTree, } from '../actions'; import { loadRepo, loadRepoFailed, loadRepoSuccess } from '../actions/status'; import { PathTypes } from '../common/types'; @@ -43,7 +39,7 @@ import { repoScopeSelector, urlQueryStringSelector, createTreeSelector, - getTreeRevision, + reposSelector, } from '../selectors'; import { history } from '../utils/url'; import { mainRoutePattern } from './patterns'; @@ -60,9 +56,7 @@ function* handleReferences(action: Action) { } function requestFindReferences(params: TextDocumentPositionParams) { - return kfetch({ - pathname: `/api/code/lsp/findReferences`, - method: 'POST', + return npStart.core.http.post(`/api/code/lsp/findReferences`, { body: JSON.stringify(params), }); } @@ -134,7 +128,7 @@ function* handleFile(repoUri: string, file: string, revision: string) { } function fetchRepo(repoUri: string) { - return kfetch({ pathname: `/api/code/repo/${repoUri}` }); + return npStart.core.http.get(`/api/code/repo/${repoUri}`); } function* loadRepoSaga(action: any) { @@ -157,9 +151,11 @@ export function* watchLoadRepo() { } function* handleMainRouteChange(action: Action) { - // in source view page, we need repos as default repo scope options when no query input - yield put(fetchRepos()); - + const repos = yield select(reposSelector); + if (repos.length === 0) { + // in source view page, we need repos as default repo scope options when no query input + yield put(fetchRepos()); + } const { location } = action.payload!; const search = location.search.startsWith('?') ? location.search.substring(1) : location.search; const queryParams = queryString.parse(search); @@ -169,8 +165,6 @@ function* handleMainRouteChange(action: Action) { if (goto) { position = parseGoto(goto); } - yield put(loadRepo(repoUri)); - yield put(fetchRepoBranches({ uri: repoUri })); if (file) { if ([PathTypes.blob, PathTypes.blame].includes(pathType as PathTypes)) { yield put(revealPosition(position)); @@ -188,14 +182,6 @@ function* handleMainRouteChange(action: Action) { } } const lastRequestPath = yield select(lastRequestPathSelector); - const currentTree: FileTree = yield select(getTree); - const currentTreeRevision: string = yield select(getTreeRevision); - // repo changed - if (currentTree.repoUri !== repoUri || revision !== currentTreeRevision) { - yield put(resetRepoTree()); - yield put(fetchRepoCommits({ uri: repoUri, revision })); - yield put(fetchRootRepoTree({ uri: repoUri, revision })); - } const tree = yield select(getTree); const isDir = pathType === PathTypes.tree; function isTreeLoaded(isDirectory: boolean, targetTree: FileTree | null) { diff --git a/x-pack/legacy/plugins/code/public/sagas/file.ts b/x-pack/legacy/plugins/code/public/sagas/file.ts index d562bf9efec98..01c1785e9e6ac 100644 --- a/x-pack/legacy/plugins/code/public/sagas/file.ts +++ b/x-pack/legacy/plugins/code/public/sagas/file.ts @@ -5,8 +5,7 @@ */ import { Action } from 'redux-actions'; -import chrome from 'ui/chrome'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import Url from 'url'; import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; @@ -93,10 +92,12 @@ function requestRepoTree({ if (parents) { query.parents = true; } - return kfetch({ - pathname: `/api/code/repo/${uri}/tree/${encodeURIComponent(revision)}/${path}`, - query, - }); + return npStart.core.http.get( + `/api/code/repo/${uri}/tree/${encodeURIComponent(revision)}/${path}`, + { + query, + } + ); } export function* watchFetchRepoTree() { @@ -127,9 +128,7 @@ function* handleFetchBranches(action: Action) { } function requestBranches({ uri }: FetchRepoPayload) { - return kfetch({ - pathname: `/api/code/repo/${uri}/references`, - }); + return npStart.core.http.get(`/api/code/repo/${uri}/references`); } function* handleFetchCommits(action: Action) { @@ -172,16 +171,19 @@ function requestCommits( count?: number ) { const pathStr = path ? `/${path}` : ''; - const options: any = { - pathname: `/api/code/repo/${uri}/history/${encodeURIComponent(revision)}${pathStr}`, - }; + let query: any = {}; if (loadMore) { - options.query = { after: 1 }; + query = { after: 1 }; } if (count) { - options.count = count; + query = { count }; } - return kfetch(options); + return npStart.core.http.get( + `/api/code/repo/${uri}/history/${encodeURIComponent(revision)}${pathStr}`, + { + query, + } + ); } export async function requestFile( @@ -194,7 +196,9 @@ export async function requestFile( if (line) { query.line = line; } - const response: Response = await fetch(chrome.addBasePath(Url.format({ pathname: url, query }))); + const response: Response = await fetch( + npStart.core.http.basePath.prepend(Url.format({ pathname: url, query })) + ); if (response.status >= 200 && response.status < 300) { const contentType = response.headers.get('Content-Type'); diff --git a/x-pack/legacy/plugins/code/public/sagas/index.ts b/x-pack/legacy/plugins/code/public/sagas/index.ts index 04534bcc28694..0b6e33de064f8 100644 --- a/x-pack/legacy/plugins/code/public/sagas/index.ts +++ b/x-pack/legacy/plugins/code/public/sagas/index.ts @@ -49,9 +49,11 @@ import { import { watchRootRoute } from './setup'; import { watchRepoCloneSuccess, watchRepoDeleteFinished, watchStatusChange } from './status'; import { watchLoadStructure } from './structure'; -import { watchRoute } from './route'; +import { watchRoute, watchRepoChange, watchRepoOrRevisionChange } from './route'; export function* rootSaga() { + yield fork(watchRepoChange); + yield fork(watchRepoOrRevisionChange); yield fork(watchRoute); yield fork(watchRootRoute); yield fork(watchLoadCommit); diff --git a/x-pack/legacy/plugins/code/public/sagas/language_server.ts b/x-pack/legacy/plugins/code/public/sagas/language_server.ts index 927892e3f49bb..c9e452201aa40 100644 --- a/x-pack/legacy/plugins/code/public/sagas/language_server.ts +++ b/x-pack/legacy/plugins/code/public/sagas/language_server.ts @@ -5,7 +5,7 @@ */ import { Action } from 'redux-actions'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { call, put, takeEvery } from 'redux-saga/effects'; import { loadLanguageServers, @@ -17,16 +17,11 @@ import { } from '../actions/language_server'; function fetchLangServers() { - return kfetch({ - pathname: '/api/code/install', - }); + return npStart.core.http.get('/api/code/install'); } function installLanguageServer(languageServer: string) { - return kfetch({ - pathname: `/api/code/install/${languageServer}`, - method: 'POST', - }); + return npStart.core.http.post(`/api/code/install/${languageServer}`); } function* handleInstallLanguageServer(action: Action) { diff --git a/x-pack/legacy/plugins/code/public/sagas/project_config.ts b/x-pack/legacy/plugins/code/public/sagas/project_config.ts index fb1672f2c65ca..c417912f9da58 100644 --- a/x-pack/legacy/plugins/code/public/sagas/project_config.ts +++ b/x-pack/legacy/plugins/code/public/sagas/project_config.ts @@ -5,7 +5,7 @@ */ import { Action } from 'redux-actions'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { all, call, put, takeEvery } from 'redux-saga/effects'; import { Repository, RepositoryConfig } from '../../model'; import { @@ -18,9 +18,7 @@ import { import { loadConfigsFailed, loadConfigsSuccess } from '../actions/project_config'; function putProjectConfig(repoUri: string, config: RepositoryConfig) { - return kfetch({ - pathname: `/api/code/repo/config/${repoUri}`, - method: 'PUT', + return npStart.core.http.put(`/api/code/repo/config/${repoUri}`, { body: JSON.stringify(config), }); } @@ -40,9 +38,7 @@ export function* watchSwitchProjectLanguageServer() { } function fetchConfigs(repoUri: string) { - return kfetch({ - pathname: `/api/code/repo/config/${repoUri}`, - }); + return npStart.core.http.get(`/api/code/repo/config/${repoUri}`); } function* loadConfigs(action: Action) { diff --git a/x-pack/legacy/plugins/code/public/sagas/project_status.ts b/x-pack/legacy/plugins/code/public/sagas/project_status.ts index d4ee77761cd09..0a392b3959c3b 100644 --- a/x-pack/legacy/plugins/code/public/sagas/project_status.ts +++ b/x-pack/legacy/plugins/code/public/sagas/project_status.ts @@ -7,7 +7,7 @@ import moment from 'moment'; import { Action } from 'redux-actions'; import { delay } from 'redux-saga'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { all, call, @@ -54,9 +54,7 @@ import { const REPO_STATUS_POLLING_FREQ_MS = 1000; function fetchStatus(repoUri: string) { - return kfetch({ - pathname: `/api/code/repo/status/${repoUri}`, - }); + return npStart.core.http.get(`/api/code/repo/status/${repoUri}`); } function* loadRepoListStatus(repos: Repository[]) { diff --git a/x-pack/legacy/plugins/code/public/sagas/repository.ts b/x-pack/legacy/plugins/code/public/sagas/repository.ts index 16d7b386648e9..c8f5ce9a67557 100644 --- a/x-pack/legacy/plugins/code/public/sagas/repository.ts +++ b/x-pack/legacy/plugins/code/public/sagas/repository.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { Action } from 'redux-actions'; import { call, put, takeEvery, takeLatest, take } from 'redux-saga/effects'; @@ -37,7 +37,7 @@ import { history } from '../utils/url'; import { adminRoutePattern } from './patterns'; function requestRepos(): any { - return kfetch({ pathname: '/api/code/repos' }); + return npStart.core.http.get('/api/code/repos'); } function* handleFetchRepos() { @@ -50,13 +50,11 @@ function* handleFetchRepos() { } function requestDeleteRepo(uri: string) { - return kfetch({ pathname: `/api/code/repo/${uri}`, method: 'delete' }); + return npStart.core.http.delete(`/api/code/repo/${uri}`); } function requestIndexRepo(uri: string) { - return kfetch({ - pathname: `/api/code/repo/index/${uri}`, - method: 'post', + return npStart.core.http.post(`/api/code/repo/index/${uri}`, { body: JSON.stringify({ reindex: true }), }); } @@ -92,9 +90,7 @@ function* handleIndexRepo(action: Action) { } function requestImportRepo(uri: string) { - return kfetch({ - pathname: '/api/code/repo', - method: 'post', + return npStart.core.http.post('/api/code/repo', { body: JSON.stringify({ url: uri }), }); } @@ -117,7 +113,7 @@ function* handleFetchRepoConfigs() { } function requestRepoConfigs() { - return kfetch({ pathname: '/api/code/workspace', method: 'get' }); + return npStart.core.http.get('/api/code/workspace'); } function* handleInitCmd(action: Action) { @@ -126,10 +122,8 @@ function* handleInitCmd(action: Action) { } function requestRepoInitCmd(repoUri: string) { - return kfetch({ - pathname: `/api/code/workspace/${repoUri}/master`, + return npStart.core.http.post(`/api/code/workspace/${repoUri}/master`, { query: { force: true }, - method: 'post', }); } function* handleGotoRepo(action: Action) { diff --git a/x-pack/legacy/plugins/code/public/sagas/route.ts b/x-pack/legacy/plugins/code/public/sagas/route.ts index c4a79103f38f1..bafb0f5326f31 100644 --- a/x-pack/legacy/plugins/code/public/sagas/route.ts +++ b/x-pack/legacy/plugins/code/public/sagas/route.ts @@ -4,40 +4,77 @@ * you may not use this file except in compliance with the Elastic License. */ -import { put, takeEvery, select } from 'redux-saga/effects'; +import { put, takeEvery, select, takeLatest } from 'redux-saga/effects'; import { Action } from 'redux-actions'; -import { routeChange, Match } from '../actions'; -import { previousMatchSelector } from '../selectors'; +import { + routeChange, + Match, + loadRepo, + fetchRepoBranches, + fetchRepoCommits, + fetchRootRepoTree, +} from '../actions'; +import { previousMatchSelector, repoUriSelector, revisionSelector } from '../selectors'; import { routePathChange, repoChange, revisionChange, filePathChange } from '../actions/route'; import * as ROUTES from '../components/routes'; +const MAIN_ROUTES = [ROUTES.MAIN, ROUTES.MAIN_ROOT]; + +function* handleRepoOrRevisionChange() { + const repoUri = yield select(repoUriSelector); + const revision = yield select(revisionSelector); + yield put(fetchRepoCommits({ uri: repoUri, revision })); + yield put(fetchRootRepoTree({ uri: repoUri, revision })); +} + +export function* watchRepoOrRevisionChange() { + yield takeLatest([String(repoChange), String(revisionChange)], handleRepoOrRevisionChange); +} + const getRepoFromMatch = (match: Match) => - `${match.params.resources}/${match.params.org}/${match.params.repo}`; + `${match.params.resource}/${match.params.org}/${match.params.repo}`; function* handleRoute(action: Action) { const currentMatch = action.payload; const previousMatch = yield select(previousMatchSelector); - if (currentMatch.path !== previousMatch.path) { - yield put(routePathChange()); - } else if (currentMatch.path === ROUTES.MAIN) { - const currentRepo = getRepoFromMatch(currentMatch); - const previousRepo = getRepoFromMatch(previousMatch); - const currentRevision = currentMatch.params.revision; - const previousRevision = previousMatch.params.revision; - const currentFilePath = currentMatch.params.path; - const previousFilePath = previousMatch.params.path; - if (currentRepo !== previousRepo) { - yield put(repoChange()); - } - if (currentRevision !== previousRevision) { + if (MAIN_ROUTES.includes(currentMatch.path)) { + if (MAIN_ROUTES.includes(previousMatch.path)) { + const currentRepo = getRepoFromMatch(currentMatch); + const previousRepo = getRepoFromMatch(previousMatch); + const currentRevision = currentMatch.params.revision; + const previousRevision = previousMatch.params.revision; + const currentFilePath = currentMatch.params.path; + const previousFilePath = previousMatch.params.path; + if (currentRepo !== previousRepo) { + yield put(repoChange(currentRepo)); + } + if (currentRevision !== previousRevision) { + yield put(revisionChange()); + } + if (currentFilePath !== previousFilePath) { + yield put(filePathChange()); + } + } else { + yield put(routePathChange()); + const currentRepo = getRepoFromMatch(currentMatch); + yield put(repoChange(currentRepo)); yield put(revisionChange()); - } - if (currentFilePath !== previousFilePath) { yield put(filePathChange()); } + } else if (currentMatch.path !== previousMatch.path) { + yield put(routePathChange()); } } export function* watchRoute() { yield takeEvery(String(routeChange), handleRoute); } + +export function* handleRepoChange(action: Action) { + yield put(loadRepo(action.payload!)); + yield put(fetchRepoBranches({ uri: action.payload! })); +} + +export function* watchRepoChange() { + yield takeEvery(String(repoChange), handleRepoChange); +} diff --git a/x-pack/legacy/plugins/code/public/sagas/search.ts b/x-pack/legacy/plugins/code/public/sagas/search.ts index 44ede3442e3d3..a9e8205d3b764 100644 --- a/x-pack/legacy/plugins/code/public/sagas/search.ts +++ b/x-pack/legacy/plugins/code/public/sagas/search.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import queryString from 'querystring'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { Action } from 'redux-actions'; import { call, put, takeEvery, takeLatest } from 'redux-saga/effects'; @@ -52,9 +52,7 @@ function requestDocumentSearch(payload: DocumentSearchPayload) { } if (query && query.length > 0) { - return kfetch({ - pathname: `/api/code/search/doc`, - method: 'get', + return npStart.core.http.get(`/api/code/search/doc`, { query: queryParams, }); } else { @@ -76,9 +74,7 @@ function* handleDocumentSearch(action: Action) { } function requestRepositorySearch(q: string) { - return kfetch({ - pathname: `/api/code/search/repo`, - method: 'get', + return npStart.core.http.get(`/api/code/search/repo`, { query: { q }, }); } diff --git a/x-pack/legacy/plugins/code/public/sagas/setup.ts b/x-pack/legacy/plugins/code/public/sagas/setup.ts index 62f8cdb6cadbc..7c7910b695d98 100644 --- a/x-pack/legacy/plugins/code/public/sagas/setup.ts +++ b/x-pack/legacy/plugins/code/public/sagas/setup.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { call, put, takeEvery } from 'redux-saga/effects'; import { checkSetupFailed, checkSetupSuccess } from '../actions'; import { rootRoutePattern, setupRoutePattern } from './patterns'; @@ -19,7 +19,7 @@ function* handleRootRoute() { } function requestSetup() { - return kfetch({ pathname: `/api/code/setup`, method: 'head' }); + return npStart.core.http.head(`/api/code/setup`); } export function* watchRootRoute() { diff --git a/x-pack/legacy/plugins/code/public/sagas/status.ts b/x-pack/legacy/plugins/code/public/sagas/status.ts index 84f7840a0ca09..796cbed837e69 100644 --- a/x-pack/legacy/plugins/code/public/sagas/status.ts +++ b/x-pack/legacy/plugins/code/public/sagas/status.ts @@ -6,7 +6,7 @@ import { Action } from 'redux-actions'; import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; -import { kfetch } from 'ui/kfetch'; +import { npStart } from 'ui/new_platform'; import { isEqual } from 'lodash'; import { delay } from 'redux-saga'; @@ -138,10 +138,7 @@ function requestStatus(location: FetchFilePayload) { ? `/api/code/repo/${uri}/status/${revision}/${path}` : `/api/code/repo/${uri}/status/${revision}`; - return kfetch({ - pathname, - method: 'GET', - }); + return npStart.core.http.get(pathname); } export function* watchStatusChange() { diff --git a/x-pack/legacy/plugins/code/public/selectors/index.ts b/x-pack/legacy/plugins/code/public/selectors/index.ts index 253229df8cbc4..870cf1cee3e81 100644 --- a/x-pack/legacy/plugins/code/public/selectors/index.ts +++ b/x-pack/legacy/plugins/code/public/selectors/index.ts @@ -35,6 +35,7 @@ export const repoUriSelector = (state: RootState) => { const { resource, org, repo } = state.route.match.params; return `${resource}/${org}/${repo}`; }; +export const revisionSelector = (state: RootState) => state.route.match.params.revision; export const routeSelector = (state: RootState) => state.route.match; @@ -103,3 +104,4 @@ export const urlQueryStringSelector = (state: RootState) => state.route.match.lo export const previousMatchSelector = (state: RootState) => state.route.previousMatch; export const statusSelector = (state: RootState) => state.status.repoFileStatus; +export const reposSelector = (state: RootState) => state.repositoryManagement.repositories; diff --git a/x-pack/legacy/plugins/code/server/__tests__/clone_worker.ts b/x-pack/legacy/plugins/code/server/__tests__/clone_worker.ts index db8d308a9c8dc..988f4d75d53e2 100644 --- a/x-pack/legacy/plugins/code/server/__tests__/clone_worker.ts +++ b/x-pack/legacy/plugins/code/server/__tests__/clone_worker.ts @@ -13,6 +13,7 @@ import rimraf from 'rimraf'; import sinon from 'sinon'; import { Repository } from '../../model'; +import { DiskWatermarkService } from '../disk_watermark'; import { GitOperations } from '../git_operations'; import { EsClient, Esqueue } from '../lib/esqueue'; import { Logger } from '../log'; @@ -106,6 +107,12 @@ describe('clone_worker_tests', () => { cancellationService.cancelCloneJob = cancelCloneJobSpy; cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const cloneWorker = new CloneWorker( esQueue as Esqueue, log, @@ -114,7 +121,8 @@ describe('clone_worker_tests', () => { gitOps, {} as IndexWorker, (repoServiceFactory as any) as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); await cloneWorker.executeJob({ @@ -125,6 +133,7 @@ describe('clone_worker_tests', () => { timestamp: 0, }); + assert.ok(isLowWatermarkSpy.calledOnce); assert.ok(newInstanceSpy.calledOnce); assert.ok(cloneSpy.calledOnce); }); @@ -154,6 +163,12 @@ describe('clone_worker_tests', () => { cancellationService.cancelCloneJob = cancelCloneJobSpy; cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const cloneWorker = new CloneWorker( esQueue as Esqueue, log, @@ -162,7 +177,8 @@ describe('clone_worker_tests', () => { gitOps, (indexWorker as any) as IndexWorker, {} as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); await cloneWorker.onJobCompleted( @@ -190,6 +206,8 @@ describe('clone_worker_tests', () => { // Index request is issued after a 1s delay. await delay(1000); assert.ok(enqueueJobSpy.calledOnce); + + assert.ok(isLowWatermarkSpy.notCalled); }); it('On clone job completed because of cancellation', async () => { @@ -217,6 +235,12 @@ describe('clone_worker_tests', () => { cancellationService.cancelCloneJob = cancelCloneJobSpy; cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const cloneWorker = new CloneWorker( esQueue as Esqueue, log, @@ -225,7 +249,8 @@ describe('clone_worker_tests', () => { gitOps, (indexWorker as any) as IndexWorker, {} as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); await cloneWorker.onJobCompleted( @@ -252,6 +277,8 @@ describe('clone_worker_tests', () => { // Index request should not be issued after clone request is done. await delay(1000); assert.ok(enqueueJobSpy.notCalled); + + assert.ok(isLowWatermarkSpy.notCalled); }); it('On clone job enqueued.', async () => { @@ -272,6 +299,12 @@ describe('clone_worker_tests', () => { cancellationService.cancelCloneJob = cancelCloneJobSpy; cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const cloneWorker = new CloneWorker( esQueue as Esqueue, log, @@ -280,7 +313,8 @@ describe('clone_worker_tests', () => { gitOps, {} as IndexWorker, {} as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); await cloneWorker.onJobEnqueued({ @@ -320,6 +354,12 @@ describe('clone_worker_tests', () => { cancellationService.cancelCloneJob = cancelCloneJobSpy; cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const cloneWorker = new CloneWorker( esQueue as Esqueue, log, @@ -328,7 +368,8 @@ describe('clone_worker_tests', () => { gitOps, {} as IndexWorker, (repoServiceFactory as any) as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); const result1 = await cloneWorker.executeJob({ @@ -342,6 +383,7 @@ describe('clone_worker_tests', () => { assert.ok(result1.repo === null); assert.ok(newInstanceSpy.notCalled); assert.ok(cloneSpy.notCalled); + assert.ok(isLowWatermarkSpy.calledOnce); const result2 = await cloneWorker.executeJob({ payload: { @@ -354,5 +396,66 @@ describe('clone_worker_tests', () => { assert.ok(result2.repo === null); assert.ok(newInstanceSpy.notCalled); assert.ok(cloneSpy.notCalled); + assert.ok(isLowWatermarkSpy.calledTwice); + }); + + it('Execute clone job failed because of low disk watermark', async () => { + // Setup RepositoryService + const cloneSpy = sinon.spy(); + const repoService = { + clone: emptyAsyncFunc, + }; + repoService.clone = cloneSpy; + const repoServiceFactory = { + newInstance: (): void => { + return; + }, + }; + const newInstanceSpy = sinon.fake.returns(repoService); + repoServiceFactory.newInstance = newInstanceSpy; + + // Setup CancellationService + const cancelCloneJobSpy = sinon.spy(); + const registerCancelableCloneJobSpy = sinon.spy(); + const cancellationService: any = { + cancelCloneJob: emptyAsyncFunc, + registerCancelableCloneJob: emptyAsyncFunc, + }; + cancellationService.cancelCloneJob = cancelCloneJobSpy; + cancellationService.registerCancelableCloneJob = registerCancelableCloneJobSpy; + + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(true); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + + const cloneWorker = new CloneWorker( + esQueue as Esqueue, + log, + {} as EsClient, + serverOptions, + gitOps, + {} as IndexWorker, + (repoServiceFactory as any) as RepositoryServiceFactory, + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService + ); + + try { + await cloneWorker.executeJob({ + payload: { + url: 'https://github.com/Microsoft/TypeScript-Node-Starter.git', + }, + options: {}, + timestamp: 0, + }); + // This step should not be touched. + assert.ok(false); + } catch (error) { + assert.ok(isLowWatermarkSpy.calledOnce); + assert.ok(newInstanceSpy.notCalled); + assert.ok(cloneSpy.notCalled); + } }); }); diff --git a/x-pack/legacy/plugins/code/server/__tests__/lsp_incremental_indexer.ts b/x-pack/legacy/plugins/code/server/__tests__/lsp_incremental_indexer.ts index fc8f6378289e4..9c00695c7a9d7 100644 --- a/x-pack/legacy/plugins/code/server/__tests__/lsp_incremental_indexer.ts +++ b/x-pack/legacy/plugins/code/server/__tests__/lsp_incremental_indexer.ts @@ -200,14 +200,14 @@ describe('lsp_incremental_indexer unit tests', () => { // There are 5 MODIFIED items and 1 ADDED item. Only 1 file is in supported // language. Each file with supported language has 1 file + 1 symbol + 1 reference. - // Total doc indexed should be 8 * 3 = 15, + // Total doc indexed should be 6 * 2 + 4 = 16, // which can be fitted into a single batch index. assert.strictEqual(bulkSpy.callCount, 2); let total = 0; for (let i = 0; i < bulkSpy.callCount; i++) { total += bulkSpy.getCall(i).args[0].body.length; } - assert.strictEqual(total, 8 * 2); + assert.strictEqual(total, 16); // @ts-ignore }).timeout(20000); diff --git a/x-pack/legacy/plugins/code/server/__tests__/lsp_service.ts b/x-pack/legacy/plugins/code/server/__tests__/lsp_service.ts index 181f80b815945..efc3d75398e88 100644 --- a/x-pack/legacy/plugins/code/server/__tests__/lsp_service.ts +++ b/x-pack/legacy/plugins/code/server/__tests__/lsp_service.ts @@ -211,7 +211,7 @@ describe('lsp_service tests', () => { assert.ok(workspaceFolderExists); const controller = lspservice.controller; // @ts-ignore - const languageServer = controller.languageServerMap.typescript; + const languageServer = controller.languageServerMap.typescript[0]; const realWorkspacePath = fs.realpathSync(workspacePath); // @ts-ignore @@ -268,7 +268,7 @@ describe('lsp_service tests', () => { await lspservice.shutdown(); } // @ts-ignore - }).timeout(10000); + }).timeout(20000); it('should update if a worktree is not the newest', async () => { const lspservice = mockLspService(); diff --git a/x-pack/legacy/plugins/code/server/__tests__/multi_node.ts b/x-pack/legacy/plugins/code/server/__tests__/multi_node.ts index 549a10a2d32f5..9c97a268962e3 100644 --- a/x-pack/legacy/plugins/code/server/__tests__/multi_node.ts +++ b/x-pack/legacy/plugins/code/server/__tests__/multi_node.ts @@ -88,7 +88,8 @@ describe('code in multiple nodes', () => { port: codePort, }, plugins: { paths: [pluginPaths] }, - xpack: xpackOption, + xpack: { ...xpackOption, code: { codeNodeUrl: `http://localhost:${codePort}` } }, + logging: { silent: false }, }, }, }); @@ -109,6 +110,7 @@ describe('code in multiple nodes', () => { ...xpackOption, code: { codeNodeUrl: `http://localhost:${codePort}` }, }, + logging: { silent: true }, }; nonCodeNode = createRootWithCorePlugins(setting); await nonCodeNode.setup(); @@ -132,13 +134,23 @@ describe('code in multiple nodes', () => { await esServer.stop(); }); + function delay(ms: number) { + return new Promise(resolve1 => { + setTimeout(resolve1, ms); + }); + } + it('Code node setup should be ok', async () => { + await delay(6000); await request.get(kbnRootServer, '/api/code/setup').expect(200); - }); + // @ts-ignore + }).timeout(20000); it('Non-code node setup should be ok', async () => { + await delay(1000); await request.get(nonCodeNode, '/api/code/setup').expect(200); - }); + // @ts-ignore + }).timeout(5000); it('Non-code node setup should fail if code node is shutdown', async () => { await kbn.stop(); diff --git a/x-pack/legacy/plugins/code/server/disk_watermark.ts b/x-pack/legacy/plugins/code/server/disk_watermark.ts new file mode 100644 index 0000000000000..549d932cd9b38 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/disk_watermark.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import checkDiskSpace from 'check-disk-space'; + +export class DiskWatermarkService { + constructor(private readonly diskWatermarkLowMb: number, private readonly repoPath: string) {} + + public async isLowWatermark(): Promise { + try { + const { free } = await checkDiskSpace(this.repoPath); + const availableMb = free / 1024 / 1024; + return availableMb <= this.diskWatermarkLowMb; + } catch (err) { + return true; + } + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/git_api.ts b/x-pack/legacy/plugins/code/server/distributed/apis/git_api.ts new file mode 100644 index 0000000000000..35af1adaee174 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/git_api.ts @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fileType from 'file-type'; +import Boom from 'boom'; +import { Commit, Oid, Revwalk } from '@elastic/nodegit'; +import { commitInfo, GitOperations } from '../../git_operations'; +import { FileTree } from '../../../model'; +import { RequestContext, ServiceHandlerFor } from '../service_definition'; +import { extractLines } from '../../utils/buffer'; +import { detectLanguage } from '../../utils/detect_language'; +import { TEXT_FILE_LIMIT } from '../../../common/file'; +import { CommitInfo, ReferenceInfo } from '../../../model/commit'; +import { CommitDiff } from '../../../common/git_diff'; +import { GitBlame } from '../../../common/git_blame'; + +interface FileLocation { + uri: string; + path: string; + revision: string; +} +export const GitServiceDefinitionOption = { routePrefix: '/api/code/internal/git' }; +export const GitServiceDefinition = { + fileTree: { + request: {} as { + uri: string; + path: string; + revision: string; + skip: number; + limit: number; + withParents: boolean; + flatten: boolean; + }, + response: {} as FileTree, + }, + blob: { + request: {} as FileLocation & { line?: string }, + response: {} as { + isBinary: boolean; + imageType?: string; + content?: string; + lang?: string; + }, + }, + raw: { + request: {} as FileLocation, + response: {} as { + isBinary: boolean; + content: string; + }, + }, + history: { + request: {} as FileLocation & { + count: number; + after: boolean; + }, + response: {} as CommitInfo[], + }, + branchesAndTags: { + request: {} as { uri: string }, + response: {} as ReferenceInfo[], + }, + commitDiff: { + request: {} as { uri: string; revision: string }, + response: {} as CommitDiff, + }, + blame: { + request: {} as FileLocation, + response: {} as GitBlame[], + }, + commit: { + request: {} as { uri: string; revision: string }, + response: {} as CommitInfo, + }, + headRevision: { + request: {} as { uri: string }, + response: {} as string, + }, +}; + +export const getGitServiceHandler = ( + gitOps: GitOperations +): ServiceHandlerFor => ({ + async fileTree( + { uri, path, revision, skip, limit, withParents, flatten }, + context: RequestContext + ) { + return await gitOps.fileTree(uri, path, revision, skip, limit, withParents, flatten); + }, + async blob({ uri, path, revision, line }) { + const blob = await gitOps.fileContent(uri, path, revision); + const isBinary = blob.isBinary(); + if (isBinary) { + const type = fileType(blob.content()); + if (type && type.mime && type.mime.startsWith('image/')) { + return { + isBinary, + imageType: type.mime, + content: blob.content().toString(), + }; + } else { + return { + isBinary, + }; + } + } else { + if (line) { + const [from, to] = line.split(','); + let fromLine = parseInt(from, 10); + let toLine = to === undefined ? fromLine + 1 : parseInt(to, 10); + if (fromLine > toLine) { + [fromLine, toLine] = [toLine, fromLine]; + } + const lines = extractLines(blob.content(), fromLine, toLine); + const lang = await detectLanguage(path, lines); + return { + isBinary, + lang, + content: lines, + }; + } else if (blob.content()!.length <= TEXT_FILE_LIMIT) { + const lang = await detectLanguage(path, blob.content()); + return { + isBinary, + lang, + content: blob.content().toString(), + }; + } else { + return { + isBinary, + }; + } + } + }, + async raw({ uri, path, revision }) { + const blob = await gitOps.fileContent(uri, path, revision); + const isBinary = blob.isBinary(); + return { + isBinary, + content: blob.content().toString(), + }; + }, + async history({ uri, path, revision, count, after }) { + const repository = await gitOps.openRepo(uri); + const commit = await gitOps.getCommitInfo(uri, revision); + if (commit === null) { + throw Boom.notFound(`commit ${revision} not found in repo ${uri}`); + } + const walk = repository.createRevWalk(); + walk.sorting(Revwalk.SORT.TIME); + const commitId = Oid.fromString(commit!.id); + walk.push(commitId); + let commits: Commit[]; + if (path) { + // magic number 10000: how many commits at the most to iterate in order to find the commits contains the path + const results = await walk.fileHistoryWalk(path, count, 10000); + commits = results.map(result => result.commit); + } else { + commits = await walk.getCommits(count); + } + if (after && commits.length > 0) { + if (commits[0].id().equal(commitId)) { + commits = commits.slice(1); + } + } + return commits.map(commitInfo); + }, + async branchesAndTags({ uri }) { + return await gitOps.getBranchAndTags(uri); + }, + async commitDiff({ uri, revision }) { + return await gitOps.getCommitDiff(uri, revision); + }, + async blame({ uri, path, revision }) { + return await gitOps.blame(uri, revision, path); + }, + async commit({ uri, revision }) { + return await gitOps.getCommitOr404(uri, revision); + }, + async headRevision({ uri }) { + return await gitOps.getHeadRevision(uri); + }, +}); diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/index.ts b/x-pack/legacy/plugins/code/server/distributed/apis/index.ts new file mode 100644 index 0000000000000..1a33cc7f220df --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './git_api'; +export * from './lsp_api'; +export * from './workspace_api'; +export * from './setup_api'; +export * from './repository_api'; diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/lsp_api.ts b/x-pack/legacy/plugins/code/server/distributed/apis/lsp_api.ts new file mode 100644 index 0000000000000..e1dd6d26607e6 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/lsp_api.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { LspService } from '../../lsp/lsp_service'; +import { ServiceHandlerFor } from '../service_definition'; +import { LanguageServerDefinition } from '../../lsp/language_servers'; +import { LanguageServerStatus } from '../../../common/language_server'; +import { WorkspaceStatus } from '../../lsp/request_expander'; + +export const LspServiceDefinitionOption = { routePrefix: '/api/code/internal/lsp' }; +export const LspServiceDefinition = { + sendRequest: { + request: {} as { method: string; params: any; timeoutForInitializeMs?: number }, + response: {} as ResponseMessage, + }, + languageSeverDef: { + request: {} as { lang: string }, + response: {} as LanguageServerDefinition[], + }, + languageServerStatus: { + request: {} as { langName: string }, + response: {} as LanguageServerStatus, + }, + initializeState: { + request: {} as { repoUri: string; revision: string }, + response: {} as { [p: string]: WorkspaceStatus }, + }, +}; + +export const getLspServiceHandler = ( + lspService: LspService +): ServiceHandlerFor => ({ + async sendRequest({ method, params, timeoutForInitializeMs }) { + return await lspService.sendRequest(method, params, timeoutForInitializeMs); + }, + async languageSeverDef({ lang }) { + return lspService.getLanguageSeverDef(lang); + }, + async languageServerStatus({ langName }) { + return lspService.languageServerStatus(langName); + }, + async initializeState({ repoUri, revision }) { + return await lspService.initializeState(repoUri, revision); + }, +}); diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/repository_api.ts b/x-pack/legacy/plugins/code/server/distributed/apis/repository_api.ts new file mode 100644 index 0000000000000..384ebc56acca2 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/repository_api.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServiceHandlerFor } from '../service_definition'; +import { CloneWorker, DeleteWorker, IndexWorker } from '../../queue'; + +export const RepositoryServiceDefinition = { + clone: { + request: {} as { url: string }, + response: {}, + }, + delete: { + request: {} as { uri: string }, + response: {}, + }, + index: { + request: {} as { + uri: string; + revision: string | undefined; + enforceReindex: boolean; + }, + response: {}, + }, +}; + +export const getRepositoryHandler = ( + cloneWorker: CloneWorker, + deleteWorker: DeleteWorker, + indexWorker: IndexWorker +): ServiceHandlerFor => ({ + async clone(payload: { url: string }) { + await cloneWorker.enqueueJob(payload, {}); + return {}; + }, + async delete(payload: { uri: string }) { + await deleteWorker.enqueueJob(payload, {}); + return {}; + }, + async index(payload: { uri: string; revision: string | undefined; enforceReindex: boolean }) { + await indexWorker.enqueueJob(payload, {}); + return {}; + }, +}); diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/setup_api.ts b/x-pack/legacy/plugins/code/server/distributed/apis/setup_api.ts new file mode 100644 index 0000000000000..229470166568b --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/setup_api.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServiceHandlerFor } from '../service_definition'; + +export const SetupDefinition = { + setup: { + request: {}, + response: {} as string, + }, +}; + +export const setupServiceHandler: ServiceHandlerFor = { + async setup() { + return 'ok'; + }, +}; diff --git a/x-pack/legacy/plugins/code/server/distributed/apis/workspace_api.ts b/x-pack/legacy/plugins/code/server/distributed/apis/workspace_api.ts new file mode 100644 index 0000000000000..19fa9fc937d96 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/apis/workspace_api.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'hapi'; +import { ServiceHandlerFor } from '../service_definition'; +import { WorkspaceHandler } from '../../lsp/workspace_handler'; +import { RepoConfig } from '../../../model'; +import { WorkspaceCommand } from '../../lsp/workspace_command'; +import { Logger } from '../../log'; + +export const WorkspaceDefinition = { + initCmd: { + request: {} as { repoUri: string; revision: string; repoConfig: RepoConfig; force: boolean }, + response: {}, + }, +}; + +export const getWorkspaceHandler = ( + server: Server, + workspaceHandler: WorkspaceHandler +): ServiceHandlerFor => ({ + async initCmd({ repoUri, revision, repoConfig, force }) { + try { + const { workspaceDir, workspaceRevision } = await workspaceHandler.openWorkspace( + repoUri, + revision + ); + const log = new Logger(server, ['workspace', repoUri]); + + const workspaceCmd = new WorkspaceCommand(repoConfig, workspaceDir, workspaceRevision, log); + await workspaceCmd.runInit(force); + return {}; + } catch (e) { + if (e.isBoom) { + return e; + } + } + }, +}); diff --git a/x-pack/legacy/plugins/code/server/distributed/code_services.test.ts b/x-pack/legacy/plugins/code/server/distributed/code_services.test.ts new file mode 100644 index 0000000000000..eaefdaf5448e2 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/code_services.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Request, Server } from 'hapi'; +import { createTestHapiServer } from '../test_utils'; +import { LocalHandlerAdapter } from './local_handler_adapter'; +import { CodeServerRouter } from '../security'; +import { RequestContext, ServiceHandlerFor } from './service_definition'; +import { CodeNodeAdapter, RequestPayload } from './multinode/code_node_adapter'; +import { DEFAULT_SERVICE_OPTION } from './service_handler_adapter'; +import { NonCodeNodeAdapter } from './multinode/non_code_node_adapter'; +import { CodeServices } from './code_services'; +import { Logger } from '../log'; + +let hapiServer: Server = createTestHapiServer(); +const log = new Logger(hapiServer); + +let server: CodeServerRouter = new CodeServerRouter(hapiServer); +beforeEach(async () => { + hapiServer = createTestHapiServer(); + server = new CodeServerRouter(hapiServer); +}); +const TestDefinition = { + test1: { + request: {} as { name: string }, + response: {} as { result: string }, + }, + test2: { + request: {}, + response: {} as RequestContext, + routePath: 'userDefinedPath', + }, +}; + +export const testServiceHandler: ServiceHandlerFor = { + async test1({ name }) { + return { result: `hello ${name}` }; + }, + async test2(_, context: RequestContext) { + return context; + }, +}; + +test('local adapter should work', async () => { + const services = new CodeServices(new LocalHandlerAdapter()); + services.registerHandler(TestDefinition, testServiceHandler); + const testApi = services.serviceFor(TestDefinition); + const endpoint = await services.locate({} as Request, ''); + const { result } = await testApi.test1(endpoint, { name: 'tester' }); + expect(result).toBe(`hello tester`); +}); + +test('multi-node adapter should register routes', async () => { + const services = new CodeServices(new CodeNodeAdapter(server, log)); + services.registerHandler(TestDefinition, testServiceHandler); + const prefix = DEFAULT_SERVICE_OPTION.routePrefix; + + const path1 = `${prefix}/test1`; + const response = await hapiServer.inject({ + method: 'POST', + url: path1, + payload: { params: { name: 'tester' } }, + }); + expect(response.statusCode).toBe(200); + const { data } = JSON.parse(response.payload); + expect(data.result).toBe(`hello tester`); +}); + +test('non-code-node could send request to code-node', async () => { + const codeNode = new CodeServices(new CodeNodeAdapter(server, log)); + const codeNodeUrl = 'http://localhost:5601'; + const nonCodeNodeAdapter = new NonCodeNodeAdapter(codeNodeUrl, log); + const nonCodeNode = new CodeServices(nonCodeNodeAdapter); + // replace client request fn to hapi.inject + nonCodeNodeAdapter.requestFn = async ( + baseUrl: string, + path: string, + payload: RequestPayload, + originRequest: Request + ) => { + expect(baseUrl).toBe(codeNodeUrl); + const response = await hapiServer.inject({ + method: 'POST', + url: path, + headers: originRequest.headers, + payload, + }); + expect(response.statusCode).toBe(200); + return JSON.parse(response.payload); + }; + codeNode.registerHandler(TestDefinition, testServiceHandler); + nonCodeNode.registerHandler(TestDefinition, null); + const testApi = nonCodeNode.serviceFor(TestDefinition); + const fakeRequest = ({ + path: 'fakePath', + headers: { + fakeHeader: 'fakeHeaderValue', + }, + } as unknown) as Request; + const fakeResource = 'fakeResource'; + const endpoint = await nonCodeNode.locate(fakeRequest, fakeResource); + const { result } = await testApi.test1(endpoint, { name: 'tester' }); + expect(result).toBe(`hello tester`); + + const context = await testApi.test2(endpoint, {}); + expect(context.resource).toBe(fakeResource); + expect(context.path).toBe(fakeRequest.path); +}); diff --git a/x-pack/legacy/plugins/code/server/distributed/code_services.ts b/x-pack/legacy/plugins/code/server/distributed/code_services.ts new file mode 100644 index 0000000000000..98aa5b7ccdf0c --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/code_services.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServiceDefinition, ServiceHandlerFor, ServiceMethodMap } from './service_definition'; +import { + DEFAULT_SERVICE_OPTION, + ServiceHandlerAdapter, + ServiceRegisterOptions, +} from './service_handler_adapter'; +import { Endpoint } from './resource_locator'; +import { RequestFacade } from '../../'; + +export class CodeServices { + constructor(private readonly adapter: ServiceHandlerAdapter) {} + + public registerHandler( + serviceDefinition: serviceDefinition, + serviceHandler: ServiceHandlerFor | null, + options: ServiceRegisterOptions = DEFAULT_SERVICE_OPTION + ) { + this.adapter.registerHandler(serviceDefinition, serviceHandler, options); + } + + public locate(req: RequestFacade, resource: string): Promise { + return this.adapter.locator.locate(req, resource); + } + + public serviceFor(serviceDefinition: def): ServiceMethodMap { + return this.adapter.getService(serviceDefinition); + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/local_endpoint.ts b/x-pack/legacy/plugins/code/server/distributed/local_endpoint.ts new file mode 100644 index 0000000000000..689ecc7fc641b --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/local_endpoint.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { Endpoint } from './resource_locator'; +import { RequestContext } from './service_definition'; + +export class LocalEndpoint implements Endpoint { + constructor(readonly httpRequest: Request, readonly resource: string) {} + + toContext(): RequestContext { + return { + resource: this.resource, + path: this.httpRequest.path, + } as RequestContext; + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/local_handler_adapter.ts b/x-pack/legacy/plugins/code/server/distributed/local_handler_adapter.ts new file mode 100644 index 0000000000000..1e51a29452686 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/local_handler_adapter.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { ServiceHandlerAdapter } from './service_handler_adapter'; +import { ServiceDefinition, ServiceHandlerFor, ServiceMethodMap } from './service_definition'; +import { Endpoint, ResourceLocator } from './resource_locator'; +import { LocalEndpoint } from './local_endpoint'; + +export class LocalHandlerAdapter implements ServiceHandlerAdapter { + handlers: Map = new Map(); + + registerHandler( + serviceDefinition: def, + serviceHandler: ServiceHandlerFor | null + ) { + if (!serviceHandler) { + throw new Error("Local service handler can't be null!"); + } + const dispatchedHandler: { [key: string]: any } = {}; + // eslint-disable-next-line guard-for-in + for (const method in serviceDefinition) { + dispatchedHandler[method] = function(endpoint: Endpoint, params: any) { + return serviceHandler[method](params, endpoint.toContext()); + }; + } + this.handlers.set(serviceDefinition, dispatchedHandler); + return dispatchedHandler as ServiceMethodMap; + } + + getService(serviceDefinition: def): ServiceMethodMap { + const serviceHandler = this.handlers.get(serviceDefinition); + if (serviceHandler) { + return serviceHandler as ServiceMethodMap; + } else { + throw new Error(`handler for ${serviceDefinition} not found`); + } + } + + locator: ResourceLocator = { + async locate(httpRequest: Request, resource: string): Promise { + return Promise.resolve(new LocalEndpoint(httpRequest, resource)); + }, + }; +} diff --git a/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_adapter.ts b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_adapter.ts new file mode 100644 index 0000000000000..eb550a9a2eb08 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_adapter.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import util from 'util'; +import Boom from 'boom'; +import { + DEFAULT_SERVICE_OPTION, + ServiceHandlerAdapter, + ServiceRegisterOptions, +} from '../service_handler_adapter'; +import { Endpoint, ResourceLocator } from '../resource_locator'; +import { + RequestContext, + ServiceDefinition, + ServiceHandlerFor, + ServiceMethodMap, +} from '../service_definition'; +import { CodeServerRouter } from '../../security'; +import { LocalHandlerAdapter } from '../local_handler_adapter'; +import { LocalEndpoint } from '../local_endpoint'; +import { Logger } from '../../log'; + +export interface RequestPayload { + context: RequestContext; + params: any; +} + +export class CodeNodeAdapter implements ServiceHandlerAdapter { + localAdapter: LocalHandlerAdapter = new LocalHandlerAdapter(); + constructor(private readonly server: CodeServerRouter, private readonly log: Logger) {} + + locator: ResourceLocator = { + async locate(httpRequest: Request, resource: string): Promise { + return Promise.resolve(new LocalEndpoint(httpRequest, resource)); + }, + }; + + getService(serviceDefinition: def): ServiceMethodMap { + // services on code node dispatch to local directly + return this.localAdapter.getService(serviceDefinition); + } + + registerHandler( + serviceDefinition: def, + serviceHandler: ServiceHandlerFor | null, + options: ServiceRegisterOptions = DEFAULT_SERVICE_OPTION + ) { + if (!serviceHandler) { + throw new Error("Code node service handler can't be null!"); + } + const serviceMethodMap = this.localAdapter.registerHandler(serviceDefinition, serviceHandler); + // eslint-disable-next-line guard-for-in + for (const method in serviceDefinition) { + const d = serviceDefinition[method]; + const path = `${options.routePrefix}/${d.routePath || method}`; + // register routes, receive requests from non-code node. + this.server.route({ + method: 'post', + path, + handler: async (req: Request) => { + const { context, params } = req.payload as RequestPayload; + this.log.debug(`Receiving RPC call ${req.url.path} ${util.inspect(params)}`); + const endpoint: Endpoint = { + toContext(): RequestContext { + return context; + }, + }; + try { + const data = await serviceMethodMap[method](endpoint, params); + return { data }; + } catch (e) { + if (!Boom.isBoom(e)) { + throw Boom.boomify(e, { statusCode: 500 }); + } else { + throw e; + } + } + }, + }); + } + return serviceMethodMap; + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_endpoint.ts b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_endpoint.ts new file mode 100644 index 0000000000000..048b7c81dfe6f --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_endpoint.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { LocalEndpoint } from '../local_endpoint'; + +export class CodeNodeEndpoint extends LocalEndpoint { + constructor( + public readonly httpRequest: Request, + public readonly resource: string, + public readonly codeNodeUrl: string + ) { + super(httpRequest, resource); + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_resource_locator.ts b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_resource_locator.ts new file mode 100644 index 0000000000000..a06dd4743974d --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/multinode/code_node_resource_locator.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { Endpoint, ResourceLocator } from '../resource_locator'; +import { CodeNodeEndpoint } from './code_node_endpoint'; + +export class CodeNodeResourceLocator implements ResourceLocator { + constructor(private readonly codeNodeUrl: string) {} + + async locate(httpRequest: Request, resource: string): Promise { + return Promise.resolve(new CodeNodeEndpoint(httpRequest, resource, this.codeNodeUrl)); + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/multinode/non_code_node_adapter.ts b/x-pack/legacy/plugins/code/server/distributed/multinode/non_code_node_adapter.ts new file mode 100644 index 0000000000000..7e0ba0d7681c3 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/multinode/non_code_node_adapter.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Wreck from '@hapi/wreck'; +import util from 'util'; +import Boom from 'boom'; +import { Request } from 'hapi'; +import * as http from 'http'; +import { + DEFAULT_SERVICE_OPTION, + ServiceHandlerAdapter, + ServiceRegisterOptions, +} from '../service_handler_adapter'; +import { ResourceLocator } from '../resource_locator'; +import { ServiceDefinition, ServiceHandlerFor, ServiceMethodMap } from '../service_definition'; +import { CodeNodeResourceLocator } from './code_node_resource_locator'; +import { CodeNodeEndpoint } from './code_node_endpoint'; +import { RequestPayload } from './code_node_adapter'; +import { Logger } from '../../log'; + +const pickHeaders = ['authorization']; + +function filterHeaders(originRequest: Request) { + const result: { [name: string]: string } = {}; + for (const header of pickHeaders) { + if (originRequest.headers[header]) { + result[header] = originRequest.headers[header]; + } + } + return result; +} + +export class NonCodeNodeAdapter implements ServiceHandlerAdapter { + handlers: Map = new Map(); + + constructor(private readonly codeNodeUrl: string, private readonly log: Logger) {} + + locator: ResourceLocator = new CodeNodeResourceLocator(this.codeNodeUrl); + + getService(serviceDefinition: def): ServiceMethodMap { + const serviceHandler = this.handlers.get(serviceDefinition); + if (!serviceHandler) { + // we don't need implement code for service, so we can register here. + this.registerHandler(serviceDefinition, null); + } + return serviceHandler as ServiceMethodMap; + } + + registerHandler( + serviceDefinition: def, + // serviceHandler will always be null here since it will be overridden by the request forwarding. + serviceHandler: ServiceHandlerFor | null, + options: ServiceRegisterOptions = DEFAULT_SERVICE_OPTION + ) { + const dispatchedHandler: { [key: string]: any } = {}; + // eslint-disable-next-line guard-for-in + for (const method in serviceDefinition) { + const d = serviceDefinition[method]; + const path = `${options.routePrefix}/${d.routePath || method}`; + dispatchedHandler[method] = async (endpoint: CodeNodeEndpoint, params: any) => { + const payload = { + context: endpoint.toContext(), + params, + }; + const { data } = await this.requestFn( + endpoint.codeNodeUrl, + path, + payload, + endpoint.httpRequest + ); + return data; + }; + } + this.handlers.set(serviceDefinition, dispatchedHandler); + return dispatchedHandler as ServiceMethodMap; + } + + async requestFn(baseUrl: string, path: string, payload: RequestPayload, originRequest: Request) { + const opt = { + baseUrl, + payload: JSON.stringify(payload), + // redirect all headers to CodeNode + headers: { ...filterHeaders(originRequest), 'kbn-xsrf': 'kibana' }, + }; + const promise = Wreck.request('POST', path, opt); + const res: http.IncomingMessage = await promise; + this.log.debug(`sending RPC call to ${baseUrl}${path} ${res.statusCode}`); + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + const buffer: Buffer = await Wreck.read(res, {}); + try { + return JSON.parse(buffer.toString(), (key, value) => { + return value && value.type === 'Buffer' ? Buffer.from(value.data) : value; + }); + } catch (e) { + this.log.error('parse json failed: ' + buffer.toString()); + throw Boom.boomify(e, { statusCode: 500 }); + } + } else { + this.log.error( + `received ${res.statusCode} from ${baseUrl}/${path}, params was ${util.inspect( + payload.params + )}` + ); + const body: Boom.Payload = await Wreck.read(res, { json: true }); + throw new Boom(body.message, { statusCode: res.statusCode || 500, data: body.error }); + } + } +} diff --git a/x-pack/legacy/plugins/code/server/distributed/resource_locator.ts b/x-pack/legacy/plugins/code/server/distributed/resource_locator.ts new file mode 100644 index 0000000000000..7794ce5c48732 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/resource_locator.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'hapi'; +import { RequestContext } from './service_definition'; + +export interface Endpoint { + toContext(): RequestContext; +} + +export interface ResourceLocator { + locate(req: Request, resource: string): Promise; +} diff --git a/x-pack/legacy/plugins/code/server/distributed/service_definition.ts b/x-pack/legacy/plugins/code/server/distributed/service_definition.ts new file mode 100644 index 0000000000000..a3aa6643c76d0 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/service_definition.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Endpoint } from './resource_locator'; + +export interface ServiceDefinition { + [method: string]: { request: any; response: any; routePath?: string }; +} + +export type MethodsFor = Extract< + keyof serviceDefinition, + string +>; + +export type MethodHandler< + serviceDefinition extends ServiceDefinition, + method extends MethodsFor +> = ( + request: RequestFor, + context: RequestContext +) => Promise>; + +export type ServiceHandlerFor = { + [method in MethodsFor]: MethodHandler; +}; + +export type RequestFor< + serviceDefinition extends ServiceDefinition, + method extends MethodsFor +> = serviceDefinition[method]['request']; + +export type ResponseFor< + serviceDefinition extends ServiceDefinition, + method extends MethodsFor +> = serviceDefinition[method]['response']; + +export interface RequestContext { + path: string; + resource: string; +} + +export type ServiceMethodMap = { + [method in MethodsFor]: ServiceMethod; +}; + +export type ServiceMethod< + serviceDefinition extends ServiceDefinition, + method extends MethodsFor +> = ( + endpoint: Endpoint, + request: RequestFor +) => Promise>; diff --git a/x-pack/legacy/plugins/code/server/distributed/service_handler_adapter.ts b/x-pack/legacy/plugins/code/server/distributed/service_handler_adapter.ts new file mode 100644 index 0000000000000..88a81d278e744 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/distributed/service_handler_adapter.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServiceDefinition, ServiceHandlerFor, ServiceMethodMap } from './service_definition'; +import { ResourceLocator } from './resource_locator'; + +export interface ServiceRegisterOptions { + routePrefix?: string; +} + +export const DEFAULT_SERVICE_OPTION: ServiceRegisterOptions = { + routePrefix: '/api/code/internal', +}; + +export interface ServiceHandlerAdapter { + locator: ResourceLocator; + getService(serviceDefinition: DEF): ServiceMethodMap; + registerHandler( + serviceDefinition: DEF, + serviceHandler: ServiceHandlerFor | null, + options: ServiceRegisterOptions + ): ServiceMethodMap; +} diff --git a/x-pack/legacy/plugins/code/server/index.ts b/x-pack/legacy/plugins/code/server/index.ts new file mode 100644 index 0000000000000..510abaef68a09 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; + +import * as constants from '../common/constants'; +import { CodePlugin } from './plugin'; + +export const codePlugin = (initializerContext: PluginInitializerContext) => + new CodePlugin(initializerContext); +export { constants }; diff --git a/x-pack/legacy/plugins/code/server/init.ts b/x-pack/legacy/plugins/code/server/init.ts deleted file mode 100644 index 2d1b44c0ba284..0000000000000 --- a/x-pack/legacy/plugins/code/server/init.ts +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import crypto from 'crypto'; -import { Server } from 'hapi'; -import * as _ from 'lodash'; -import { i18n } from '@kbn/i18n'; - -import { XPackMainPlugin } from '../../xpack_main/xpack_main'; -import { GitOperations } from './git_operations'; -import { LspIndexerFactory, RepositoryIndexInitializerFactory, tryMigrateIndices } from './indexer'; -import { EsClient, Esqueue } from './lib/esqueue'; -import { Logger } from './log'; -import { InstallManager } from './lsp/install_manager'; -import { JAVA } from './lsp/language_servers'; -import { LspService } from './lsp/lsp_service'; -import { CancellationSerivce, CloneWorker, DeleteWorker, IndexWorker, UpdateWorker } from './queue'; -import { RepositoryConfigController } from './repository_config_controller'; -import { RepositoryServiceFactory } from './repository_service_factory'; -import { fileRoute } from './routes/file'; -import { installRoute } from './routes/install'; -import { lspRoute, symbolByQnameRoute } from './routes/lsp'; -import { redirectRoute } from './routes/redirect'; -import { repositoryRoute } from './routes/repository'; -import { documentSearchRoute, repositorySearchRoute, symbolSearchRoute } from './routes/search'; -import { setupRoute } from './routes/setup'; -import { workspaceRoute } from './routes/workspace'; -import { CloneScheduler, IndexScheduler, UpdateScheduler } from './scheduler'; -import { CodeServerRouter } from './security'; -import { ServerOptions } from './server_options'; -import { ServerLoggerFactory } from './utils/server_logger_factory'; -import { EsClientWithInternalRequest } from './utils/esclient_with_internal_request'; -import { checkCodeNode, checkRoute } from './routes/check'; -import { statusRoute } from './routes/status'; - -async function retryUntilAvailable( - func: () => Promise, - intervalMs: number, - retries: number = Number.MAX_VALUE -): Promise { - const value = await func(); - if (value) { - return value; - } else { - const promise = new Promise(resolve => { - const retry = () => { - func().then(v => { - if (v) { - resolve(v); - } else { - retries--; - if (retries > 0) { - setTimeout(retry, intervalMs); - } else { - resolve(v); - } - } - }); - }; - setTimeout(retry, intervalMs); - }); - return await promise; - } -} - -export function init(server: Server, options: any) { - if (!options.ui.enabled) { - return; - } - - const log = new Logger(server); - const serverOptions = new ServerOptions(options, server.config()); - const xpackMainPlugin: XPackMainPlugin = server.plugins.xpack_main; - xpackMainPlugin.registerFeature({ - id: 'code', - name: i18n.translate('xpack.code.featureRegistry.codeFeatureName', { - defaultMessage: 'Code', - }), - icon: 'codeApp', - navLinkId: 'code', - app: ['code', 'kibana'], - catalogue: [], // TODO add catalogue here - privileges: { - all: { - api: ['code_user', 'code_admin'], - savedObject: { - all: [], - read: ['config'], - }, - ui: ['show', 'user', 'admin'], - }, - read: { - api: ['code_user'], - savedObject: { - all: [], - read: ['config'], - }, - ui: ['show', 'user'], - }, - }, - }); - - // @ts-ignore - const kbnServer = this.kbnServer; - kbnServer.ready().then(async () => { - const codeNodeUrl = serverOptions.codeNodeUrl; - const rndString = crypto.randomBytes(20).toString('hex'); - checkRoute(server, rndString); - if (codeNodeUrl) { - const checkResult = await retryUntilAvailable( - async () => await checkCodeNode(codeNodeUrl, log, rndString), - 5000 - ); - if (checkResult.me) { - await initCodeNode(server, serverOptions, log); - } else { - await initNonCodeNode(codeNodeUrl, server, log); - } - } else { - // codeNodeUrl not set, single node mode - await initCodeNode(server, serverOptions, log); - } - }); -} - -async function initNonCodeNode(url: string, server: Server, log: Logger) { - log.info(`Initializing Code plugin as non-code node, redirecting all code requests to ${url}`); - redirectRoute(server, url, log); -} - -async function initCodeNode(server: Server, serverOptions: ServerOptions, log: Logger) { - // wait until elasticsearch is ready - // @ts-ignore - await server.plugins.elasticsearch.waitUntilReady(); - - log.info('Initializing Code plugin as code-node.'); - const queueIndex: string = server.config().get('xpack.code.queueIndex'); - const queueTimeoutMs: number = server.config().get('xpack.code.queueTimeoutMs'); - const devMode: boolean = server.config().get('env.dev'); - - const esClient: EsClient = new EsClientWithInternalRequest(server); - const repoConfigController = new RepositoryConfigController(esClient); - - server.injectUiAppVars('code', () => ({ - enableLangserversDeveloping: devMode, - })); - // Enable the developing language servers in development mode. - if (devMode) { - JAVA.downloadUrl = _.partialRight(JAVA!.downloadUrl!, devMode); - } - - // Initialize git operations - const gitOps = new GitOperations(serverOptions.repoPath); - - const installManager = new InstallManager(server, serverOptions); - const lspService = new LspService( - '127.0.0.1', - serverOptions, - gitOps, - esClient, - installManager, - new ServerLoggerFactory(server), - repoConfigController - ); - server.events.on('stop', async () => { - log.debug('shutdown lsp process'); - await lspService.shutdown(); - }); - // Initialize indexing factories. - const lspIndexerFactory = new LspIndexerFactory(lspService, serverOptions, gitOps, esClient, log); - - const repoIndexInitializerFactory = new RepositoryIndexInitializerFactory(esClient, log); - - // Initialize queue worker cancellation service. - const cancellationService = new CancellationSerivce(); - - // Execute index version checking and try to migrate index data if necessary. - await tryMigrateIndices(esClient, log); - - // Initialize queue. - const queue = new Esqueue(queueIndex, { - client: esClient, - timeout: queueTimeoutMs, - }); - const indexWorker = new IndexWorker( - queue, - log, - esClient, - [lspIndexerFactory], - gitOps, - cancellationService - ).bind(); - - const repoServiceFactory: RepositoryServiceFactory = new RepositoryServiceFactory(); - - const cloneWorker = new CloneWorker( - queue, - log, - esClient, - serverOptions, - gitOps, - indexWorker, - repoServiceFactory, - cancellationService - ).bind(); - const deleteWorker = new DeleteWorker( - queue, - log, - esClient, - serverOptions, - gitOps, - cancellationService, - lspService, - repoServiceFactory - ).bind(); - const updateWorker = new UpdateWorker( - queue, - log, - esClient, - serverOptions, - gitOps, - repoServiceFactory, - cancellationService - ).bind(); - - // Initialize schedulers. - const cloneScheduler = new CloneScheduler(cloneWorker, serverOptions, esClient, log); - const updateScheduler = new UpdateScheduler(updateWorker, serverOptions, esClient, log); - const indexScheduler = new IndexScheduler(indexWorker, serverOptions, esClient, log); - updateScheduler.start(); - if (!serverOptions.disableIndexScheduler) { - indexScheduler.start(); - } - // Check if the repository is local on the file system. - // This should be executed once at the startup time of Kibana. - cloneScheduler.schedule(); - - const codeServerRouter = new CodeServerRouter(server); - // Add server routes and initialize the plugin here - repositoryRoute( - codeServerRouter, - cloneWorker, - deleteWorker, - indexWorker, - repoIndexInitializerFactory, - repoConfigController, - serverOptions - ); - repositorySearchRoute(codeServerRouter, log); - documentSearchRoute(codeServerRouter, log); - symbolSearchRoute(codeServerRouter, log); - fileRoute(codeServerRouter, gitOps); - workspaceRoute(codeServerRouter, serverOptions, gitOps); - symbolByQnameRoute(codeServerRouter, log); - installRoute(codeServerRouter, lspService); - lspRoute(codeServerRouter, lspService, serverOptions); - setupRoute(codeServerRouter); - statusRoute(codeServerRouter, gitOps, lspService); - - server.events.on('stop', () => { - gitOps.cleanAllRepo(); - if (!serverOptions.disableIndexScheduler) { - indexScheduler.stop(); - } - updateScheduler.stop(); - queue.destroy(); - }); -} diff --git a/x-pack/legacy/plugins/code/server/init_es.ts b/x-pack/legacy/plugins/code/server/init_es.ts new file mode 100644 index 0000000000000..39ae05bf26877 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/init_es.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'hapi'; +import { RepositoryIndexInitializerFactory } from './indexer'; +import { RepositoryConfigController } from './repository_config_controller'; +import { EsClientWithInternalRequest } from './utils/esclient_with_internal_request'; +import { EsClient } from './lib/esqueue'; +import { Logger } from './log'; + +export async function initEs(server: Server, log: Logger) { + // wait until elasticsearch is ready + await server.plugins.elasticsearch.waitUntilReady(); + const esClient: EsClient = new EsClientWithInternalRequest(server); + const repoConfigController = new RepositoryConfigController(esClient); + const repoIndexInitializerFactory = new RepositoryIndexInitializerFactory(esClient, log); + return { + esClient, + repoConfigController, + repoIndexInitializerFactory, + }; +} diff --git a/x-pack/legacy/plugins/code/server/init_local.ts b/x-pack/legacy/plugins/code/server/init_local.ts new file mode 100644 index 0000000000000..f39726c5e55e5 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/init_local.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'hapi'; +import { ServerOptions } from './server_options'; +import { CodeServices } from './distributed/code_services'; +import { EsClient } from './lib/esqueue'; +import { RepositoryConfigController } from './repository_config_controller'; +import { GitOperations } from './git_operations'; +import { Logger } from './log'; +import { + getGitServiceHandler, + getLspServiceHandler, + getWorkspaceHandler, + GitServiceDefinition, + GitServiceDefinitionOption, + LspServiceDefinition, + LspServiceDefinitionOption, + SetupDefinition, + setupServiceHandler, + WorkspaceDefinition, +} from './distributed/apis'; +import { InstallManager } from './lsp/install_manager'; +import { LspService } from './lsp/lsp_service'; +import { ServerLoggerFactory } from './utils/server_logger_factory'; + +export function initLocalService( + server: Server, + log: Logger, + serverOptions: ServerOptions, + codeServices: CodeServices, + esClient: EsClient, + repoConfigController: RepositoryConfigController +) { + // Initialize git operations + const gitOps = new GitOperations(serverOptions.repoPath); + codeServices.registerHandler( + GitServiceDefinition, + getGitServiceHandler(gitOps), + GitServiceDefinitionOption + ); + + const installManager = new InstallManager(server, serverOptions); + const lspService = new LspService( + '127.0.0.1', + serverOptions, + gitOps, + esClient, + installManager, + new ServerLoggerFactory(server), + repoConfigController + ); + server.events.on('stop', async () => { + log.debug('shutdown lsp process'); + await lspService.shutdown(); + await gitOps.cleanAllRepo(); + }); + codeServices.registerHandler( + LspServiceDefinition, + getLspServiceHandler(lspService), + LspServiceDefinitionOption + ); + codeServices.registerHandler( + WorkspaceDefinition, + getWorkspaceHandler(server, lspService.workspaceHandler) + ); + codeServices.registerHandler(SetupDefinition, setupServiceHandler); + + return { gitOps, lspService, installManager }; +} diff --git a/x-pack/legacy/plugins/code/server/init_queue.ts b/x-pack/legacy/plugins/code/server/init_queue.ts new file mode 100644 index 0000000000000..444eb589d5303 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/init_queue.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'hapi'; +import { EsClient, Esqueue } from './lib/esqueue'; +import { Logger } from './log'; + +export function initQueue(server: Server, log: Logger, esClient: EsClient) { + const queueIndex: string = server.config().get('xpack.code.queueIndex'); + const queueTimeoutMs: number = server.config().get('xpack.code.queueTimeoutMs'); + const queue = new Esqueue(queueIndex, { + client: esClient, + timeout: queueTimeoutMs, + }); + return queue; +} diff --git a/x-pack/legacy/plugins/code/server/init_workers.ts b/x-pack/legacy/plugins/code/server/init_workers.ts new file mode 100644 index 0000000000000..56ccc198fb6b0 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/init_workers.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'hapi'; +import { DiskWatermarkService } from './disk_watermark'; +import { EsClient, Esqueue } from './lib/esqueue'; +import { LspService } from './lsp/lsp_service'; +import { GitOperations } from './git_operations'; +import { ServerOptions } from './server_options'; +import { CodeServices } from './distributed/code_services'; +import { LspIndexerFactory } from './indexer'; +import { CancellationSerivce, CloneWorker, DeleteWorker, IndexWorker, UpdateWorker } from './queue'; +import { RepositoryServiceFactory } from './repository_service_factory'; +import { getRepositoryHandler, RepositoryServiceDefinition } from './distributed/apis'; +import { CloneScheduler, IndexScheduler, UpdateScheduler } from './scheduler'; +import { Logger } from './log'; + +export function initWorkers( + server: Server, + log: Logger, + esClient: EsClient, + queue: Esqueue, + lspService: LspService, + gitOps: GitOperations, + serverOptions: ServerOptions, + codeServices: CodeServices +) { + // Initialize indexing factories. + const lspIndexerFactory = new LspIndexerFactory(lspService, serverOptions, gitOps, esClient, log); + + // Initialize queue worker cancellation service. + const cancellationService = new CancellationSerivce(); + const indexWorker = new IndexWorker( + queue, + log, + esClient, + [lspIndexerFactory], + gitOps, + cancellationService + ).bind(); + + const repoServiceFactory: RepositoryServiceFactory = new RepositoryServiceFactory(); + + const watermarkService = new DiskWatermarkService( + serverOptions.disk.watermarkLowMb, + serverOptions.repoPath + ); + const cloneWorker = new CloneWorker( + queue, + log, + esClient, + serverOptions, + gitOps, + indexWorker, + repoServiceFactory, + cancellationService, + watermarkService + ).bind(); + const deleteWorker = new DeleteWorker( + queue, + log, + esClient, + serverOptions, + gitOps, + cancellationService, + lspService, + repoServiceFactory + ).bind(); + const updateWorker = new UpdateWorker( + queue, + log, + esClient, + serverOptions, + gitOps, + repoServiceFactory, + cancellationService, + watermarkService + ).bind(); + codeServices.registerHandler( + RepositoryServiceDefinition, + getRepositoryHandler(cloneWorker, deleteWorker, indexWorker) + ); + + // Initialize schedulers. + const cloneScheduler = new CloneScheduler(cloneWorker, serverOptions, esClient, log); + const updateScheduler = new UpdateScheduler(updateWorker, serverOptions, esClient, log); + const indexScheduler = new IndexScheduler(indexWorker, serverOptions, esClient, log); + updateScheduler.start(); + indexScheduler.start(); + // Check if the repository is local on the file system. + // This should be executed once at the startup time of Kibana. + cloneScheduler.schedule(); + return { indexScheduler, updateScheduler }; +} diff --git a/x-pack/legacy/plugins/code/server/log.ts b/x-pack/legacy/plugins/code/server/log.ts index 483f304d80bc1..352e649fb7525 100644 --- a/x-pack/legacy/plugins/code/server/log.ts +++ b/x-pack/legacy/plugins/code/server/log.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; import { inspect } from 'util'; import { Logger as VsLogger } from 'vscode-jsonrpc'; +import { ServerFacade } from '..'; + export class Logger implements VsLogger { private readonly verbose: boolean = false; - constructor(private server: Hapi.Server, private baseTags: string[] = ['code']) { + constructor(private server: ServerFacade, private baseTags: string[] = ['code']) { if (server) { this.verbose = this.server.config().get('xpack.code.verbose'); } diff --git a/x-pack/legacy/plugins/code/server/lsp/controller.ts b/x-pack/legacy/plugins/code/server/lsp/controller.ts index 30eb3613cafce..d7c4a44b27f6a 100644 --- a/x-pack/legacy/plugins/code/server/lsp/controller.ts +++ b/x-pack/legacy/plugins/code/server/lsp/controller.ts @@ -46,7 +46,7 @@ export class LanguageServerController implements ILanguageServerHandler { // a list of support language servers private readonly languageServers: LanguageServerData[]; // a { lang -> server } map from above list - private readonly languageServerMap: { [lang: string]: LanguageServerData }; + private readonly languageServerMap: { [lang: string]: LanguageServerData[] }; private log: Logger; constructor( @@ -64,13 +64,22 @@ export class LanguageServerController implements ILanguageServerHandler { maxWorkspace: options.maxWorkspace, launcher: new def.launcher(this.targetHost, options, loggerFactory), })); + const add2map = ( + map: { [lang: string]: LanguageServerData[] }, + lang: string, + ls: LanguageServerData + ) => { + const arr = map[lang] || []; + arr.push(ls); + map[lang] = arr.sort((a, b) => b.definition.priority - a.definition.priority); + }; this.languageServerMap = this.languageServers.reduce( (map, ls) => { - ls.languages.forEach(lang => (map[lang] = ls)); - map[ls.definition.name] = ls; + ls.languages.forEach(lang => add2map(map, lang, ls)); + map[ls.definition.name] = [ls]; return map; }, - {} as { [lang: string]: LanguageServerData } + {} as { [lang: string]: LanguageServerData[] } ); } @@ -183,24 +192,24 @@ export class LanguageServerController implements ILanguageServerHandler { } } - public status(lang: string): LanguageServerStatus { - const ls = this.languageServerMap[lang]; - const status = this.installManager.status(ls.definition); + public status(def: LanguageServerDefinition): LanguageServerStatus { + const status = this.installManager.status(def); // installed, but is it running? if (status === LanguageServerStatus.READY) { - if (ls.launcher.running) { + const ls = this.languageServers.find(d => d.definition === def); + if (ls && ls.launcher.running) { return LanguageServerStatus.RUNNING; } } return status; } - public getLanguageServerDef(lang: string): LanguageServerDefinition | null { + public getLanguageServerDef(lang: string): LanguageServerDefinition[] { const data = this.languageServerMap[lang]; if (data) { - return data.definition; + return data.map(d => d.definition); } - return null; + return []; } private async findOrCreateHandler( @@ -250,18 +259,18 @@ export class LanguageServerController implements ILanguageServerHandler { } private findLanguageServer(lang: string) { - const ls = this.languageServerMap[lang]; - if (ls) { - if ( - !this.options.lsp.detach && - this.installManager.status(ls.definition) !== LanguageServerStatus.READY - ) { + const lsArr = this.languageServerMap[lang]; + if (lsArr) { + const ls = lsArr.find( + d => this.installManager.status(d.definition) !== LanguageServerStatus.NOT_INSTALLED + ); + if (!this.options.lsp.detach && ls === undefined) { throw new ResponseError( LanguageServerNotInstalled, `language server ${lang} not installed` ); } else { - return ls; + return ls!; } } else { throw new ResponseError(UnknownFileLanguage, `unsupported language ${lang}`); diff --git a/x-pack/legacy/plugins/code/server/lsp/install_manager.test.ts b/x-pack/legacy/plugins/code/server/lsp/install_manager.test.ts index 9ce9a024a38e6..6aa80899a7008 100644 --- a/x-pack/legacy/plugins/code/server/lsp/install_manager.test.ts +++ b/x-pack/legacy/plugins/code/server/lsp/install_manager.test.ts @@ -6,15 +6,17 @@ /* eslint-disable no-console */ import fs from 'fs'; +import { Server } from 'hapi'; import os from 'os'; import path from 'path'; +import rimraf from 'rimraf'; + import { LanguageServers } from './language_servers'; import { InstallManager } from './install_manager'; import { ServerOptions } from '../server_options'; -import rimraf from 'rimraf'; import { LanguageServerStatus } from '../../common/language_server'; -import { Server } from 'hapi'; import { InstallationType } from '../../common/installation'; +import { ServerFacade } from '../..'; const LANG_SERVER_NAME = 'Java'; const langSrvDef = LanguageServers.find(l => l.name === LANG_SERVER_NAME)!; @@ -23,7 +25,7 @@ const fakeTestDir = path.join(os.tmpdir(), 'foo-'); const options: ServerOptions = {} as ServerOptions; -const server = new Server(); +const server: ServerFacade = new Server(); server.config = () => { return { get(key: string): any { diff --git a/x-pack/legacy/plugins/code/server/lsp/install_manager.ts b/x-pack/legacy/plugins/code/server/lsp/install_manager.ts index 70de222f16f6a..f75cd2e401991 100644 --- a/x-pack/legacy/plugins/code/server/lsp/install_manager.ts +++ b/x-pack/legacy/plugins/code/server/lsp/install_manager.ts @@ -5,14 +5,15 @@ */ import fs from 'fs'; -import { Server } from 'hapi'; + import { InstallationType } from '../../common/installation'; import { LanguageServerStatus } from '../../common/language_server'; import { ServerOptions } from '../server_options'; import { LanguageServerDefinition } from './language_servers'; +import { ServerFacade } from '../..'; export class InstallManager { - constructor(public readonly server: Server, readonly serverOptions: ServerOptions) {} + constructor(public readonly server: ServerFacade, readonly serverOptions: ServerOptions) {} public status(def: LanguageServerDefinition): LanguageServerStatus { if (def.installationType === InstallationType.Embed) { diff --git a/x-pack/legacy/plugins/code/server/lsp/java_launcher.ts b/x-pack/legacy/plugins/code/server/lsp/java_launcher.ts index 4099398065b1d..82ba16db15ac1 100644 --- a/x-pack/legacy/plugins/code/server/lsp/java_launcher.ts +++ b/x-pack/legacy/plugins/code/server/lsp/java_launcher.ts @@ -43,6 +43,13 @@ export class JavaLauncher extends AbstractLauncher { 'java.autobuild.enabled': false, }, }, + clientCapabilities: { + textDocument: { + documentSymbol: { + hierarchicalDocumentSymbolSupport: true, + }, + }, + }, } as InitializeOptions, this.log ); diff --git a/x-pack/legacy/plugins/code/server/lsp/language_servers.ts b/x-pack/legacy/plugins/code/server/lsp/language_servers.ts index c4f06928bff16..02f5155e10ebd 100644 --- a/x-pack/legacy/plugins/code/server/lsp/language_servers.ts +++ b/x-pack/legacy/plugins/code/server/lsp/language_servers.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; - import { InstallationType } from '../../common/installation'; import { LanguageServer } from '../../common/language_server'; import { CtagsLauncher } from './ctags_launcher'; @@ -14,6 +12,7 @@ import { JavaLauncher } from './java_launcher'; import { LauncherConstructor } from './language_server_launcher'; import { TypescriptServerLauncher } from './ts_launcher'; import { CTAGS_SUPPORT_LANGS } from '../../common/language_server'; +import { ServerFacade } from '../..'; export interface LanguageServerDefinition extends LanguageServer { builtinWorkspaceFolders: boolean; @@ -22,6 +21,7 @@ export interface LanguageServerDefinition extends LanguageServer { downloadUrl?: (version: string, devMode?: boolean) => string; embedPath?: string; installationPluginName?: string; + priority: number; } export const TYPESCRIPT: LanguageServerDefinition = { @@ -31,6 +31,7 @@ export const TYPESCRIPT: LanguageServerDefinition = { launcher: TypescriptServerLauncher, installationType: InstallationType.Embed, embedPath: require.resolve('@elastic/javascript-typescript-langserver/lib/language-server.js'), + priority: 2, }; export const JAVA: LanguageServerDefinition = { name: 'Java', @@ -40,6 +41,7 @@ export const JAVA: LanguageServerDefinition = { installationType: InstallationType.Plugin, installationPluginName: 'java-langserver', installationFolderName: 'jdt', + priority: 2, downloadUrl: (version: string, devMode?: boolean) => devMode! ? `https://snapshots.elastic.co/downloads/java-langserver-plugins/java-langserver/java-langserver-${version}-SNAPSHOT-$OS.zip` @@ -52,6 +54,7 @@ export const GO: LanguageServerDefinition = { launcher: GoServerLauncher, installationType: InstallationType.Plugin, installationPluginName: 'goLanguageServer', + priority: 2, }; export const CTAGS: LanguageServerDefinition = { name: 'Ctags', @@ -60,11 +63,12 @@ export const CTAGS: LanguageServerDefinition = { launcher: CtagsLauncher, installationType: InstallationType.Embed, embedPath: require.resolve('@elastic/ctags-langserver/lib/cli.js'), + priority: 1, }; export const LanguageServers: LanguageServerDefinition[] = [TYPESCRIPT, JAVA, CTAGS]; export const LanguageServersDeveloping: LanguageServerDefinition[] = [GO]; -export function enabledLanguageServers(server: Hapi.Server) { +export function enabledLanguageServers(server: ServerFacade) { const devMode: boolean = server.config().get('env.dev'); function isEnabled(lang: LanguageServerDefinition, defaultEnabled: boolean) { diff --git a/x-pack/legacy/plugins/code/server/lsp/lsp_service.ts b/x-pack/legacy/plugins/code/server/lsp/lsp_service.ts index 5da93c2f34921..a7b54740d6359 100644 --- a/x-pack/legacy/plugins/code/server/lsp/lsp_service.ts +++ b/x-pack/legacy/plugins/code/server/lsp/lsp_service.ts @@ -79,15 +79,21 @@ export class LspService { } public supportLanguage(lang: string) { - return this.controller.getLanguageServerDef(lang) !== null; + return this.controller.getLanguageServerDef(lang).length > 0; } public getLanguageSeverDef(lang: string) { return this.controller.getLanguageServerDef(lang); } - public languageServerStatus(lang: string): LanguageServerStatus { - return this.controller.status(lang); + public languageServerStatus(name: string): LanguageServerStatus { + const defs = this.controller.getLanguageServerDef(name); + if (defs.length > 0) { + const def = defs[0]; + return this.controller.status(def); + } else { + return LanguageServerStatus.NOT_INSTALLED; + } } public async initializeState(repoUri: string, revision: string) { diff --git a/x-pack/legacy/plugins/code/server/plugin.ts b/x-pack/legacy/plugins/code/server/plugin.ts new file mode 100644 index 0000000000000..408afa1eddee9 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/plugin.ts @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import crypto from 'crypto'; +import * as _ from 'lodash'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; + +import { XPackMainPlugin } from '../../xpack_main/xpack_main'; +import { GitOperations } from './git_operations'; +import { RepositoryIndexInitializerFactory, tryMigrateIndices } from './indexer'; +import { Esqueue } from './lib/esqueue'; +import { Logger } from './log'; +import { JAVA } from './lsp/language_servers'; +import { LspService } from './lsp/lsp_service'; +import { RepositoryConfigController } from './repository_config_controller'; +import { IndexScheduler, UpdateScheduler } from './scheduler'; +import { CodeServerRouter } from './security'; +import { ServerOptions } from './server_options'; +import { + checkCodeNode, + checkRoute, + documentSearchRoute, + fileRoute, + installRoute, + lspRoute, + repositoryRoute, + repositorySearchRoute, + setupRoute, + statusRoute, + symbolByQnameRoute, + symbolSearchRoute, + workspaceRoute, +} from './routes'; +import { CodeServices } from './distributed/code_services'; +import { CodeNodeAdapter } from './distributed/multinode/code_node_adapter'; +import { LocalHandlerAdapter } from './distributed/local_handler_adapter'; +import { NonCodeNodeAdapter } from './distributed/multinode/non_code_node_adapter'; +import { + GitServiceDefinition, + GitServiceDefinitionOption, + LspServiceDefinition, + LspServiceDefinitionOption, + RepositoryServiceDefinition, + SetupDefinition, + WorkspaceDefinition, +} from './distributed/apis'; +import { initEs } from './init_es'; +import { initLocalService } from './init_local'; +import { initQueue } from './init_queue'; +import { initWorkers } from './init_workers'; + +export class CodePlugin { + private isCodeNode = false; + + private gitOps: GitOperations | null = null; + private queue: Esqueue | null = null; + private log: Logger; + private serverOptions: ServerOptions; + private indexScheduler: IndexScheduler | null = null; + private updateScheduler: UpdateScheduler | null = null; + private lspService: LspService | null = null; + + constructor(initializerContext: PluginInitializerContext) { + this.log = {} as Logger; + this.serverOptions = {} as ServerOptions; + } + + // TODO: options is not a valid param for the setup() api + // of the new platform. Will need to pass through the configs + // correctly in the new platform. + public setup(core: CoreSetup, options: any) { + const { server } = core.http as any; + + this.log = new Logger(server); + this.serverOptions = new ServerOptions(options, server.config()); + + const xpackMainPlugin: XPackMainPlugin = server.plugins.xpack_main; + xpackMainPlugin.registerFeature({ + id: 'code', + name: i18n.translate('xpack.code.featureRegistry.codeFeatureName', { + defaultMessage: 'Code', + }), + icon: 'codeApp', + navLinkId: 'code', + app: ['code', 'kibana'], + catalogue: [], // TODO add catalogue here + privileges: { + all: { + api: ['code_user', 'code_admin'], + savedObject: { + all: [], + read: ['config'], + }, + ui: ['show', 'user', 'admin'], + }, + read: { + api: ['code_user'], + savedObject: { + all: [], + read: ['config'], + }, + ui: ['show', 'user'], + }, + }, + }); + } + + // TODO: CodeStart will not have the register route api. + // Let's make it CoreSetup as the param for now. + public async start(core: CoreSetup) { + // called after all plugins are set up + const { server } = core.http as any; + const codeServerRouter = new CodeServerRouter(server); + const codeNodeUrl = this.serverOptions.codeNodeUrl; + const rndString = crypto.randomBytes(20).toString('hex'); + checkRoute(server, rndString); + if (codeNodeUrl) { + const checkResult = await this.retryUntilAvailable( + async () => await checkCodeNode(codeNodeUrl, this.log, rndString), + 5000 + ); + if (checkResult.me) { + const codeServices = new CodeServices(new CodeNodeAdapter(codeServerRouter, this.log)); + this.log.info('Initializing Code plugin as code-node.'); + await this.initCodeNode(server, codeServices); + } else { + await this.initNonCodeNode(codeNodeUrl, core); + } + } else { + const codeServices = new CodeServices(new LocalHandlerAdapter()); + // codeNodeUrl not set, single node mode + this.log.info('Initializing Code plugin as single-node.'); + this.initDevMode(server); + await this.initCodeNode(server, codeServices); + } + } + + private async initCodeNode(server: any, codeServices: CodeServices) { + this.isCodeNode = true; + const { esClient, repoConfigController, repoIndexInitializerFactory } = await initEs( + server, + this.log + ); + + this.queue = initQueue(server, this.log, esClient); + + const { gitOps, lspService } = initLocalService( + server, + this.log, + this.serverOptions, + codeServices, + esClient, + repoConfigController + ); + this.lspService = lspService; + this.gitOps = gitOps; + const { indexScheduler, updateScheduler } = initWorkers( + server, + this.log, + esClient, + this.queue!, + lspService, + gitOps, + this.serverOptions, + codeServices + ); + this.indexScheduler = indexScheduler; + this.updateScheduler = updateScheduler; + + // Execute index version checking and try to migrate index data if necessary. + await tryMigrateIndices(esClient, this.log); + + this.initRoutes(server, codeServices, repoIndexInitializerFactory, repoConfigController); + } + + public async stop() { + if (this.isCodeNode) { + if (this.gitOps) await this.gitOps.cleanAllRepo(); + if (this.indexScheduler) this.indexScheduler.stop(); + if (this.updateScheduler) this.updateScheduler.stop(); + if (this.queue) this.queue.destroy(); + if (this.lspService) await this.lspService.shutdown(); + } + } + + private async initNonCodeNode(url: string, core: CoreSetup) { + const { server } = core.http as any; + this.log.info( + `Initializing Code plugin as non-code node, redirecting all code requests to ${url}` + ); + const codeServices = new CodeServices(new NonCodeNodeAdapter(url, this.log)); + codeServices.registerHandler(GitServiceDefinition, null, GitServiceDefinitionOption); + codeServices.registerHandler(RepositoryServiceDefinition, null); + codeServices.registerHandler(LspServiceDefinition, null, LspServiceDefinitionOption); + codeServices.registerHandler(WorkspaceDefinition, null); + codeServices.registerHandler(SetupDefinition, null); + const { repoConfigController, repoIndexInitializerFactory } = await initEs(server, this.log); + this.initRoutes(server, codeServices, repoIndexInitializerFactory, repoConfigController); + } + + private async initRoutes( + server: any, + codeServices: CodeServices, + repoIndexInitializerFactory: RepositoryIndexInitializerFactory, + repoConfigController: RepositoryConfigController + ) { + const codeServerRouter = new CodeServerRouter(server); + repositoryRoute( + codeServerRouter, + codeServices, + repoIndexInitializerFactory, + repoConfigController, + this.serverOptions + ); + repositorySearchRoute(codeServerRouter, this.log); + documentSearchRoute(codeServerRouter, this.log); + symbolSearchRoute(codeServerRouter, this.log); + fileRoute(codeServerRouter, codeServices); + workspaceRoute(codeServerRouter, this.serverOptions, codeServices); + symbolByQnameRoute(codeServerRouter, this.log); + installRoute(codeServerRouter, codeServices); + lspRoute(codeServerRouter, codeServices, this.serverOptions); + setupRoute(codeServerRouter, codeServices); + statusRoute(codeServerRouter, codeServices); + } + + private async retryUntilAvailable( + func: () => Promise, + intervalMs: number, + retries: number = Number.MAX_VALUE + ): Promise { + const value = await func(); + if (value) { + return value; + } else { + const promise = new Promise(resolve => { + const retry = () => { + func().then(v => { + if (v) { + resolve(v); + } else { + retries--; + if (retries > 0) { + setTimeout(retry, intervalMs); + } else { + resolve(v); + } + } + }); + }; + setTimeout(retry, intervalMs); + }); + return await promise; + } + } + + private initDevMode(server: any) { + // @ts-ignore + const devMode: boolean = server.config().get('env.dev'); + server.injectUiAppVars('code', () => ({ + enableLangserversDeveloping: devMode, + })); + // Enable the developing language servers in development mode. + if (devMode) { + JAVA.downloadUrl = _.partialRight(JAVA!.downloadUrl!, devMode); + } + } +} diff --git a/x-pack/legacy/plugins/code/server/queue/abstract_git_worker.ts b/x-pack/legacy/plugins/code/server/queue/abstract_git_worker.ts index 0d26254792f16..d5282008ad5fa 100644 --- a/x-pack/legacy/plugins/code/server/queue/abstract_git_worker.ts +++ b/x-pack/legacy/plugins/code/server/queue/abstract_git_worker.ts @@ -4,12 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + import { CloneProgress, CloneWorkerProgress, CloneWorkerResult, WorkerReservedProgress, + WorkerResult, } from '../../model'; +import { DiskWatermarkService } from '../disk_watermark'; import { GitOperations } from '../git_operations'; import { EsClient, Esqueue } from '../lib/esqueue'; import { Logger } from '../log'; @@ -27,12 +31,34 @@ export abstract class AbstractGitWorker extends AbstractWorker { protected readonly log: Logger, protected readonly client: EsClient, protected readonly serverOptions: ServerOptions, - protected readonly gitOps: GitOperations + protected readonly gitOps: GitOperations, + protected readonly watermarkService: DiskWatermarkService ) { super(queue, log); this.objectClient = new RepositoryObjectClient(client); } + public async executeJob(_: Job): Promise { + const { thresholdEnabled, watermarkLowMb } = this.serverOptions.disk; + if (thresholdEnabled) { + const isLowWatermark = await this.watermarkService.isLowWatermark(); + if (isLowWatermark) { + const msg = i18n.translate('xpack.code.git.diskWatermarkLowMessage', { + defaultMessage: `Disk watermark level lower than {watermarkLowMb} MB`, + values: { + watermarkLowMb, + }, + }); + this.log.error(msg); + throw new Error(msg); + } + } + + return new Promise((resolve, reject) => { + resolve(); + }); + } + public async onJobCompleted(job: Job, res: CloneWorkerResult) { if (res.cancelled) { // Skip updating job progress if the job is done because of cancellation. diff --git a/x-pack/legacy/plugins/code/server/queue/clone_worker.ts b/x-pack/legacy/plugins/code/server/queue/clone_worker.ts index d95b3c96f2a94..4761a2bb20b29 100644 --- a/x-pack/legacy/plugins/code/server/queue/clone_worker.ts +++ b/x-pack/legacy/plugins/code/server/queue/clone_worker.ts @@ -14,6 +14,7 @@ import { CloneWorkerResult, WorkerReservedProgress, } from '../../model'; +import { DiskWatermarkService } from '../disk_watermark'; import { GitOperations } from '../git_operations'; import { EsClient, Esqueue } from '../lib/esqueue'; import { Logger } from '../log'; @@ -35,12 +36,15 @@ export class CloneWorker extends AbstractGitWorker { protected readonly gitOps: GitOperations, private readonly indexWorker: IndexWorker, private readonly repoServiceFactory: RepositoryServiceFactory, - private readonly cancellationService: CancellationSerivce + private readonly cancellationService: CancellationSerivce, + protected readonly watermarkService: DiskWatermarkService ) { - super(queue, log, client, serverOptions, gitOps); + super(queue, log, client, serverOptions, gitOps, watermarkService); } public async executeJob(job: Job) { + await super.executeJob(job); + const { payload, cancellationToken } = job; const { url } = payload; try { diff --git a/x-pack/legacy/plugins/code/server/queue/update_worker.test.ts b/x-pack/legacy/plugins/code/server/queue/update_worker.test.ts index 82c6e640da8d8..57b936dc827b4 100644 --- a/x-pack/legacy/plugins/code/server/queue/update_worker.test.ts +++ b/x-pack/legacy/plugins/code/server/queue/update_worker.test.ts @@ -8,6 +8,7 @@ import sinon from 'sinon'; import { EsClient, Esqueue } from '../lib/esqueue'; import { Repository } from '../../model'; +import { DiskWatermarkService } from '../disk_watermark'; import { GitOperations } from '../git_operations'; import { Logger } from '../log'; import { RepositoryServiceFactory } from '../repository_service_factory'; @@ -51,6 +52,12 @@ test('Execute update job', async () => { cancellationService.cancelUpdateJob = cancelUpdateJobSpy; cancellationService.registerCancelableUpdateJob = registerCancelableUpdateJobSpy; + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(false); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + const updateWorker = new UpdateWorker( esQueue as Esqueue, log, @@ -59,10 +66,15 @@ test('Execute update job', async () => { security: { enableGitCertCheck: true, }, + disk: { + thresholdEnabled: true, + watermarkLowMb: 100, + }, } as ServerOptions, {} as GitOperations, (repoServiceFactory as any) as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService ); await updateWorker.executeJob({ @@ -73,6 +85,7 @@ test('Execute update job', async () => { timestamp: 0, }); + expect(isLowWatermarkSpy.calledOnce).toBeTruthy(); expect(newInstanceSpy.calledOnce).toBeTruthy(); expect(updateSpy.calledOnce).toBeTruthy(); }); @@ -99,10 +112,15 @@ test('On update job completed because of cancellation ', async () => { security: { enableGitCertCheck: true, }, + disk: { + thresholdEnabled: true, + watermarkLowMb: 100, + }, } as ServerOptions, {} as GitOperations, {} as RepositoryServiceFactory, - cancellationService as CancellationSerivce + cancellationService as CancellationSerivce, + {} as DiskWatermarkService ); await updateWorker.onJobCompleted( @@ -127,3 +145,71 @@ test('On update job completed because of cancellation ', async () => { // cancellation. expect(updateSpy.notCalled).toBeTruthy(); }); + +test('Execute update job failed because of low disk watermark ', async () => { + // Setup RepositoryService + const updateSpy = sinon.spy(); + const repoService = { + update: emptyAsyncFunc, + }; + repoService.update = updateSpy; + const repoServiceFactory = { + newInstance: (): void => { + return; + }, + }; + const newInstanceSpy = sinon.fake.returns(repoService); + repoServiceFactory.newInstance = newInstanceSpy; + + // Setup CancellationService + const cancelUpdateJobSpy = sinon.spy(); + const registerCancelableUpdateJobSpy = sinon.spy(); + const cancellationService: any = { + cancelUpdateJob: emptyAsyncFunc, + registerCancelableUpdateJob: emptyAsyncFunc, + }; + cancellationService.cancelUpdateJob = cancelUpdateJobSpy; + cancellationService.registerCancelableUpdateJob = registerCancelableUpdateJobSpy; + + // Setup DiskWatermarkService + const isLowWatermarkSpy = sinon.stub().resolves(true); + const diskWatermarkService: any = { + isLowWatermark: isLowWatermarkSpy, + }; + + const updateWorker = new UpdateWorker( + esQueue as Esqueue, + log, + esClient as EsClient, + { + security: { + enableGitCertCheck: true, + }, + disk: { + thresholdEnabled: true, + watermarkLowMb: 100, + }, + } as ServerOptions, + {} as GitOperations, + {} as RepositoryServiceFactory, + cancellationService as CancellationSerivce, + diskWatermarkService as DiskWatermarkService + ); + + try { + await updateWorker.executeJob({ + payload: { + uri: 'mockrepo', + }, + options: {}, + timestamp: 0, + }); + // This step should not be touched. + expect(false).toBeTruthy(); + } catch (error) { + // Exception should be thrown. + expect(isLowWatermarkSpy.calledOnce).toBeTruthy(); + expect(newInstanceSpy.notCalled).toBeTruthy(); + expect(updateSpy.notCalled).toBeTruthy(); + } +}); diff --git a/x-pack/legacy/plugins/code/server/queue/update_worker.ts b/x-pack/legacy/plugins/code/server/queue/update_worker.ts index 716781fcb5e9b..ee709f1116728 100644 --- a/x-pack/legacy/plugins/code/server/queue/update_worker.ts +++ b/x-pack/legacy/plugins/code/server/queue/update_worker.ts @@ -6,6 +6,7 @@ import { CloneWorkerResult, Repository } from '../../model'; import { EsClient, Esqueue } from '../lib/esqueue'; +import { DiskWatermarkService } from '../disk_watermark'; import { GitOperations } from '../git_operations'; import { Logger } from '../log'; import { RepositoryServiceFactory } from '../repository_service_factory'; @@ -18,18 +19,21 @@ export class UpdateWorker extends AbstractGitWorker { public id: string = 'update'; constructor( - queue: Esqueue, + protected readonly queue: Esqueue, protected readonly log: Logger, protected readonly client: EsClient, protected readonly serverOptions: ServerOptions, protected readonly gitOps: GitOperations, protected readonly repoServiceFactory: RepositoryServiceFactory, - private readonly cancellationService: CancellationSerivce + private readonly cancellationService: CancellationSerivce, + protected readonly watermarkService: DiskWatermarkService ) { - super(queue, log, client, serverOptions, gitOps); + super(queue, log, client, serverOptions, gitOps, watermarkService); } public async executeJob(job: Job) { + await super.executeJob(job); + const { payload, cancellationToken } = job; const repo: Repository = payload; this.log.info(`Execute update job for ${repo.uri}`); diff --git a/x-pack/legacy/plugins/code/server/routes/check.ts b/x-pack/legacy/plugins/code/server/routes/check.ts index 0fcce31f22393..ad89d6281b4ff 100644 --- a/x-pack/legacy/plugins/code/server/routes/check.ts +++ b/x-pack/legacy/plugins/code/server/routes/check.ts @@ -4,21 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; import fetch from 'node-fetch'; + import { Logger } from '../log'; +import { ServerFacade } from '../..'; export async function checkCodeNode(url: string, log: Logger, rndStr: string) { - const res = await fetch(`${url}/api/code/codeNode?rndStr=${rndStr}`, {}); - if (res.ok) { - return await res.json(); + try { + const res = await fetch(`${url}/api/code/codeNode?rndStr=${rndStr}`, {}); + if (res.ok) { + return await res.json(); + } + } catch (e) { + // request failed + log.error(e); } log.info(`Access code node ${url} failed, try again later.`); return null; } -export function checkRoute(server: Server, rndStr: string) { +export function checkRoute(server: ServerFacade, rndStr: string) { server.route({ method: 'GET', path: '/api/code/codeNode', diff --git a/x-pack/legacy/plugins/code/server/routes/file.ts b/x-pack/legacy/plugins/code/server/routes/file.ts index 981bb71c3541e..15f50e6aa9d65 100644 --- a/x-pack/legacy/plugins/code/server/routes/file.ts +++ b/x-pack/legacy/plugins/code/server/routes/file.ts @@ -4,22 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Commit, Oid, Revwalk } from '@elastic/nodegit'; import Boom from 'boom'; -import fileType from 'file-type'; -import hapi, { RequestQuery } from 'hapi'; -import { commitInfo, DEFAULT_TREE_CHILDREN_LIMIT, GitOperations } from '../git_operations'; -import { extractLines } from '../utils/buffer'; -import { detectLanguage } from '../utils/detect_language'; + +import { RequestFacade, RequestQueryFacade, ResponseToolkitFacade } from '../../'; +import { DEFAULT_TREE_CHILDREN_LIMIT } from '../git_operations'; import { CodeServerRouter } from '../security'; import { RepositoryObjectClient } from '../search'; import { EsClientWithRequest } from '../utils/esclient_with_request'; -import { TEXT_FILE_LIMIT } from '../../common/file'; import { decodeRevisionString } from '../../common/uri_util'; +import { CodeServices } from '../distributed/code_services'; +import { GitServiceDefinition } from '../distributed/apis'; + +export function fileRoute(router: CodeServerRouter, codeServices: CodeServices) { + const gitService = codeServices.serviceFor(GitServiceDefinition); -export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { async function getRepoUriFromMeta( - req: hapi.Request, + req: RequestFacade, repoUri: string ): Promise { const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); @@ -32,13 +32,13 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { } } - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/tree/{ref}/{path*}', method: 'GET', - async handler(req: hapi.Request) { + async handler(req: RequestFacade) { const { uri, path, ref } = req.params; const revision = decodeRevisionString(ref); - const queries = req.query as RequestQuery; + const queries = req.query as RequestQueryFacade; const limit = queries.limit ? parseInt(queries.limit as string, 10) : DEFAULT_TREE_CHILDREN_LIMIT; @@ -49,9 +49,17 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } - + const endpoint = await codeServices.locate(req, uri); try { - return await gitOps.fileTree(repoUri, path, revision, skip, limit, withParents, flatten); + return await gitService.fileTree(endpoint, { + uri: repoUri, + path, + revision, + skip, + limit, + withParents, + flatten, + }); } catch (e) { if (e.isBoom) { return e; @@ -62,52 +70,40 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { }, }); - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/blob/{ref}/{path*}', method: 'GET', - async handler(req: hapi.Request, h: hapi.ResponseToolkit) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { const { uri, path, ref } = req.params; const revision = decodeRevisionString(ref); const repoUri = await getRepoUriFromMeta(req, uri); if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } + const endpoint = await codeServices.locate(req, uri); try { - const blob = await gitOps.fileContent(repoUri, path, decodeURIComponent(revision)); - if (blob.isBinary()) { - const type = fileType(blob.content()); - if (type && type.mime && type.mime.startsWith('image/')) { - const response = h.response(blob.content()); - response.type(type.mime); - return response; - } else { - // this api will return a empty response with http code 204 - return h - .response('') - .type('application/octet-stream') - .code(204); - } + const blob = await gitService.blob(endpoint, { + uri, + path, + line: (req.query as RequestQueryFacade).line as string, + revision: decodeURIComponent(revision), + }); + + if (blob.imageType) { + const response = h.response(blob.content); + response.type(blob.imageType); + return response; + } else if (blob.isBinary) { + return h + .response('') + .type('application/octet-stream') + .code(204); } else { - const line = (req.query as RequestQuery).line as string; - if (line) { - const [from, to] = line.split(','); - let fromLine = parseInt(from, 10); - let toLine = to === undefined ? fromLine + 1 : parseInt(to, 10); - if (fromLine > toLine) { - [fromLine, toLine] = [toLine, fromLine]; - } - const lines = extractLines(blob.content(), fromLine, toLine); - const lang = await detectLanguage(path, lines); + if (blob.content) { return h - .response(lines) - .type(`text/plain`) - .header('lang', lang); - } else if (blob.content().length <= TEXT_FILE_LIMIT) { - const lang = await detectLanguage(path, blob.content()); - return h - .response(blob.content()) - .type(`text/plain'`) - .header('lang', lang); + .response(blob.content) + .type('text/plain') + .header('lang', blob.lang!); } else { return h.response('').type(`text/big`); } @@ -122,22 +118,24 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { }, }); - server.route({ + router.route({ path: '/app/code/repo/{uri*3}/raw/{ref}/{path*}', method: 'GET', - async handler(req, h: hapi.ResponseToolkit) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { const { uri, path, ref } = req.params; const revision = decodeRevisionString(ref); const repoUri = await getRepoUriFromMeta(req, uri); if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } + const endpoint = await codeServices.locate(req, uri); + try { - const blob = await gitOps.fileContent(repoUri, path, revision); - if (blob.isBinary()) { - return h.response(blob.content()).type('application/octet-stream'); + const blob = await gitService.raw(endpoint, { uri: repoUri, path, revision }); + if (blob.isBinary) { + return h.response(blob.content).type('application/octet-stream'); } else { - return h.response(blob.content()).type('text/plain'); + return h.response(blob.content).type('text/plain'); } } catch (e) { if (e.isBoom) { @@ -149,22 +147,22 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { }, }); - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/history/{ref}', method: 'GET', handler: historyHandler, }); - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/history/{ref}/{path*}', method: 'GET', handler: historyHandler, }); - async function historyHandler(req: hapi.Request) { + async function historyHandler(req: RequestFacade) { const { uri, ref, path } = req.params; const revision = decodeRevisionString(ref); - const queries = req.query as RequestQuery; + const queries = req.query as RequestQueryFacade; const count = queries.count ? parseInt(queries.count as string, 10) : 10; const after = queries.after !== undefined; try { @@ -172,29 +170,8 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } - const repository = await gitOps.openRepo(repoUri); - const commit = await gitOps.getCommitInfo(repoUri, revision); - if (commit === null) { - throw Boom.notFound(`commit ${revision} not found in repo ${uri}`); - } - const walk = repository.createRevWalk(); - walk.sorting(Revwalk.SORT.TIME); - const commitId = Oid.fromString(commit!.id); - walk.push(commitId); - let commits: Commit[]; - if (path) { - // magic number 10000: how many commits at the most to iterate in order to find the commits contains the path - const results = await walk.fileHistoryWalk(path, count, 10000); - commits = results.map(result => result.commit); - } else { - commits = await walk.getCommits(count); - } - if (after && commits.length > 0) { - if (commits[0].id().equal(commitId)) { - commits = commits.slice(1); - } - } - return commits.map(commitInfo); + const endpoint = await codeServices.locate(req, uri); + return await gitService.history(endpoint, { uri: repoUri, path, revision, count, after }); } catch (e) { if (e.isBoom) { return e; @@ -203,17 +180,20 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { } } } - server.route({ + + router.route({ path: '/api/code/repo/{uri*3}/references', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const uri = req.params.uri; const repoUri = await getRepoUriFromMeta(req, uri); if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } + const endpoint = await codeServices.locate(req, uri); + try { - return await gitOps.getBranchAndTags(repoUri); + return await gitService.branchesAndTags(endpoint, { uri: repoUri }); } catch (e) { if (e.isBoom) { return e; @@ -224,18 +204,21 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { }, }); - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/diff/{revision}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const { uri, revision } = req.params; const repoUri = await getRepoUriFromMeta(req, uri); if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } + const endpoint = await codeServices.locate(req, uri); try { - const diff = await gitOps.getCommitDiff(repoUri, decodeRevisionString(revision)); - return diff; + return await gitService.commitDiff(endpoint, { + uri: repoUri, + revision: decodeRevisionString(revision), + }); } catch (e) { if (e.isBoom) { return e; @@ -246,22 +229,23 @@ export function fileRoute(server: CodeServerRouter, gitOps: GitOperations) { }, }); - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/blame/{revision}/{path*}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const { uri, path, revision } = req.params; const repoUri = await getRepoUriFromMeta(req, uri); if (!repoUri) { return Boom.notFound(`repo ${uri} not found`); } + const endpoint = await codeServices.locate(req, uri); + try { - const blames = await gitOps.blame( - repoUri, - decodeRevisionString(decodeURIComponent(revision)), - path - ); - return blames; + return await gitService.blame(endpoint, { + uri: repoUri, + revision: decodeRevisionString(decodeURIComponent(revision)), + path, + }); } catch (e) { if (e.isBoom) { return e; diff --git a/x-pack/legacy/plugins/code/server/routes/index.ts b/x-pack/legacy/plugins/code/server/routes/index.ts new file mode 100644 index 0000000000000..27f40de552a3e --- /dev/null +++ b/x-pack/legacy/plugins/code/server/routes/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './check'; +export * from './file'; +export * from './install'; +export * from './lsp'; +export * from './redirect'; +export * from './repository'; +export * from './search'; +export * from './setup'; +export * from './status'; +export * from './workspace'; diff --git a/x-pack/legacy/plugins/code/server/routes/install.ts b/x-pack/legacy/plugins/code/server/routes/install.ts index 78a7ce9b10628..fb1ea08849b59 100644 --- a/x-pack/legacy/plugins/code/server/routes/install.ts +++ b/x-pack/legacy/plugins/code/server/routes/install.ts @@ -5,16 +5,20 @@ */ import * as Boom from 'boom'; -import { Request } from 'hapi'; + +import { RequestFacade } from '../..'; import { enabledLanguageServers, LanguageServerDefinition } from '../lsp/language_servers'; -import { LspService } from '../lsp/lsp_service'; import { CodeServerRouter } from '../security'; +import { CodeServices } from '../distributed/code_services'; +import { LspServiceDefinition } from '../distributed/apis'; +import { Endpoint } from '../distributed/resource_locator'; -export function installRoute(server: CodeServerRouter, lspService: LspService) { - const kibanaVersion = server.server.config().get('pkg.version') as string; - const status = (def: LanguageServerDefinition) => ({ +export function installRoute(router: CodeServerRouter, codeServices: CodeServices) { + const lspService = codeServices.serviceFor(LspServiceDefinition); + const kibanaVersion = router.server.config().get('pkg.version') as string; + const status = (endpoint: Endpoint, def: LanguageServerDefinition) => ({ name: def.name, - status: lspService.languageServerStatus(def.name), + status: lspService.languageServerStatus(endpoint, { langName: def.name }), version: def.version, build: def.build, languages: def.languages, @@ -24,21 +28,23 @@ export function installRoute(server: CodeServerRouter, lspService: LspService) { pluginName: def.installationPluginName, }); - server.route({ + router.route({ path: '/api/code/install', - handler() { - return enabledLanguageServers(server.server).map(status); + async handler(req: RequestFacade) { + const endpoint = await codeServices.locate(req, ''); + return enabledLanguageServers(router.server).map(def => status(endpoint, def)); }, method: 'GET', }); - server.route({ + router.route({ path: '/api/code/install/{name}', - handler(req: Request) { + async handler(req: RequestFacade) { const name = req.params.name; - const def = enabledLanguageServers(server.server).find(d => d.name === name); + const def = enabledLanguageServers(router.server).find(d => d.name === name); + const endpoint = await codeServices.locate(req, ''); if (def) { - return status(def); + return status(endpoint, def); } else { return Boom.notFound(`language server ${name} not found.`); } diff --git a/x-pack/legacy/plugins/code/server/routes/lsp.ts b/x-pack/legacy/plugins/code/server/routes/lsp.ts index 965b0b9c69c63..edeefe8f9808d 100644 --- a/x-pack/legacy/plugins/code/server/routes/lsp.ts +++ b/x-pack/legacy/plugins/code/server/routes/lsp.ts @@ -5,21 +5,19 @@ */ import Boom from 'boom'; -import hapi from 'hapi'; import { groupBy, last } from 'lodash'; import { ResponseError } from 'vscode-jsonrpc'; import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; import { Location } from 'vscode-languageserver-types'; + import { LanguageServerStartFailed, ServerNotInitialized, UnknownFileLanguage, } from '../../common/lsp_error_codes'; import { parseLspUrl } from '../../common/uri_util'; -import { GitOperations } from '../git_operations'; import { Logger } from '../log'; import { CTAGS, GO } from '../lsp/language_servers'; -import { LspService } from '../lsp/lsp_service'; import { SymbolSearchClient } from '../search'; import { CodeServerRouter } from '../security'; import { ServerOptions } from '../server_options'; @@ -32,28 +30,37 @@ import { import { detectLanguage } from '../utils/detect_language'; import { EsClientWithRequest } from '../utils/esclient_with_request'; import { promiseTimeout } from '../utils/timeout'; +import { RequestFacade, ResponseToolkitFacade } from '../..'; +import { CodeServices } from '../distributed/code_services'; +import { GitServiceDefinition, LspServiceDefinition } from '../distributed/apis'; const LANG_SERVER_ERROR = 'language server error'; export function lspRoute( server: CodeServerRouter, - lspService: LspService, + codeServices: CodeServices, serverOptions: ServerOptions ) { const log = new Logger(server.server); - + const lspService = codeServices.serviceFor(LspServiceDefinition); + const gitService = codeServices.serviceFor(GitServiceDefinition); server.route({ path: '/api/code/lsp/textDocument/{method}', - async handler(req, h: hapi.ResponseToolkit) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { if (typeof req.payload === 'object' && req.payload != null) { const method = req.params.method; if (method) { try { - const result = await promiseTimeout( - serverOptions.lsp.requestTimeoutMs, - lspService.sendRequest(`textDocument/${method}`, req.payload, 1000) - ); - return result; + const params = (req.payload as unknown) as any; + const uri = params.textDocument.uri; + const { repoUri } = parseLspUrl(uri)!; + const endpoint = await codeServices.locate(req, repoUri); + const requestPromise = lspService.sendRequest(endpoint, { + method: `textDocument/${method}`, + params: req.payload, + timeoutForInitializeMs: 1000, + }); + return await promiseTimeout(serverOptions.lsp.requestTimeoutMs, requestPromise); } catch (error) { if (error instanceof ResponseError) { // hide some errors; @@ -91,22 +98,26 @@ export function lspRoute( server.route({ path: '/api/code/lsp/findReferences', method: 'POST', - async handler(req, h: hapi.ResponseToolkit) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { try { // @ts-ignore const { textDocument, position } = req.payload; const { uri } = textDocument; + const endpoint = await codeServices.locate(req, parseLspUrl(uri).repoUri); const response: ResponseMessage = await promiseTimeout( serverOptions.lsp.requestTimeoutMs, - lspService.sendRequest( - `textDocument/references`, - { textDocument: { uri }, position }, - 1000 - ) + lspService.sendRequest(endpoint, { + method: `textDocument/references`, + params: { textDocument: { uri }, position }, + timeoutForInitializeMs: 1000, + }) ); - const hover = await lspService.sendRequest('textDocument/hover', { - textDocument: { uri }, - position, + const hover = await lspService.sendRequest(endpoint, { + method: 'textDocument/hover', + params: { + textDocument: { uri }, + position, + }, }); let title: string; if (hover.result && hover.result.contents) { @@ -136,11 +147,11 @@ export function lspRoute( } else { title = last(uri.toString().split('/')) + `(${position.line}, ${position.character})`; } - const gitOperations = new GitOperations(serverOptions.repoPath); const files = []; const groupedLocations = groupBy(response.result as Location[], 'uri'); for (const url of Object.keys(groupedLocations)) { const { repoUri, revision, file } = parseLspUrl(url)!; + const ep = await codeServices.locate(req, repoUri); const locations: Location[] = groupedLocations[url]; const lines = locations.map(l => ({ startLine: l.range.start.line, @@ -148,36 +159,35 @@ export function lspRoute( })); const ranges = expandRanges(lines, 1); const mergedRanges = mergeRanges(ranges); - const blob = await gitOperations.fileContent(repoUri, file!, revision); - const source = blob - .content() - .toString('utf8') - .split('\n'); - const language = await detectLanguage(file!, blob.content()); - const lineMappings = new LineMapping(); - const code = extractSourceContent(mergedRanges, source, lineMappings).join('\n'); - const lineNumbers = lineMappings.toStringArray(); - const highlights = locations.map(l => { - const { start, end } = l.range; - const startLineNumber = lineMappings.lineNumber(start.line); - const endLineNumber = lineMappings.lineNumber(end.line); - return { - startLineNumber, - startColumn: start.character + 1, - endLineNumber, - endColumn: end.character + 1, - }; - }); - files.push({ - repo: repoUri, - file, - language, - uri: url, - revision, - code, - lineNumbers, - highlights, - }); + const blob = await gitService.blob(ep, { uri: repoUri, path: file!, revision }); + if (blob.content) { + const source = blob.content.split('\n'); + const language = blob.lang; + const lineMappings = new LineMapping(); + const code = extractSourceContent(mergedRanges, source, lineMappings).join('\n'); + const lineNumbers = lineMappings.toStringArray(); + const highlights = locations.map(l => { + const { start, end } = l.range; + const startLineNumber = lineMappings.lineNumber(start.line); + const endLineNumber = lineMappings.lineNumber(end.line); + return { + startLineNumber, + startColumn: start.character + 1, + endLineNumber, + endColumn: end.character + 1, + }; + }); + files.push({ + repo: repoUri, + file, + language, + uri: url, + revision, + code, + lineNumbers, + highlights, + }); + } } return { title, files: groupBy(files, 'repo'), uri, position }; } catch (error) { @@ -200,11 +210,11 @@ export function lspRoute( }); } -export function symbolByQnameRoute(server: CodeServerRouter, log: Logger) { - server.route({ +export function symbolByQnameRoute(router: CodeServerRouter, log: Logger) { + router.route({ path: '/api/code/lsp/symbol/{qname}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { try { const symbolSearchClient = new SymbolSearchClient(new EsClientWithRequest(req), log); const res = await symbolSearchClient.findByQname(req.params.qname); diff --git a/x-pack/legacy/plugins/code/server/routes/redirect.ts b/x-pack/legacy/plugins/code/server/routes/redirect.ts index 17084a98f738e..2882a37334836 100644 --- a/x-pack/legacy/plugins/code/server/routes/redirect.ts +++ b/x-pack/legacy/plugins/code/server/routes/redirect.ts @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import hapi from 'hapi'; +import { RequestFacade, ServerFacade } from '../../'; import { Logger } from '../log'; -export function redirectRoute(server: hapi.Server, redirectUrl: string, log: Logger) { +export function redirectRoute(server: ServerFacade, redirectUrl: string, log: Logger) { const proxyHandler = { proxy: { passThrough: true, - async mapUri(request: hapi.Request) { + async mapUri(request: RequestFacade) { let uri; uri = `${redirectUrl}${request.path}`; if (request.url.search) { diff --git a/x-pack/legacy/plugins/code/server/routes/repository.ts b/x-pack/legacy/plugins/code/server/routes/repository.ts index 22afc40de1c21..c89c5b3dc2f16 100644 --- a/x-pack/legacy/plugins/code/server/routes/repository.ts +++ b/x-pack/legacy/plugins/code/server/routes/repository.ts @@ -6,33 +6,34 @@ import Boom from 'boom'; +import { RequestFacade, ResponseToolkitFacade } from '../..'; import { validateGitUrl } from '../../common/git_url_utils'; import { RepositoryUtils } from '../../common/repository_utils'; import { RepositoryConfig, RepositoryUri } from '../../model'; import { RepositoryIndexInitializer, RepositoryIndexInitializerFactory } from '../indexer'; import { Logger } from '../log'; -import { CloneWorker, DeleteWorker, IndexWorker } from '../queue'; import { RepositoryConfigController } from '../repository_config_controller'; import { RepositoryObjectClient } from '../search'; import { ServerOptions } from '../server_options'; import { EsClientWithRequest } from '../utils/esclient_with_request'; import { CodeServerRouter } from '../security'; +import { CodeServices } from '../distributed/code_services'; +import { RepositoryServiceDefinition } from '../distributed/apis'; export function repositoryRoute( - server: CodeServerRouter, - cloneWorker: CloneWorker, - deleteWorker: DeleteWorker, - indexWorker: IndexWorker, + router: CodeServerRouter, + codeServices: CodeServices, repoIndexInitializerFactory: RepositoryIndexInitializerFactory, repoConfigController: RepositoryConfigController, options: ServerOptions ) { + const repositoryService = codeServices.serviceFor(RepositoryServiceDefinition); // Clone a git repository - server.route({ + router.route({ path: '/api/code/repo', requireAdmin: true, method: 'POST', - async handler(req, h) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { const repoUrl: string = (req.payload as any).url; const log = new Logger(req.server); @@ -78,7 +79,8 @@ export function repositoryRoute( const payload = { url: repoUrl, }; - await cloneWorker.enqueueJob(payload, {}); + const endpoint = await codeServices.locate(req, repoUrl); + await repositoryService.clone(endpoint, payload); return repo; } catch (error2) { const msg = `Issue repository clone request for ${repoUrl} error`; @@ -91,11 +93,11 @@ export function repositoryRoute( }); // Remove a git repository - server.route({ + router.route({ path: '/api/code/repo/{uri*3}', requireAdmin: true, method: 'DELETE', - async handler(req, h) { + async handler(req: RequestFacade, h: ResponseToolkitFacade) { const repoUri: string = req.params.uri as string; const log = new Logger(req.server); const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); @@ -118,8 +120,8 @@ export function repositoryRoute( const payload = { uri: repoUri, }; - await deleteWorker.enqueueJob(payload, {}); - + const endpoint = await codeServices.locate(req, repoUri); + await repositoryService.delete(endpoint, payload); return {}; } catch (error) { const msg = `Issue repository delete request for ${repoUri} error`; @@ -131,10 +133,10 @@ export function repositoryRoute( }); // Get a git repository - server.route({ + router.route({ path: '/api/code/repo/{uri*3}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const repoUri = req.params.uri as string; const log = new Logger(req.server); try { @@ -149,10 +151,10 @@ export function repositoryRoute( }, }); - server.route({ + router.route({ path: '/api/code/repo/status/{uri*3}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const repoUri = req.params.uri as string; const log = new Logger(req.server); try { @@ -192,10 +194,10 @@ export function repositoryRoute( }); // Get all git repositories - server.route({ + router.route({ path: '/api/code/repos', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const log = new Logger(req.server); try { const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); @@ -212,11 +214,11 @@ export function repositoryRoute( // Issue a repository index task. // TODO(mengwei): This is just temporary API stub to trigger the index job. Eventually in the near // future, this route will be removed. The scheduling strategy is still in discussion. - server.route({ + router.route({ path: '/api/code/repo/index/{uri*3}', method: 'POST', requireAdmin: true, - async handler(req) { + async handler(req: RequestFacade) { const repoUri = req.params.uri as string; const log = new Logger(req.server); const reindex: boolean = (req.payload as any).reindex; @@ -229,7 +231,8 @@ export function repositoryRoute( revision: cloneStatus.revision, enforceReindex: reindex, }; - await indexWorker.enqueueJob(payload, {}); + const endpoint = await codeServices.locate(req, repoUri); + await repositoryService.index(endpoint, payload); return {}; } catch (error) { const msg = `Index repository ${repoUri} error`; @@ -241,11 +244,11 @@ export function repositoryRoute( }); // Update a repo config - server.route({ + router.route({ path: '/api/code/repo/config/{uri*3}', method: 'PUT', requireAdmin: true, - async handler(req, h) { + async handler(req: RequestFacade) { const config: RepositoryConfig = req.payload as RepositoryConfig; const repoUri: RepositoryUri = config.uri; const log = new Logger(req.server); @@ -273,10 +276,10 @@ export function repositoryRoute( }); // Get repository config - server.route({ + router.route({ path: '/api/code/repo/config/{uri*3}', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { const repoUri = req.params.uri as string; try { const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); diff --git a/x-pack/legacy/plugins/code/server/routes/search.ts b/x-pack/legacy/plugins/code/server/routes/search.ts index 8b759f502ddf0..f2a0ba4db705a 100644 --- a/x-pack/legacy/plugins/code/server/routes/search.ts +++ b/x-pack/legacy/plugins/code/server/routes/search.ts @@ -6,20 +6,20 @@ import Boom from 'boom'; -import hapi from 'hapi'; +import { RequestFacade, RequestQueryFacade } from '../../'; import { DocumentSearchRequest, RepositorySearchRequest, SymbolSearchRequest } from '../../model'; import { Logger } from '../log'; import { DocumentSearchClient, RepositorySearchClient, SymbolSearchClient } from '../search'; import { EsClientWithRequest } from '../utils/esclient_with_request'; import { CodeServerRouter } from '../security'; -export function repositorySearchRoute(server: CodeServerRouter, log: Logger) { - server.route({ +export function repositorySearchRoute(router: CodeServerRouter, log: Logger) { + router.route({ path: '/api/code/search/repo', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { let page = 1; - const { p, q, repoScope } = req.query as hapi.RequestQuery; + const { p, q, repoScope } = req.query as RequestQueryFacade; if (p) { page = parseInt(p as string, 10); } @@ -44,12 +44,12 @@ export function repositorySearchRoute(server: CodeServerRouter, log: Logger) { }, }); - server.route({ + router.route({ path: '/api/code/suggestions/repo', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { let page = 1; - const { p, q, repoScope } = req.query as hapi.RequestQuery; + const { p, q, repoScope } = req.query as RequestQueryFacade; if (p) { page = parseInt(p as string, 10); } @@ -75,13 +75,13 @@ export function repositorySearchRoute(server: CodeServerRouter, log: Logger) { }); } -export function documentSearchRoute(server: CodeServerRouter, log: Logger) { - server.route({ +export function documentSearchRoute(router: CodeServerRouter, log: Logger) { + router.route({ path: '/api/code/search/doc', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { let page = 1; - const { p, q, langs, repos, repoScope } = req.query as hapi.RequestQuery; + const { p, q, langs, repos, repoScope } = req.query as RequestQueryFacade; if (p) { page = parseInt(p as string, 10); } @@ -108,12 +108,12 @@ export function documentSearchRoute(server: CodeServerRouter, log: Logger) { }, }); - server.route({ + router.route({ path: '/api/code/suggestions/doc', method: 'GET', - async handler(req) { + async handler(req: RequestFacade) { let page = 1; - const { p, q, repoScope } = req.query as hapi.RequestQuery; + const { p, q, repoScope } = req.query as RequestQueryFacade; if (p) { page = parseInt(p as string, 10); } @@ -139,10 +139,10 @@ export function documentSearchRoute(server: CodeServerRouter, log: Logger) { }); } -export function symbolSearchRoute(server: CodeServerRouter, log: Logger) { - const symbolSearchHandler = async (req: hapi.Request) => { +export function symbolSearchRoute(router: CodeServerRouter, log: Logger) { + const symbolSearchHandler = async (req: RequestFacade) => { let page = 1; - const { p, q, repoScope } = req.query as hapi.RequestQuery; + const { p, q, repoScope } = req.query as RequestQueryFacade; if (p) { page = parseInt(p as string, 10); } @@ -167,12 +167,12 @@ export function symbolSearchRoute(server: CodeServerRouter, log: Logger) { }; // Currently these 2 are the same. - server.route({ + router.route({ path: '/api/code/suggestions/symbol', method: 'GET', handler: symbolSearchHandler, }); - server.route({ + router.route({ path: '/api/code/search/symbol', method: 'GET', handler: symbolSearchHandler, diff --git a/x-pack/legacy/plugins/code/server/routes/setup.ts b/x-pack/legacy/plugins/code/server/routes/setup.ts index 0c75b8ec1d46a..58db84fd80aaf 100644 --- a/x-pack/legacy/plugins/code/server/routes/setup.ts +++ b/x-pack/legacy/plugins/code/server/routes/setup.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResponseToolkit } from 'hapi'; +import { RequestFacade } from '../..'; import { CodeServerRouter } from '../security'; +import { CodeServices } from '../distributed/code_services'; +import { SetupDefinition } from '../distributed/apis'; -export function setupRoute(server: CodeServerRouter) { - server.route({ +export function setupRoute(router: CodeServerRouter, codeServices: CodeServices) { + const setupService = codeServices.serviceFor(SetupDefinition); + router.route({ method: 'get', path: '/api/code/setup', - handler(req, h: ResponseToolkit) { - return h.response('').code(200); + async handler(req: RequestFacade) { + const endpoint = await codeServices.locate(req, ''); + return await setupService.setup(endpoint, {}); }, }); } diff --git a/x-pack/legacy/plugins/code/server/routes/status.ts b/x-pack/legacy/plugins/code/server/routes/status.ts index 6589b73ee56e2..7a4a062926cf2 100644 --- a/x-pack/legacy/plugins/code/server/routes/status.ts +++ b/x-pack/legacy/plugins/code/server/routes/status.ts @@ -3,35 +3,37 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import hapi from 'hapi'; + import Boom from 'boom'; -import { LspService } from '../lsp/lsp_service'; -import { GitOperations } from '../git_operations'; import { CodeServerRouter } from '../security'; +import { RequestFacade } from '../../'; import { LangServerType, RepoFileStatus, StatusReport } from '../../common/repo_file_status'; import { CTAGS, LanguageServerDefinition } from '../lsp/language_servers'; import { LanguageServerStatus } from '../../common/language_server'; import { WorkspaceStatus } from '../lsp/request_expander'; import { RepositoryObjectClient } from '../search'; import { EsClientWithRequest } from '../utils/esclient_with_request'; -import { TEXT_FILE_LIMIT } from '../../common/file'; -import { detectLanguage } from '../utils/detect_language'; +import { CodeServices } from '../distributed/code_services'; +import { GitServiceDefinition, LspServiceDefinition } from '../distributed/apis'; +import { Endpoint } from '../distributed/resource_locator'; -export function statusRoute( - server: CodeServerRouter, - gitOps: GitOperations, - lspService: LspService -) { +export function statusRoute(router: CodeServerRouter, codeServices: CodeServices) { + const gitService = codeServices.serviceFor(GitServiceDefinition); + const lspService = codeServices.serviceFor(LspServiceDefinition); async function handleRepoStatus( + endpoint: Endpoint, report: StatusReport, repoUri: string, revision: string, repoObjectClient: RepositoryObjectClient ) { - const commit = await gitOps.getCommit(repoUri, decodeURIComponent(revision)); - const head = await gitOps.getHeadRevision(repoUri); - if (head === commit.sha()) { + const commit = await gitService.commit(endpoint, { + uri: repoUri, + revision: decodeURIComponent(revision), + }); + const head = await gitService.headRevision(endpoint, { uri: repoUri }); + if (head === commit.id) { try { const indexStatus = await repoObjectClient.getRepositoryLspIndexStatus(repoUri); if (indexStatus.progress < 100) { @@ -46,62 +48,84 @@ export function statusRoute( } } - async function handleFileStatus(report: StatusReport, content: Buffer, path: string) { - if (content.length <= TEXT_FILE_LIMIT) { - const lang: string = await detectLanguage(path, content); - const def = lspService.getLanguageSeverDef(lang); - if (def === null) { + async function handleFileStatus( + endpoint: Endpoint, + report: StatusReport, + blob: { isBinary: boolean; imageType?: string; content?: string; lang?: string } + ) { + if (blob.content) { + const lang: string = blob.lang!; + const defs = await lspService.languageSeverDef(endpoint, { lang }); + if (defs.length === 0) { report.fileStatus = RepoFileStatus.FILE_NOT_SUPPORTED; } else { - return def; + return defs; } } else { report.fileStatus = RepoFileStatus.FILE_IS_TOO_BIG; } + return []; } async function handleLspStatus( + endpoint: Endpoint, report: StatusReport, - def: LanguageServerDefinition, + defs: LanguageServerDefinition[], repoUri: string, revision: string ) { - report.langServerType = def === CTAGS ? LangServerType.GENERIC : LangServerType.DEDICATED; - if (lspService.languageServerStatus(def.languages[0]) === LanguageServerStatus.NOT_INSTALLED) { + const dedicated = defs.find(d => d !== CTAGS); + const generic = defs.find(d => d === CTAGS); + report.langServerType = dedicated ? LangServerType.DEDICATED : LangServerType.GENERIC; + if ( + dedicated && + (await lspService.languageServerStatus(endpoint, { langName: dedicated.name })) === + LanguageServerStatus.NOT_INSTALLED + ) { report.langServerStatus = RepoFileStatus.LANG_SERVER_NOT_INSTALLED; + if (generic) { + // dedicated lang server not installed, fallback to generic + report.langServerType = LangServerType.GENERIC; + } } else { - const state = await lspService.initializeState(repoUri, revision); - const initState = state[def.name]; + const def = dedicated || generic; + const state = await lspService.initializeState(endpoint, { repoUri, revision }); + const initState = state[def!.name]; report.langServerStatus = initState === WorkspaceStatus.Initialized ? RepoFileStatus.LANG_SERVER_INITIALIZED : RepoFileStatus.LANG_SERVER_IS_INITIALIZING; } } - - server.route({ + router.route({ path: '/api/code/repo/{uri*3}/status/{ref}/{path*}', method: 'GET', - async handler(req: hapi.Request) { + async handler(req: RequestFacade) { const { uri, path, ref } = req.params; const report: StatusReport = {}; const repoObjectClient = new RepositoryObjectClient(new EsClientWithRequest(req)); + const endpoint = await codeServices.locate(req, uri); + try { // Check if the repository already exists await repoObjectClient.getRepository(uri); } catch (e) { return Boom.notFound(`repo ${uri} not found`); } - await handleRepoStatus(report, uri, ref, repoObjectClient); + await handleRepoStatus(endpoint, report, uri, ref, repoObjectClient); if (path) { try { try { - const blob = await gitOps.fileContent(uri, path, decodeURIComponent(ref)); + const blob = await gitService.blob(endpoint, { + uri, + path, + revision: decodeURIComponent(ref), + }); // text file - if (!blob.isBinary()) { - const def = await handleFileStatus(report, blob.content(), path); - if (def) { - await handleLspStatus(report, def, uri, ref); + if (!blob.isBinary) { + const defs = await handleFileStatus(endpoint, report, blob); + if (defs.length > 0) { + await handleLspStatus(endpoint, report, defs, uri, ref); } } } catch (e) { diff --git a/x-pack/legacy/plugins/code/server/routes/workspace.ts b/x-pack/legacy/plugins/code/server/routes/workspace.ts index a1e3aa78c4922..708c2ebe8ac7f 100644 --- a/x-pack/legacy/plugins/code/server/routes/workspace.ts +++ b/x-pack/legacy/plugins/code/server/routes/workspace.ts @@ -5,23 +5,21 @@ */ import Boom from 'boom'; -import hapi, { RequestQuery } from 'hapi'; -import { GitOperations } from '../git_operations'; -import { Logger } from '../log'; -import { WorkspaceCommand } from '../lsp/workspace_command'; -import { WorkspaceHandler } from '../lsp/workspace_handler'; +import { RequestFacade, RequestQueryFacade } from '../../'; import { ServerOptions } from '../server_options'; -import { EsClientWithRequest } from '../utils/esclient_with_request'; -import { ServerLoggerFactory } from '../utils/server_logger_factory'; import { CodeServerRouter } from '../security'; +import { CodeServices } from '../distributed/code_services'; +import { WorkspaceDefinition } from '../distributed/apis'; export function workspaceRoute( - server: CodeServerRouter, + router: CodeServerRouter, serverOptions: ServerOptions, - gitOps: GitOperations + codeServices: CodeServices ) { - server.route({ + const workspaceService = codeServices.serviceFor(WorkspaceDefinition); + + router.route({ path: '/api/code/workspace', method: 'GET', async handler() { @@ -29,37 +27,19 @@ export function workspaceRoute( }, }); - server.route({ + router.route({ path: '/api/code/workspace/{uri*3}/{revision}', requireAdmin: true, method: 'POST', - async handler(req: hapi.Request, reply) { + async handler(req: RequestFacade) { const repoUri = req.params.uri as string; const revision = req.params.revision as string; const repoConfig = serverOptions.repoConfigs[repoUri]; - const force = !!(req.query as RequestQuery).force; + const force = !!(req.query as RequestQueryFacade).force; if (repoConfig) { - const log = new Logger(server.server, ['workspace', repoUri]); - const workspaceHandler = new WorkspaceHandler( - gitOps, - serverOptions.workspacePath, - new EsClientWithRequest(req), - new ServerLoggerFactory(server.server) - ); + const endpoint = await codeServices.locate(req, repoUri); try { - const { workspaceDir, workspaceRevision } = await workspaceHandler.openWorkspace( - repoUri, - revision - ); - const workspaceCmd = new WorkspaceCommand( - repoConfig, - workspaceDir, - workspaceRevision, - log - ); - workspaceCmd.runInit(force).then(() => { - return ''; - }); + await workspaceService.initCmd(endpoint, { repoUri, revision, force, repoConfig }); } catch (e) { if (e.isBoom) { return e; diff --git a/x-pack/legacy/plugins/code/server/security.ts b/x-pack/legacy/plugins/code/server/security.ts index 50368117904ab..c548b51940599 100644 --- a/x-pack/legacy/plugins/code/server/security.ts +++ b/x-pack/legacy/plugins/code/server/security.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server, ServerRoute, RouteOptions } from 'hapi'; +import { ServerFacade, ServerRouteFacade, RouteOptionsFacade } from '..'; export class CodeServerRouter { - constructor(readonly server: Server) {} + constructor(readonly server: ServerFacade) {} route(route: CodeRoute) { - const routeOptions: RouteOptions = (route.options || {}) as RouteOptions; + const routeOptions: RouteOptionsFacade = (route.options || {}) as RouteOptionsFacade; routeOptions.tags = [ ...(routeOptions.tags || []), `access:code_${route.requireAdmin ? 'admin' : 'user'}`, @@ -25,6 +25,6 @@ export class CodeServerRouter { } } -export interface CodeRoute extends ServerRoute { +export interface CodeRoute extends ServerRouteFacade { requireAdmin?: boolean; } diff --git a/x-pack/legacy/plugins/code/server/server_options.ts b/x-pack/legacy/plugins/code/server/server_options.ts index 35525d1db8f4e..30172bcdfc826 100644 --- a/x-pack/legacy/plugins/code/server/server_options.ts +++ b/x-pack/legacy/plugins/code/server/server_options.ts @@ -22,6 +22,11 @@ export interface SecurityOptions { enableGitCertCheck: boolean; } +export interface DiskOptions { + thresholdEnabled: boolean; + watermarkLowMb: number; +} + export class ServerOptions { public readonly workspacePath = resolve(this.config.get('path.data'), 'code/workspace'); @@ -43,14 +48,14 @@ export class ServerOptions { public readonly maxWorkspace: number = this.options.maxWorkspace; - public readonly disableIndexScheduler: boolean = this.options.disableIndexScheduler; - public readonly enableGlobalReference: boolean = this.options.enableGlobalReference; public readonly lsp: LspOptions = this.options.lsp; public readonly security: SecurityOptions = this.options.security; + public readonly disk: DiskOptions = this.options.disk; + public readonly repoConfigs: RepoConfigs = (this.options.repos as RepoConfig[]).reduce( (previous, current) => { previous[current.repo] = current; diff --git a/x-pack/legacy/plugins/code/server/test_utils.ts b/x-pack/legacy/plugins/code/server/test_utils.ts index 00ab4cc2deff9..02ef6b3c687f9 100644 --- a/x-pack/legacy/plugins/code/server/test_utils.ts +++ b/x-pack/legacy/plugins/code/server/test_utils.ts @@ -11,6 +11,7 @@ import path from 'path'; import { AnyObject } from './lib/esqueue'; import { ServerOptions } from './server_options'; +import { ServerFacade } from '..'; // TODO migrate other duplicate classes, functions @@ -36,9 +37,12 @@ const TEST_OPTIONS = { enableGitCertCheck: true, gitProtocolWhitelist: ['ssh', 'https', 'git'], }, + disk: { + thresholdEnabled: true, + watermarkLowMb: 100, + }, repos: [], maxWorkspace: 5, // max workspace folder for each language server - disableIndexScheduler: true, // Temp option to disable index scheduler. }; export function createTestServerOption() { @@ -56,7 +60,7 @@ export function createTestServerOption() { } export function createTestHapiServer() { - const server = new Server(); + const server: ServerFacade = new Server(); // @ts-ignore server.config = () => { return { diff --git a/x-pack/legacy/plugins/code/server/utils/esclient_with_internal_request.ts b/x-pack/legacy/plugins/code/server/utils/esclient_with_internal_request.ts index 3a134ebb65cf6..5a2cb0952e4b6 100644 --- a/x-pack/legacy/plugins/code/server/utils/esclient_with_internal_request.ts +++ b/x-pack/legacy/plugins/code/server/utils/esclient_with_internal_request.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; +import { ServerFacade } from '../..'; import { AnyObject, EsClient } from '../lib/esqueue'; import { EsIndexClient } from './es_index_client'; import { WithInternalRequest } from './with_internal_request'; @@ -12,7 +12,7 @@ import { WithInternalRequest } from './with_internal_request'; export class EsClientWithInternalRequest extends WithInternalRequest implements EsClient { public readonly indices = new EsIndexClient(this); - constructor(server: Server) { + constructor(server: ServerFacade) { super(server); } diff --git a/x-pack/legacy/plugins/code/server/utils/esclient_with_request.ts b/x-pack/legacy/plugins/code/server/utils/esclient_with_request.ts index 85249b4344a0d..a1f70db0a7074 100644 --- a/x-pack/legacy/plugins/code/server/utils/esclient_with_request.ts +++ b/x-pack/legacy/plugins/code/server/utils/esclient_with_request.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; +import { RequestFacade } from '../../'; import { AnyObject, EsClient } from '../lib/esqueue'; import { EsIndexClient } from './es_index_client'; import { WithRequest } from './with_request'; @@ -12,7 +12,7 @@ import { WithRequest } from './with_request'; export class EsClientWithRequest extends WithRequest implements EsClient { public readonly indices = new EsIndexClient(this); - constructor(readonly req: Request) { + constructor(readonly req: RequestFacade) { super(req); } diff --git a/x-pack/legacy/plugins/code/server/utils/server_logger_factory.ts b/x-pack/legacy/plugins/code/server/utils/server_logger_factory.ts index ad45b0d5be564..62a7d197e419e 100644 --- a/x-pack/legacy/plugins/code/server/utils/server_logger_factory.ts +++ b/x-pack/legacy/plugins/code/server/utils/server_logger_factory.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Hapi from 'hapi'; import { Logger } from '../log'; import { LoggerFactory } from './log_factory'; +import { ServerFacade } from '../..'; export class ServerLoggerFactory implements LoggerFactory { - constructor(private readonly server: Hapi.Server) {} + constructor(private readonly server: ServerFacade) {} public getLogger(tags: string[]): Logger { return new Logger(this.server, tags); diff --git a/x-pack/legacy/plugins/code/server/utils/with_internal_request.ts b/x-pack/legacy/plugins/code/server/utils/with_internal_request.ts index efccc4c7d0cdf..a51fa990ff10e 100644 --- a/x-pack/legacy/plugins/code/server/utils/with_internal_request.ts +++ b/x-pack/legacy/plugins/code/server/utils/with_internal_request.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; +import { ServerFacade } from '../..'; import { AnyObject } from '../lib/esqueue'; export class WithInternalRequest { public readonly callCluster: (endpoint: string, clientOptions?: AnyObject) => Promise; - constructor(server: Server) { + constructor(server: ServerFacade) { const cluster = server.plugins.elasticsearch.getCluster('admin'); this.callCluster = cluster.callWithInternalUser; } diff --git a/x-pack/legacy/plugins/code/server/utils/with_request.ts b/x-pack/legacy/plugins/code/server/utils/with_request.ts index fe049d044d4df..e08b9727f375e 100644 --- a/x-pack/legacy/plugins/code/server/utils/with_request.ts +++ b/x-pack/legacy/plugins/code/server/utils/with_request.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; +import { RequestFacade } from '../../'; import { AnyObject } from '../lib/esqueue'; export class WithRequest { public readonly callCluster: (endpoint: string, clientOptions?: AnyObject) => Promise; - constructor(readonly req: Request) { + constructor(readonly req: RequestFacade) { const cluster = req.server.plugins.elasticsearch.getCluster('data'); // @ts-ignore diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js index de0a89153e2b8..61ca7359efbda 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_form.test.js @@ -11,11 +11,15 @@ jest.mock('../services/auto_follow_pattern_validators', () => ({ validateLeaderIndexPattern: jest.fn(), })); -jest.mock('ui/index_patterns/index_patterns.js', () => ({ +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/_index_pattern', () => ({ + IndexPattern: jest.fn(), +})); + +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/index_patterns', () => ({ IndexPatterns: jest.fn(), })); -jest.mock('ui/index_patterns/index_patterns_api_client.js', () => ({ +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/index_patterns_api_client', () => ({ IndexPatternsApiClient: jest.fn(), })); diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js b/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js index 8a5a8290a899d..be12bdecf56bc 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/app/services/auto_follow_pattern_validators.test.js @@ -7,11 +7,15 @@ import { validateAutoFollowPattern } from './auto_follow_pattern_validators'; -jest.mock('ui/index_patterns/index_patterns.js', () => ({ +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/_index_pattern', () => ({ + IndexPattern: jest.fn(), +})); + +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/index_patterns', () => ({ IndexPatterns: jest.fn(), })); -jest.mock('ui/index_patterns/index_patterns_api_client.js', () => ({ +jest.mock('../../../../../../../src/legacy/ui/public/index_patterns/index_patterns_api_client', () => ({ IndexPatternsApiClient: jest.fn(), })); diff --git a/x-pack/legacy/plugins/file_upload/public/components/index_settings.js b/x-pack/legacy/plugins/file_upload/public/components/index_settings.js index 61c0ce841b651..47f8e8ba72c79 100644 --- a/x-pack/legacy/plugins/file_upload/public/components/index_settings.js +++ b/x-pack/legacy/plugins/file_upload/public/components/index_settings.js @@ -112,6 +112,7 @@ export class IndexSettings extends Component { } > ({ text: indexType, @@ -131,6 +132,7 @@ export class IndexSettings extends Component { error={[indexNameError]} > - + {indexDataJson} @@ -100,7 +100,7 @@ export class JsonImportProgress extends Component { /> - + {indexPatternJson} @@ -112,6 +112,7 @@ export class JsonImportProgress extends Component { defaultMessage: 'Further index modifications can be made using\n', })} {fileParsingProgress ? : null} - { const cleanAndValidate = jest.fn(a => a); @@ -61,26 +61,4 @@ describe('parse file', () => { // Confirm preview function called expect(previewFunction.mock.calls.length).toEqual(1); }); - - it('should use object clone for preview function', () => { - const justFinalJson = { - 'type': 'Feature', - 'geometry': { - 'type': 'Polygon', - 'coordinates': [[ - [-104.05, 78.99], - [-87.22, 78.98], - [-86.58, 75.94], - [-104.03, 75.94], - [-104.05, 78.99] - ]] - }, - }; - - jsonPreview(justFinalJson, previewFunction); - // Confirm equal object passed - expect(previewFunction.mock.calls[0][0]).toEqual(justFinalJson); - // Confirm not the same object - expect(previewFunction.mock.calls[0][0]).not.toBe(justFinalJson); - }); }); diff --git a/x-pack/legacy/plugins/file_upload/public/util/geo_processing.js b/x-pack/legacy/plugins/file_upload/public/util/geo_processing.js index c74b0d6456ca4..235a02ae409e9 100644 --- a/x-pack/legacy/plugins/file_upload/public/util/geo_processing.js +++ b/x-pack/legacy/plugins/file_upload/public/util/geo_processing.js @@ -26,15 +26,18 @@ const DEFAULT_GEO_POINT_MAPPINGS = { const DEFAULT_INGEST_PIPELINE = {}; export function getGeoIndexTypesForFeatures(featureTypes) { - if (!featureTypes || !featureTypes.length) { + const hasNoFeatureType = !featureTypes || !featureTypes.length; + if (hasNoFeatureType) { return []; - } else if (!featureTypes.includes('Point')) { + } + + const isPoint = featureTypes.includes('Point') || featureTypes.includes('MultiPoint'); + if (!isPoint) { return [ES_GEO_FIELD_TYPE.GEO_SHAPE]; - } else if (featureTypes.includes('Point') && featureTypes.length === 1) { + } else if (isPoint && featureTypes.length === 1) { return [ ES_GEO_FIELD_TYPE.GEO_POINT, ES_GEO_FIELD_TYPE.GEO_SHAPE ]; - } else { - return [ ES_GEO_FIELD_TYPE.GEO_SHAPE ]; } + return [ ES_GEO_FIELD_TYPE.GEO_SHAPE ]; } // Reduces & flattens geojson to coordinates and properties (if any) diff --git a/x-pack/legacy/plugins/graph/public/app.js b/x-pack/legacy/plugins/graph/public/app.js index 3f01c73bf6265..4b3a4f96b796c 100644 --- a/x-pack/legacy/plugins/graph/public/app.js +++ b/x-pack/legacy/plugins/graph/public/app.js @@ -742,7 +742,7 @@ app.controller('graphuiPlugin', function ( } } - $scope.indices = $route.current.locals.indexPatterns.filter(indexPattern => !indexPattern.get('type')); + $scope.indices = $route.current.locals.indexPatterns.filter(indexPattern => !indexPattern.type); $scope.setDetail = function (data) { diff --git a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/home.helpers.ts b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/home.helpers.ts index efeadc75253c5..d2857d5f5f54b 100644 --- a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/home.helpers.ts +++ b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/home.helpers.ts @@ -10,6 +10,7 @@ import { TestBed, TestBedConfig, findTestSubject, + nextTick, } from '../../../../../../test_utils'; import { IndexManagementHome } from '../../../public/sections/home'; import { BASE_PATH } from '../../../common/constants'; @@ -28,9 +29,12 @@ const initTestBed = registerTestBed(IndexManagementHome, testBedConfig); export interface IdxMgmtHomeTestBed extends TestBed { actions: { - selectTab: (tab: 'indices' | 'index templates') => void; + selectHomeTab: (tab: 'indices' | 'index templates') => void; + selectDetailsTab: (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => void; clickReloadButton: () => void; clickTemplateActionAt: (index: number, action: 'delete') => void; + clickTemplateAt: (index: number) => void; + clickCloseDetailsButton: () => void; }; } @@ -41,7 +45,7 @@ export const setup = async (): Promise => { * User Actions */ - const selectTab = (tab: 'indices' | 'index templates') => { + const selectHomeTab = (tab: 'indices' | 'index templates') => { const tabs = ['indices', 'index templates']; testBed @@ -50,6 +54,15 @@ export const setup = async (): Promise => { .simulate('click'); }; + const selectDetailsTab = (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => { + const tabs = ['summary', 'settings', 'mappings', 'aliases']; + + testBed + .find('templateDetails.tab') + .at(tabs.indexOf(tab)) + .simulate('click'); + }; + const clickReloadButton = () => { const { find } = testBed; find('reloadButton').simulate('click'); @@ -69,12 +82,35 @@ export const setup = async (): Promise => { }); }; + const clickTemplateAt = async (index: number) => { + const { component, table, router } = testBed; + const { rows } = table.getMetaData('templatesTable'); + const templateLink = findTestSubject(rows[index].reactWrapper, 'templateDetailsLink'); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + const { href } = templateLink.props(); + router.navigateTo(href!); + await nextTick(); + component.update(); + }); + }; + + const clickCloseDetailsButton = () => { + const { find } = testBed; + + find('closeDetailsButton').simulate('click'); + }; + return { ...testBed, actions: { - selectTab, + selectHomeTab, + selectDetailsTab, clickReloadButton, clickTemplateActionAt, + clickTemplateAt, + clickCloseDetailsButton, }, }; }; @@ -82,19 +118,31 @@ export const setup = async (): Promise => { type IdxMgmtTestSubjects = TestSubjects; export type TestSubjects = + | 'aliasesTab' | 'appTitle' | 'cell' + | 'closeDetailsButton' | 'deleteSystemTemplateCallOut' | 'deleteTemplateButton' | 'deleteTemplatesButton' | 'deleteTemplatesConfirmation' | 'documentationLink' | 'emptyPrompt' + | 'mappingsTab' | 'indicesList' | 'reloadButton' | 'row' + | 'sectionError' | 'sectionLoading' + | 'settingsTab' + | 'summaryTab' + | 'summaryTitle' | 'systemTemplatesSwitch' | 'tab' + | 'templateDetails' + | 'templateDetails.deleteTemplateButton' + | 'templateDetails.sectionLoading' + | 'templateDetails.tab' + | 'templateDetails.title' | 'templatesList' | 'templatesTable'; diff --git a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index ddcdc31b31598..90320740d09ae 100644 --- a/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -36,10 +36,22 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setLoadTemplateResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? error.body : response; + + server.respondWith('GET', `${API_PATH}/templates/:id`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(body), + ]); + }; + return { setLoadTemplatesResponse, setLoadIndicesResponse, setDeleteTemplateResponse, + setLoadTemplateResponse, }; }; diff --git a/x-pack/legacy/plugins/index_management/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/index_management/__jest__/client_integration/home.test.ts index a66168c51e448..fbd0f6419979b 100644 --- a/x-pack/legacy/plugins/index_management/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/index_management/__jest__/client_integration/home.test.ts @@ -80,7 +80,7 @@ describe.skip('', () => { httpRequestsMockHelpers.setLoadTemplatesResponse([]); - actions.selectTab('index templates'); + actions.selectHomeTab('index templates'); // @ts-ignore (remove when react 16.9.0 is released) await act(async () => { @@ -100,7 +100,7 @@ describe.skip('', () => { httpRequestsMockHelpers.setLoadTemplatesResponse([]); - actions.selectTab('index templates'); + actions.selectHomeTab('index templates'); // @ts-ignore (remove when react 16.9.0 is released) await act(async () => { @@ -146,7 +146,7 @@ describe.skip('', () => { httpRequestsMockHelpers.setLoadTemplatesResponse(templates); - actions.selectTab('index templates'); + actions.selectHomeTab('index templates'); // @ts-ignore (remove when react 16.9.0 is released) await act(async () => { @@ -220,6 +220,16 @@ describe.skip('', () => { expect(updatedRows.length).toEqual(templates.length); }); + test('each row should have a link to the template', async () => { + const { find, exists, actions } = testBed; + + await actions.clickTemplateAt(0); + + expect(exists('templatesList')).toBe(true); + expect(exists('templateDetails')).toBe(true); + expect(find('templateDetails.title').text()).toBe(template1.name); + }); + describe('delete index template', () => { test('should have action buttons on each row to delete an index template', () => { const { table } = testBed; @@ -298,6 +308,221 @@ describe.skip('', () => { expect(latestRequest.url).toBe(`${API_PATH}/templates/${template1.name}`); }); }); + + describe('detail flyout', () => { + it('should have a close button and be able to close flyout', async () => { + const template = fixtures.getTemplate({ + name: `a${getRandomString()}`, + indexPatterns: ['template1Pattern1*', 'template1Pattern2'], + }); + + const { actions, component, exists } = testBed; + + httpRequestsMockHelpers.setLoadTemplateResponse(template); + + await actions.clickTemplateAt(0); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('closeDetailsButton')).toBe(true); + expect(exists('summaryTab')).toBe(true); + + actions.clickCloseDetailsButton(); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('summaryTab')).toBe(false); + }); + + it('should have a delete button', async () => { + const template = fixtures.getTemplate({ + name: `a${getRandomString()}`, + indexPatterns: ['template1Pattern1*', 'template1Pattern2'], + }); + + const { actions, component, exists } = testBed; + + httpRequestsMockHelpers.setLoadTemplateResponse(template); + + await actions.clickTemplateAt(0); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('templateDetails.deleteTemplateButton')).toBe(true); + }); + + it('should render an error if error fetching template details', async () => { + const { actions, component, exists } = testBed; + const error = { + status: 404, + error: 'Not found', + message: 'Template not found', + }; + + httpRequestsMockHelpers.setLoadTemplateResponse(undefined, { body: error }); + + await actions.clickTemplateAt(0); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('sectionError')).toBe(true); + // Delete button should not render if error + expect(exists('templateDetails.deleteTemplateButton')).toBe(false); + }); + + describe('tabs', () => { + test('should have 4 tabs if template has mappings, settings and aliases data', async () => { + const template = fixtures.getTemplate({ + name: `a${getRandomString()}`, + indexPatterns: ['template1Pattern1*', 'template1Pattern2'], + settings: { + index: { + number_of_shards: '1', + }, + }, + mappings: { + _source: { + enabled: false, + }, + properties: { + created_at: { + type: 'date', + format: 'EEE MMM dd HH:mm:ss Z yyyy', + }, + }, + }, + aliases: { + alias1: {}, + }, + }); + + const { find, actions, exists, component } = testBed; + + httpRequestsMockHelpers.setLoadTemplateResponse(template); + + await actions.clickTemplateAt(0); + + expect(find('templateDetails.tab').length).toBe(4); + expect(find('templateDetails.tab').map(t => t.text())).toEqual([ + 'Summary', + 'Settings', + 'Mappings', + 'Aliases', + ]); + + // Summary tab should be initial active tab + expect(exists('summaryTab')).toBe(true); + + // Navigate and verify all tabs + actions.selectDetailsTab('settings'); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('summaryTab')).toBe(false); + expect(exists('settingsTab')).toBe(true); + + actions.selectDetailsTab('aliases'); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('summaryTab')).toBe(false); + expect(exists('settingsTab')).toBe(false); + expect(exists('aliasesTab')).toBe(true); + + actions.selectDetailsTab('mappings'); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(exists('summaryTab')).toBe(false); + expect(exists('settingsTab')).toBe(false); + expect(exists('aliasesTab')).toBe(false); + expect(exists('mappingsTab')).toBe(true); + }); + + it('should not show tabs if mappings, settings and aliases data is not present', async () => { + const templateWithNoTabs = fixtures.getTemplate({ + name: `a${getRandomString()}`, + indexPatterns: ['template1Pattern1*', 'template1Pattern2'], + }); + + const { actions, find, exists, component } = testBed; + + httpRequestsMockHelpers.setLoadTemplateResponse(templateWithNoTabs); + + await actions.clickTemplateAt(0); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(find('templateDetails.tab').length).toBe(0); + expect(exists('summaryTab')).toBe(true); + expect(exists('summaryTitle')).toBe(true); + }); + + it('should not show all tabs if mappings, settings or aliases data is not present', async () => { + const templateWithSomeTabs = fixtures.getTemplate({ + name: `a${getRandomString()}`, + indexPatterns: ['template1Pattern1*', 'template1Pattern2'], + settings: { + index: { + number_of_shards: '1', + }, + }, + }); + + const { actions, find, exists, component } = testBed; + + httpRequestsMockHelpers.setLoadTemplateResponse(templateWithSomeTabs); + + await actions.clickTemplateAt(0); + + // @ts-ignore (remove when react 16.9.0 is released) + await act(async () => { + await nextTick(); + component.update(); + }); + + expect(find('templateDetails.tab').length).toBe(2); + expect(exists('summaryTab')).toBe(true); + // Template does not contain aliases or mappings, so tabs will not render + expect(find('templateDetails.tab').map(t => t.text())).toEqual([ + 'Summary', + 'Settings', + ]); + }); + }); + }); }); }); }); diff --git a/x-pack/legacy/plugins/index_management/common/constants/index.ts b/x-pack/legacy/plugins/index_management/common/constants/index.ts index 9a122ecee63ef..06159fd45ede1 100644 --- a/x-pack/legacy/plugins/index_management/common/constants/index.ts +++ b/x-pack/legacy/plugins/index_management/common/constants/index.ts @@ -40,4 +40,9 @@ export { UIM_TEMPLATE_LIST_LOAD, UIM_TEMPLATE_DELETE, UIM_TEMPLATE_DELETE_MANY, + UIM_TEMPLATE_SHOW_DETAILS_CLICK, + UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, + UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, + UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, + UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, } from './ui_metric'; diff --git a/x-pack/legacy/plugins/index_management/common/constants/ui_metric.ts b/x-pack/legacy/plugins/index_management/common/constants/ui_metric.ts index 962b39bfab196..7f0c62ddf5ec0 100644 --- a/x-pack/legacy/plugins/index_management/common/constants/ui_metric.ts +++ b/x-pack/legacy/plugins/index_management/common/constants/ui_metric.ts @@ -36,3 +36,8 @@ export const UIM_DETAIL_PANEL_SUMMARY_TAB = 'detail_panel_summary_tab'; export const UIM_TEMPLATE_LIST_LOAD = 'template_list_load'; export const UIM_TEMPLATE_DELETE = 'template_delete'; export const UIM_TEMPLATE_DELETE_MANY = 'template_delete_many'; +export const UIM_TEMPLATE_SHOW_DETAILS_CLICK = 'template_show_details_click'; +export const UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB = 'template_details_summary_tab'; +export const UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB = 'template_details_settings_tab'; +export const UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB = 'template_details_mappings_tab'; +export const UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB = 'template_details_aliases_tab'; diff --git a/x-pack/legacy/plugins/index_management/public/components/delete_templates_modal.tsx b/x-pack/legacy/plugins/index_management/public/components/delete_templates_modal.tsx index cb3ac528ba682..926be62739265 100644 --- a/x-pack/legacy/plugins/index_management/public/components/delete_templates_modal.tsx +++ b/x-pack/legacy/plugins/index_management/public/components/delete_templates_modal.tsx @@ -21,10 +21,6 @@ export const DeleteTemplatesModal = ({ }) => { const numTemplatesToDelete = templatesToDelete.length; - if (!numTemplatesToDelete) { - return null; - } - const hasSystemTemplate = Boolean( templatesToDelete.find(templateName => templateName.startsWith('.')) ); diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/home.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/home.tsx index 2784f049ba028..5d2fb54216da7 100644 --- a/x-pack/legacy/plugins/index_management/public/sections/home/home.tsx +++ b/x-pack/legacy/plugins/index_management/public/sections/home/home.tsx @@ -104,7 +104,7 @@ export const IndexManagementHome: React.FunctionComponent - + diff --git a/x-pack/test/types/services.d.ts b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/index.ts similarity index 78% rename from x-pack/test/types/services.d.ts rename to x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/index.ts index a219e43ebd956..7360c96c250cf 100644 --- a/x-pack/test/types/services.d.ts +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface LogService { - debug: (message: string) => void; -} +export { TemplateDetails } from './template_details'; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/aliases_tab.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/aliases_tab.tsx new file mode 100644 index 0000000000000..02cb59619aae1 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/aliases_tab.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCodeEditor } from '@elastic/eui'; +import { Template } from '../../../../../../common/types'; + +interface Props { + templateDetails: Template; +} + +export const AliasesTab: React.FunctionComponent = ({ templateDetails }) => { + const { aliases } = templateDetails; + const aliasesJsonString = JSON.stringify(aliases, null, 2); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/index.ts b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/index.ts new file mode 100644 index 0000000000000..a648a7f476312 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SummaryTab } from './summary_tab'; +export { MappingsTab } from './mappings_tab'; +export { SettingsTab } from './settings_tab'; +export { AliasesTab } from './aliases_tab'; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/mappings_tab.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/mappings_tab.tsx new file mode 100644 index 0000000000000..15133e595a280 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/mappings_tab.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCodeEditor } from '@elastic/eui'; +import { Template } from '../../../../../../common/types'; + +interface Props { + templateDetails: Template; +} + +export const MappingsTab: React.FunctionComponent = ({ templateDetails }) => { + const { mappings } = templateDetails; + const mappingsJsonString = JSON.stringify(mappings, null, 2); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/settings_tab.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/settings_tab.tsx new file mode 100644 index 0000000000000..697c2e4ab5272 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/settings_tab.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCodeEditor } from '@elastic/eui'; +import { Template } from '../../../../../../common/types'; + +interface Props { + templateDetails: Template; +} + +export const SettingsTab: React.FunctionComponent = ({ templateDetails }) => { + const { settings } = templateDetails; + const settingsJsonString = JSON.stringify(settings, null, 2); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/summary_tab.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/summary_tab.tsx new file mode 100644 index 0000000000000..3d55bdf6ab977 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/tabs/summary_tab.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { Template } from '../../../../../../common/types'; +import { getILMPolicyPath } from '../../../../../services/navigation'; + +interface Props { + templateDetails: Template; +} + +const NoneDescriptionText = () => ( + +); + +export const SummaryTab: React.FunctionComponent = ({ templateDetails }) => { + const { version, order, indexPatterns = [], settings } = templateDetails; + + const ilmPolicy = settings && settings.index && settings.index.lifecycle; + const numIndexPatterns = indexPatterns.length; + + return ( + + + + + + + + {numIndexPatterns > 1 ? ( + +
    + {indexPatterns.map((indexName: string, i: number) => { + return ( +
  • + + {indexName} + +
  • + ); + })} +
+
+ ) : ( + indexPatterns.toString() + )} +
+
+
+ + + + + + + + {ilmPolicy && ilmPolicy.name ? ( + {ilmPolicy.name} + ) : ( + + )} + + + + + + {typeof order !== 'undefined' ? order : } + + + + + + {typeof version !== 'undefined' ? version : } + + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/template_details.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/template_details.tsx new file mode 100644 index 0000000000000..9c295fbcbc2d3 --- /dev/null +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/template_details/template_details.tsx @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiTab, + EuiTabs, + EuiSpacer, +} from '@elastic/eui'; +import { + UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, + UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, + UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, + UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, +} from '../../../../../common/constants'; +import { Template } from '../../../../../common/types'; +import { DeleteTemplatesModal, SectionLoading, SectionError } from '../../../../components'; +import { loadIndexTemplate } from '../../../../services/api'; +import { trackUiMetric, METRIC_TYPE } from '../../../../services/track_ui_metric'; +import { SummaryTab, MappingsTab, SettingsTab, AliasesTab } from './tabs'; + +interface Props { + templateName: Template['name']; + onClose: () => void; + reload: () => Promise; +} + +const SUMMARY_TAB_ID = 'summary'; +const MAPPINGS_TAB_ID = 'mappings'; +const ALIASES_TAB_ID = 'aliases'; +const SETTINGS_TAB_ID = 'settings'; + +const summaryTabData = { + id: SUMMARY_TAB_ID, + name: ( + + ), +}; + +const settingsTabData = { + id: SETTINGS_TAB_ID, + name: ( + + ), +}; + +const mappingsTabData = { + id: MAPPINGS_TAB_ID, + name: ( + + ), +}; + +const aliasesTabData = { + id: ALIASES_TAB_ID, + name: ( + + ), +}; + +const tabToComponentMap: { + [key: string]: React.FunctionComponent<{ templateDetails: Template }>; +} = { + [SUMMARY_TAB_ID]: SummaryTab, + [SETTINGS_TAB_ID]: SettingsTab, + [MAPPINGS_TAB_ID]: MappingsTab, + [ALIASES_TAB_ID]: AliasesTab, +}; + +const tabToUiMetricMap: { [key: string]: string } = { + [SUMMARY_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, + [SETTINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, + [MAPPINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, + [ALIASES_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, +}; + +const hasEntries = (tabData: object) => { + return tabData ? Object.entries(tabData).length > 0 : false; +}; + +export const TemplateDetails: React.FunctionComponent = ({ + templateName, + onClose, + reload, +}) => { + const { error, data: templateDetails, isLoading } = loadIndexTemplate(templateName); + + const [templateToDelete, setTemplateToDelete] = useState>([]); + const [activeTab, setActiveTab] = useState(SUMMARY_TAB_ID); + + let content; + + if (isLoading) { + content = ( + + + + ); + } else if (error) { + content = ( + + } + error={error} + data-test-subj="sectionError" + /> + ); + } else if (templateDetails) { + const { settings, mappings, aliases } = templateDetails; + + const settingsTab = hasEntries(settings) ? [settingsTabData] : []; + const mappingsTab = hasEntries(mappings) ? [mappingsTabData] : []; + const aliasesTab = hasEntries(aliases) ? [aliasesTabData] : []; + + const optionalTabs = [...settingsTab, ...mappingsTab, ...aliasesTab]; + const tabs = optionalTabs.length > 0 ? [summaryTabData, ...optionalTabs] : []; + + if (tabs.length > 0) { + const Content = tabToComponentMap[activeTab]; + + content = ( + + + {tabs.map(tab => ( + { + trackUiMetric(METRIC_TYPE.CLICK, tabToUiMetricMap[tab.id]); + setActiveTab(tab.id); + }} + isSelected={tab.id === activeTab} + key={tab.id} + data-test-subj="tab" + > + {tab.name} + + ))} + + + + + + + ); + } else { + content = ( + + +

+ +

+
+ + + + +
+ ); + } + } + + return ( + + {templateToDelete.length ? ( + { + if (data && data.hasDeletedTemplates) { + reload(); + } + setTemplateToDelete([]); + onClose(); + }} + templatesToDelete={templateToDelete} + /> + ) : null} + + + + +

+ {templateName} +

+
+
+ + {content} + + + + + + + + + + {templateDetails && ( + + + + + + )} + + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_list.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_list.tsx index a17bfaf1ce27a..51fa92582d573 100644 --- a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_list.tsx +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_list.tsx @@ -5,6 +5,7 @@ */ import React, { Fragment, useState, useEffect, useMemo } from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt, @@ -20,9 +21,19 @@ import { TemplatesTable } from './templates_table'; import { loadIndexTemplates } from '../../../services/api'; import { Template } from '../../../../common/types'; import { trackUiMetric, METRIC_TYPE } from '../../../services/track_ui_metric'; -import { UIM_TEMPLATE_LIST_LOAD } from '../../../../common/constants'; +import { UIM_TEMPLATE_LIST_LOAD, BASE_PATH } from '../../../../common/constants'; +import { TemplateDetails } from './template_details'; -export const TemplatesList: React.FunctionComponent = () => { +interface MatchParams { + templateName?: Template['name']; +} + +export const TemplatesList: React.FunctionComponent> = ({ + match: { + params: { templateName }, + }, + history, +}) => { const { error, isLoading, data: templates, createRequest: reload } = loadIndexTemplates(); let content; @@ -36,6 +47,10 @@ export const TemplatesList: React.FunctionComponent = () => { [templates] ); + const closeTemplateDetails = () => { + history.push(`${BASE_PATH}templates`); + }; + // Track component loaded useEffect(() => { trackUiMetric(METRIC_TYPE.LOADED, UIM_TEMPLATE_LIST_LOAD); @@ -115,5 +130,16 @@ export const TemplatesList: React.FunctionComponent = () => { ); } - return
{content}
; + return ( +
+ {content} + {templateName && ( + + )} +
+ ); }; diff --git a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_table/templates_table.tsx b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_table/templates_table.tsx index c237d26021bec..8025b89f9d9fd 100644 --- a/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_table/templates_table.tsx +++ b/x-pack/legacy/plugins/index_management/public/sections/home/templates_list/templates_table/templates_table.tsx @@ -7,9 +7,18 @@ import React, { useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiInMemoryTable, EuiIcon, EuiButton, EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { + EuiInMemoryTable, + EuiIcon, + EuiButton, + EuiToolTip, + EuiButtonIcon, + EuiLink, +} from '@elastic/eui'; import { Template } from '../../../../../common/types'; +import { BASE_PATH, UIM_TEMPLATE_SHOW_DETAILS_CLICK } from '../../../../../common/constants'; import { DeleteTemplatesModal } from '../../../../components'; +import { trackUiMetric, METRIC_TYPE } from '../../../../services/track_ui_metric'; interface Props { templates: Template[]; @@ -34,6 +43,17 @@ export const TemplatesTable: React.FunctionComponent = ({ templates, relo }), truncateText: true, sortable: true, + render: (name: Template['name']) => { + return ( + trackUiMetric(METRIC_TYPE.CLICK, UIM_TEMPLATE_SHOW_DETAILS_CLICK)} + > + {name} + + ); + }, }, { field: 'indexPatterns', @@ -206,15 +226,17 @@ export const TemplatesTable: React.FunctionComponent = ({ templates, relo return ( - { - if (data && data.hasDeletedTemplates) { - reload(); - } - setTemplatesToDelete([]); - }} - templatesToDelete={templatesToDelete} - /> + {templatesToDelete.length ? ( + { + if (data && data.hasDeletedTemplates) { + reload(); + } + setTemplatesToDelete([]); + }} + templatesToDelete={templatesToDelete} + /> + ) : null} ) => { uimActionType, }); }; + +export function loadIndexTemplate(name: Template['name']) { + return useRequest({ + path: `${apiPrefix}/templates/${encodeURIComponent(name)}`, + method: 'get', + }); +} diff --git a/x-pack/legacy/plugins/index_management/public/services/navigation.js b/x-pack/legacy/plugins/index_management/public/services/navigation.ts similarity index 69% rename from x-pack/legacy/plugins/index_management/public/services/navigation.js rename to x-pack/legacy/plugins/index_management/public/services/navigation.ts index 45e4c73d8643d..16072cb702f00 100644 --- a/x-pack/legacy/plugins/index_management/public/services/navigation.js +++ b/x-pack/legacy/plugins/index_management/public/services/navigation.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import { BASE_PATH } from '../../common/constants'; -let urlService; -export const setUrlService = (aUrlService) => { + +let urlService: any; + +export const setUrlService = (aUrlService: any) => { urlService = aUrlService; }; + export const getUrlService = () => { return urlService; }; -export const getIndexListUri = (filter) => { - if(filter) { + +export const getIndexListUri = (filter: any) => { + if (filter) { // React router tries to decode url params but it can't because the browser partially // decodes them. So we have to encode both the URL and the filter to get it all to // work correctly for filters with URL unsafe characters in them. @@ -22,3 +26,11 @@ export const getIndexListUri = (filter) => { // If no filter, URI is already safe so no need to encode. return `#${BASE_PATH}indices`; }; + +export const getILMPolicyPath = (policyName: string) => { + return encodeURI( + `#/management/elasticsearch/index_lifecycle_management/policies/edit/${encodeURIComponent( + policyName + )}` + ); +}; diff --git a/x-pack/legacy/plugins/index_management/server/lib/fetch_templates.ts b/x-pack/legacy/plugins/index_management/server/lib/fetch_templates.ts deleted file mode 100644 index 673c4569f21c0..0000000000000 --- a/x-pack/legacy/plugins/index_management/server/lib/fetch_templates.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const fetchTemplates = async (callWithRequest: any) => { - const indexTemplatesByName = await callWithRequest('indices.getTemplate'); - const indexTemplateNames = Object.keys(indexTemplatesByName); - - const indexTemplates = indexTemplateNames.map(name => { - const { - version, - order, - index_patterns: indexPatterns = [], - settings = {}, - aliases = {}, - mappings = {}, - } = indexTemplatesByName[name]; - return { - name, - version, - order, - indexPatterns: indexPatterns.sort(), - settings, - aliases, - mappings, - }; - }); - - return indexTemplates; -}; diff --git a/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_get_routes.ts b/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_get_routes.ts new file mode 100644 index 0000000000000..7ed94ba73b13c --- /dev/null +++ b/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_get_routes.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Router, RouterRouteHandler } from '../../../../../../server/lib/create_router'; + +const allHandler: RouterRouteHandler = async (_req, callWithRequest) => { + const indexTemplatesByName = await callWithRequest('indices.getTemplate'); + const indexTemplateNames = Object.keys(indexTemplatesByName); + + const indexTemplates = indexTemplateNames.map(name => { + const { + version, + order, + index_patterns: indexPatterns = [], + settings = {}, + aliases = {}, + mappings = {}, + } = indexTemplatesByName[name]; + return { + name, + version, + order, + indexPatterns: indexPatterns.sort(), + settings, + aliases, + mappings, + }; + }); + + return indexTemplates; +}; + +const oneHandler: RouterRouteHandler = async (req, callWithRequest) => { + const { name } = req.params; + const indexTemplateByName = await callWithRequest('indices.getTemplate', { name }); + + if (indexTemplateByName[name]) { + const { + version, + order, + index_patterns: indexPatterns = [], + settings = {}, + aliases = {}, + mappings = {}, + } = indexTemplateByName[name]; + + return { + name, + version, + order, + indexPatterns: indexPatterns.sort(), + settings, + aliases, + mappings, + }; + } +}; + +export function registerGetAllRoute(router: Router) { + router.get('templates', allHandler); +} + +export function registerGetOneRoute(router: Router) { + router.get('templates/{name}', oneHandler); +} diff --git a/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_list_route.ts b/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_list_route.ts deleted file mode 100644 index c951c2d62330d..0000000000000 --- a/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_list_route.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Router, RouterRouteHandler } from '../../../../../../server/lib/create_router'; -import { fetchTemplates } from '../../../lib/fetch_templates'; - -const handler: RouterRouteHandler = async (_req, callWithRequest) => { - return fetchTemplates(callWithRequest); -}; - -export function registerListRoute(router: Router) { - router.get('templates', handler); -} diff --git a/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_templates_routes.ts b/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_templates_routes.ts index 80b59ef5ce0f9..084abd0a91eb3 100644 --- a/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_templates_routes.ts +++ b/x-pack/legacy/plugins/index_management/server/routes/api/templates/register_templates_routes.ts @@ -5,12 +5,13 @@ */ import { Router } from '../../../../../../server/lib/create_router'; -import { registerListRoute } from './register_list_route'; +import { registerGetAllRoute, registerGetOneRoute } from './register_get_routes'; import { registerDeleteRoute } from './register_delete_route'; import { registerCreateRoute } from './register_create_route'; export function registerTemplatesRoutes(router: Router) { - registerListRoute(router); + registerGetAllRoute(router); + registerGetOneRoute(router); registerDeleteRoute(router); registerCreateRoute(router); } diff --git a/x-pack/legacy/plugins/infra/common/graphql/types.ts b/x-pack/legacy/plugins/infra/common/graphql/types.ts index 2da829dbf2936..7843a54b93bed 100644 --- a/x-pack/legacy/plugins/infra/common/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/common/graphql/types.ts @@ -28,8 +28,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A hierarchy of metadata entries by node */ - metadataByNode: InfraNodeMetadata; /** A consecutive span of log entries surrounding a point in time */ logEntriesAround: InfraLogEntryInterval; /** A consecutive span of log entries within an interval */ @@ -132,20 +130,6 @@ export interface InfraIndexField { /** Whether the field's values can be aggregated */ aggregatable: boolean; } -/** One metadata entry for a node. */ -export interface InfraNodeMetadata { - id: string; - - name: string; - - features: InfraNodeFeature[]; -} - -export interface InfraNodeFeature { - name: string; - - source: string; -} /** A consecutive sequence of log entries */ export interface InfraLogEntryInterval { /** The key corresponding to the start of the interval covered by the entries */ @@ -424,11 +408,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface MetadataByNodeInfraSourceArgs { - nodeId: string; - - nodeType: InfraNodeType; -} export interface LogEntriesAroundInfraSourceArgs { /** The sort key that corresponds to the point in time */ key: InfraTimeKeyInput; @@ -722,44 +701,6 @@ export namespace LogSummary { }; } -export namespace MetadataQuery { - export type Variables = { - sourceId: string; - nodeId: string; - nodeType: InfraNodeType; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - metadataByNode: MetadataByNode; - }; - - export type MetadataByNode = { - __typename?: 'InfraNodeMetadata'; - - name: string; - - features: Features[]; - }; - - export type Features = { - __typename?: 'InfraNodeFeature'; - - name: string; - - source: string; - }; -} - export namespace MetricsQuery { export type Variables = { sourceId: string; diff --git a/x-pack/legacy/plugins/infra/common/http_api/index.ts b/x-pack/legacy/plugins/infra/common/http_api/index.ts index 90afdcb43ffb1..19a3dcea3bd0d 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/index.ts @@ -6,3 +6,5 @@ export * from './search_results_api'; export * from './search_summary_api'; +export * from './metadata_api'; +export * from './timed_api'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts b/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts new file mode 100644 index 0000000000000..796960651122f --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/metadata_api.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { InfraWrappableRequest } from '../../server/lib/adapters/framework'; + +export const InfraMetadataNodeTypeRT = rt.keyof({ + host: null, + pod: null, + container: null, +}); + +export const InfraMetadataRequestRT = rt.type({ + nodeId: rt.string, + nodeType: InfraMetadataNodeTypeRT, + sourceId: rt.string, +}); + +export const InfraMetadataFeatureRT = rt.type({ + name: rt.string, + source: rt.string, +}); + +export const InfraMetadataOSRT = rt.partial({ + codename: rt.string, + family: rt.string, + kernel: rt.string, + name: rt.string, + platform: rt.string, + version: rt.string, +}); + +export const InfraMetadataHostRT = rt.partial({ + name: rt.string, + os: InfraMetadataOSRT, + architecture: rt.string, + containerized: rt.boolean, +}); + +export const InfraMetadataInstanceRT = rt.partial({ + id: rt.string, + name: rt.string, +}); + +export const InfraMetadataProjectRT = rt.partial({ + id: rt.string, +}); + +export const InfraMetadataMachineRT = rt.partial({ + interface: rt.string, +}); + +export const InfraMetadataCloudRT = rt.partial({ + instance: InfraMetadataInstanceRT, + provider: rt.string, + availability_zone: rt.string, + project: InfraMetadataProjectRT, + machine: InfraMetadataMachineRT, +}); + +export const InfraMetadataInfoRT = rt.partial({ + cloud: InfraMetadataCloudRT, + host: InfraMetadataHostRT, +}); + +const InfraMetadataRequiredRT = rt.type({ + name: rt.string, + features: rt.array(InfraMetadataFeatureRT), +}); + +const InfraMetadataOptionalRT = rt.partial({ + info: InfraMetadataInfoRT, +}); + +export const InfraMetadataRT = rt.intersection([InfraMetadataRequiredRT, InfraMetadataOptionalRT]); + +export type InfraMetadata = rt.TypeOf; + +export type InfraMetadataRequest = rt.TypeOf; + +export type InfraMetadataWrappedRequest = InfraWrappableRequest; + +export type InfraMetadataFeature = rt.TypeOf; + +export type InfraMetadataInfo = rt.TypeOf; + +export type InfraMetadataCloud = rt.TypeOf; + +export type InfraMetadataInstance = rt.TypeOf; + +export type InfraMetadataProject = rt.TypeOf; + +export type InfraMetadataMachine = rt.TypeOf; + +export type InfraMetadataHost = rt.TypeOf; + +export type InfraMEtadataOS = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/common/runtime_types.ts b/x-pack/legacy/plugins/infra/common/runtime_types.ts new file mode 100644 index 0000000000000..297743f9b3456 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/runtime_types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Errors } from 'io-ts'; +import { failure } from 'io-ts/lib/PathReporter'; + +export const createPlainError = (message: string) => new Error(message); + +export const throwErrors = (createError: (message: string) => Error) => (errors: Errors) => { + throw createError(failure(errors).join('\n')); +}; diff --git a/x-pack/legacy/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx b/x-pack/legacy/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx index 990ee1c97c2e9..940d187f2f3a0 100644 --- a/x-pack/legacy/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx +++ b/x-pack/legacy/plugins/infra/public/components/autocomplete_field/autocomplete_field.tsx @@ -28,6 +28,7 @@ interface AutocompleteFieldProps { suggestions: AutocompleteSuggestion[]; value: string; autoFocus?: boolean; + 'aria-label'?: string; } interface AutocompleteFieldState { @@ -49,7 +50,14 @@ export class AutocompleteField extends React.Component< private inputElement: HTMLInputElement | null = null; public render() { - const { suggestions, isLoadingSuggestions, isValid, placeholder, value } = this.props; + const { + suggestions, + isLoadingSuggestions, + isValid, + placeholder, + value, + 'aria-label': ariaLabel, + } = this.props; const { areSuggestionsVisible, selectedIndex } = this.state; return ( @@ -65,9 +73,9 @@ export class AutocompleteField extends React.Component< onKeyDown={this.handleKeyDown} onKeyUp={this.handleKeyUp} onSearch={this.submit} - onBlur={this.submit} placeholder={placeholder} value={value} + aria-label={ariaLabel} /> {areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? ( diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx index c49178a92f8dc..398ef42048d70 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/column_headers.tsx @@ -31,7 +31,7 @@ export const LogColumnHeaders = injectI18n<{ }>(({ columnConfigurations, columnWidths, intl, showColumnConfiguration }) => { const showColumnConfigurationLabel = intl.formatMessage({ id: 'xpack.infra.logColumnHeaders.configureColumnsLabel', - defaultMessage: 'Configure columns', + defaultMessage: 'Configure source', }); return ( diff --git a/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx b/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx index 8976d77ea912f..547ea08361bf0 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics/sections/chart_section.tsx @@ -4,9 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useCallback } from 'react'; +import moment from 'moment'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; -import { Axis, Chart, getAxisId, niceTimeFormatter, Position, Settings } from '@elastic/charts'; +import { + Axis, + Chart, + getAxisId, + niceTimeFormatter, + Position, + Settings, + TooltipValue, +} from '@elastic/charts'; import { EuiPageContentBody, EuiTitle } from '@elastic/eui'; import { InfraMetricLayoutSection } from '../../../pages/metrics/layouts/types'; import { InfraMetricData, InfraTimerangeInput } from '../../../graphql/types'; @@ -22,6 +31,7 @@ import { seriesHasLessThen2DataPoints, } from './helpers'; import { ErrorMessage } from './error_message'; +import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; interface Props { section: InfraMetricLayoutSection; @@ -35,6 +45,7 @@ interface Props { export const ChartSection = injectI18n( ({ onChangeRangeTime, section, metric, intl, stopLiveStreaming, isLiveStreaming }: Props) => { const { visConfig } = section; + const [dateFormat] = useKibanaUiSetting('dateFormat'); const formatter = get(visConfig, 'formatter', InfraFormatterType.number); const formatterTemplate = get(visConfig, 'formatterTemplate', '{{value}}'); const valueFormatter = useCallback(getFormatter(formatter, formatterTemplate), [ @@ -57,6 +68,12 @@ export const ChartSection = injectI18n( }, [onChangeRangeTime, isLiveStreaming, stopLiveStreaming] ); + const tooltipProps = { + headerFormatter: useCallback( + (data: TooltipValue) => moment(data.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'), + [dateFormat] + ), + }; if (!metric) { return ( @@ -115,7 +132,11 @@ export const ChartSection = injectI18n( stack={visConfig.stacked} /> ))} - + diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart.tsx index d3fcd9671acf7..7cd8385172c55 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/chart.tsx @@ -7,7 +7,15 @@ import React, { useCallback, useMemo } from 'react'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { EuiTitle, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { Axis, Chart, getAxisId, niceTimeFormatter, Position, Settings } from '@elastic/charts'; +import { + Axis, + Chart, + getAxisId, + niceTimeFormatter, + Position, + Settings, + TooltipValue, +} from '@elastic/charts'; import { first, last } from 'lodash'; import moment from 'moment'; import { UICapabilities } from 'ui/capabilities'; @@ -27,6 +35,7 @@ import { SourceQuery } from '../../graphql/types'; import { MetricsExplorerEmptyChart } from './empty_chart'; import { MetricsExplorerNoMetrics } from './no_metrics'; import { getChartTheme } from './helpers/get_chart_theme'; +import { useKibanaUiSetting } from '../../utils/use_kibana_ui_setting'; import { calculateDomain } from './helpers/calculate_domain'; interface Props { @@ -60,6 +69,7 @@ export const MetricsExplorerChart = injectUICapabilities( uiCapabilities, }: Props) => { const { metrics } = options; + const [dateFormat] = useKibanaUiSetting('dateFormat'); const handleTimeChange = (from: number, to: number) => { onTimeChange(moment(from).toISOString(), moment(to).toISOString()); }; @@ -70,6 +80,12 @@ export const MetricsExplorerChart = injectUICapabilities( : (value: number) => `${value}`, [series.rows] ); + const tooltipProps = { + headerFormatter: useCallback( + (data: TooltipValue) => moment(data.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'), + [dateFormat] + ), + }; const yAxisFormater = useCallback(createFormatterForMetric(first(metrics)), [options]); const dataDomain = calculateDomain(series, metrics, chartOptions.stack); const domain = @@ -138,7 +154,11 @@ export const MetricsExplorerChart = injectUICapabilities( tickFormat={yAxisFormater} domain={domain} /> - + ) : options.metrics.length > 0 ? ( diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx index 9188bf7cadacb..77be28f8ca6ec 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/group_by.tsx @@ -7,14 +7,14 @@ import { EuiComboBox } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; -import { StaticIndexPatternField } from 'ui/index_patterns'; +import { FieldType } from 'ui/index_patterns'; import { MetricsExplorerOptions } from '../../containers/metrics_explorer/use_metrics_explorer_options'; interface Props { intl: InjectedIntl; options: MetricsExplorerOptions; onChange: (groupBy: string | null) => void; - fields: StaticIndexPatternField[]; + fields: FieldType[]; } export const MetricsExplorerGroupBy = injectI18n(({ intl, options, onChange, fields }: Props) => { diff --git a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx index cfe24b51113ef..1ba512ef08eab 100644 --- a/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx +++ b/x-pack/legacy/plugins/infra/public/components/metrics_explorer/metrics.tsx @@ -7,7 +7,7 @@ import { EuiComboBox } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { useCallback, useState, useEffect } from 'react'; -import { StaticIndexPatternField } from 'ui/index_patterns'; +import { FieldType } from 'ui/index_patterns'; import { colorTransformer, MetricsExplorerColor } from '../../../common/color_palette'; import { MetricsExplorerMetric, @@ -20,7 +20,7 @@ interface Props { autoFocus?: boolean; options: MetricsExplorerOptions; onChange: (metrics: MetricsExplorerMetric[]) => void; - fields: StaticIndexPatternField[]; + fields: FieldType[]; } interface SelectedOption { diff --git a/x-pack/legacy/plugins/infra/public/components/source_configuration/log_columns_configuration_panel.tsx b/x-pack/legacy/plugins/infra/public/components/source_configuration/log_columns_configuration_panel.tsx index bbc3cde41794c..53d426eba0449 100644 --- a/x-pack/legacy/plugins/infra/public/components/source_configuration/log_columns_configuration_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/source_configuration/log_columns_configuration_panel.tsx @@ -19,7 +19,8 @@ import { EuiDroppable, EuiIcon, } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { DragHandleProps, DropResult } from '../../../../../common/eui_draggable'; @@ -167,29 +168,35 @@ const FieldLogColumnConfigurationPanel: React.FunctionComponent<{ remove, }, dragHandleProps, -}) => ( - - - -
- -
-
- - - - - {field} - - - - -
-
-); +}) => { + const fieldLogColumnTitle = i18n.translate( + 'xpack.infra.sourceConfiguration.fieldLogColumnTitle', + { + defaultMessage: 'Field', + } + ); + return ( + + + +
+ +
+
+ {fieldLogColumnTitle} + + {field} + + + + +
+
+ ); +}; const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{ fieldName: React.ReactNode; @@ -213,23 +220,26 @@ const ExplainedLogColumnConfigurationPanel: React.FunctionComponent<{ - + ); -const RemoveLogColumnButton = injectI18n<{ +const RemoveLogColumnButton: React.FunctionComponent<{ onClick?: () => void; -}>(({ intl, onClick }) => { - const removeColumnLabel = intl.formatMessage({ - id: 'xpack.infra.sourceConfiguration.removeLogColumnButtonLabel', - defaultMessage: 'Remove this column', - }); + columnDescription: string; +}> = ({ onClick, columnDescription }) => { + const removeColumnLabel = i18n.translate( + 'xpack.infra.sourceConfiguration.removeLogColumnButtonLabel', + { + defaultMessage: 'Remove {columnDescription} column', + values: { columnDescription }, + } + ); return ( ); -}); +}; const LogColumnConfigurationEmptyPrompt: React.FunctionComponent = () => ( { + if (props.hidden) { + return props.children; + } + const propsWithoutHidden = omit(props, 'hidden'); + return {props.children}; +}; diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/custom_field_panel.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/custom_field_panel.tsx index 58444ab5e7d29..04748095b7b9f 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/custom_field_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/custom_field_panel.tsx @@ -7,10 +7,10 @@ import { EuiButton, EuiComboBox, EuiForm, EuiFormRow } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React from 'react'; -import { InfraIndexField } from '../../../server/graphql/types'; +import { FieldType } from 'ui/index_patterns'; interface Props { onSubmit: (field: string) => void; - fields: InfraIndexField[]; + fields: FieldType[]; intl: InjectedIntl; } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx index d10c8b6223774..b3845c570a9cd 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/node.tsx @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiToolTip } from '@elastic/eui'; import moment from 'moment'; import { darken, readableColor } from 'polished'; import React from 'react'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { ConditionalToolTip } from './conditional_tooltip'; import euiStyled from '../../../../../common/eui_styled_components'; import { InfraTimerangeInput, InfraNodeType } from '../../graphql/types'; import { InfraWaffleMapBounds, InfraWaffleMapNode, InfraWaffleMapOptions } from '../../lib/lib'; @@ -75,9 +75,14 @@ export const Node = injectI18n( closePopover={this.closePopover} options={options} timeRange={newTimerange} - popoverPosition="upCenter" + popoverPosition="downCenter" > - + + ); } diff --git a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx index 24eacf840fd69..e39099899c416 100644 --- a/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/waffle/waffle_group_by_controls.tsx @@ -15,7 +15,8 @@ import { } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React from 'react'; -import { InfraIndexField, InfraNodeType, InfraSnapshotGroupbyInput } from '../../graphql/types'; +import { FieldType } from 'ui/index_patterns'; +import { InfraNodeType, InfraSnapshotGroupbyInput } from '../../graphql/types'; import { InfraGroupByOptions } from '../../lib/lib'; import { CustomFieldPanel } from './custom_field_panel'; import { fieldToName } from './lib/field_to_display_name'; @@ -26,7 +27,7 @@ interface Props { groupBy: InfraSnapshotGroupbyInput[]; onChange: (groupBy: InfraSnapshotGroupbyInput[]) => void; onChangeCustomOptions: (options: InfraGroupByOptions[]) => void; - fields: InfraIndexField[]; + fields: FieldType[]; intl: InjectedIntl; customOptions: InfraGroupByOptions[]; } diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/lib/get_filtered_layouts.ts b/x-pack/legacy/plugins/infra/public/containers/metadata/lib/get_filtered_layouts.ts new file mode 100644 index 0000000000000..a543365aa68d5 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/metadata/lib/get_filtered_layouts.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { InfraMetadataFeature } from '../../../../common/http_api/metadata_api'; +import { InfraMetricLayout } from '../../../pages/metrics/layouts/types'; + +export const getFilteredLayouts = ( + layouts: InfraMetricLayout[], + metadata: Array | undefined +): InfraMetricLayout[] => { + if (!metadata) { + return layouts; + } + + const metricMetadata: Array = metadata + .filter(data => data && data.source === 'metrics') + .map(data => data && data.name); + + // After filtering out sections that can't be displayed, a layout may end up empty and can be removed. + const filteredLayouts = layouts + .map(layout => getFilteredLayout(layout, metricMetadata)) + .filter(layout => layout.sections.length > 0); + return filteredLayouts; +}; + +export const getFilteredLayout = ( + layout: InfraMetricLayout, + metricMetadata: Array +): InfraMetricLayout => { + // A section is only displayed if at least one of its requirements is met + // All others are filtered out. + const filteredSections = layout.sections.filter( + section => _.intersection(section.requires, metricMetadata).length > 0 + ); + return { ...layout, sections: filteredSections }; +}; diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/metadata.gql_query.ts b/x-pack/legacy/plugins/infra/public/containers/metadata/metadata.gql_query.ts deleted file mode 100644 index 9a59cfcbee9ec..0000000000000 --- a/x-pack/legacy/plugins/infra/public/containers/metadata/metadata.gql_query.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const metadataQuery = gql` - query MetadataQuery($sourceId: ID!, $nodeId: String!, $nodeType: InfraNodeType!) { - source(id: $sourceId) { - id - metadataByNode(nodeId: $nodeId, nodeType: $nodeType) { - name - features { - name - source - } - } - } - } -`; diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts new file mode 100644 index 0000000000000..941c7532d5b29 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/containers/metadata/use_metadata.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect } from 'react'; +import { InfraNodeType } from '../../graphql/types'; +import { InfraMetricLayout } from '../../pages/metrics/layouts/types'; +import { InfraMetadata, InfraMetadataRT } from '../../../common/http_api/metadata_api'; +import { getFilteredLayouts } from './lib/get_filtered_layouts'; +import { useHTTPRequest } from '../../hooks/use_http_request'; +import { throwErrors, createPlainError } from '../../../common/runtime_types'; + +export function useMetadata( + nodeId: string, + nodeType: InfraNodeType, + layouts: InfraMetricLayout[], + sourceId: string +) { + const decodeResponse = (response: any) => { + return InfraMetadataRT.decode(response).getOrElseL(throwErrors(createPlainError)); + }; + + const { error, loading, response, makeRequest } = useHTTPRequest( + '/api/infra/metadata', + 'POST', + JSON.stringify({ + nodeId, + nodeType, + sourceId, + decodeResponse, + }) + ); + + useEffect(() => { + (async () => { + await makeRequest(); + })(); + }, [makeRequest]); + + return { + name: (response && response.name) || '', + filteredLayouts: (response && getFilteredLayouts(layouts, response.features)) || [], + error: (error && error.message) || null, + loading, + }; +} diff --git a/x-pack/legacy/plugins/infra/public/containers/metadata/with_metadata.tsx b/x-pack/legacy/plugins/infra/public/containers/metadata/with_metadata.tsx deleted file mode 100644 index 1950ecf436539..0000000000000 --- a/x-pack/legacy/plugins/infra/public/containers/metadata/with_metadata.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React from 'react'; -import { Query } from 'react-apollo'; - -import { InfraNodeType, MetadataQuery } from '../../graphql/types'; -import { InfraMetricLayout } from '../../pages/metrics/layouts/types'; -import { metadataQuery } from './metadata.gql_query'; - -interface WithMetadataProps { - children: (args: WithMetadataArgs) => React.ReactNode; - layouts: InfraMetricLayout[]; - nodeType: InfraNodeType; - nodeId: string; - sourceId: string; -} - -interface WithMetadataArgs { - name: string; - filteredLayouts: InfraMetricLayout[]; - error?: string | undefined; - loading: boolean; -} - -export const WithMetadata = ({ - children, - layouts, - nodeType, - nodeId, - sourceId, -}: WithMetadataProps) => { - return ( - - query={metadataQuery} - fetchPolicy="no-cache" - variables={{ - sourceId, - nodeType, - nodeId, - }} - > - {({ data, error, loading }) => { - const metadata = data && data.source && data.source.metadataByNode; - const filteredLayouts = (metadata && getFilteredLayouts(layouts, metadata.features)) || []; - return children({ - name: (metadata && metadata.name) || '', - filteredLayouts, - error: error && error.message, - loading, - }); - }} - - ); -}; - -const getFilteredLayouts = ( - layouts: InfraMetricLayout[], - metadata: Array | undefined -): InfraMetricLayout[] => { - if (!metadata) { - return layouts; - } - - const metricMetadata: Array = metadata - .filter(data => data && data.source === 'metrics') - .map(data => data && data.name); - - // After filtering out sections that can't be displayed, a layout may end up empty and can be removed. - const filteredLayouts = layouts - .map(layout => getFilteredLayout(layout, metricMetadata)) - .filter(layout => layout.sections.length > 0); - return filteredLayouts; -}; - -const getFilteredLayout = ( - layout: InfraMetricLayout, - metricMetadata: Array -): InfraMetricLayout => { - // A section is only displayed if at least one of its requirements is met - // All others are filtered out. - const filteredSections = layout.sections.filter( - section => _.intersection(section.requires, metricMetadata).length > 0 - ); - return { ...layout, sections: filteredSections }; -}; diff --git a/x-pack/legacy/plugins/infra/public/containers/source/source.tsx b/x-pack/legacy/plugins/infra/public/containers/source/source.tsx index 9632a5bda0792..8b931545a5c6c 100644 --- a/x-pack/legacy/plugins/infra/public/containers/source/source.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/source/source.tsx @@ -21,6 +21,19 @@ import { updateSourceMutation } from './update_source.gql_query'; type Source = SourceQuery.Query['source']; +const pickIndexPattern = (source: Source | undefined, type: 'logs' | 'metrics' | 'both') => { + if (!source) { + return 'unknown-index'; + } + if (type === 'logs') { + return source.configuration.logAlias; + } + if (type === 'metrics') { + return source.configuration.metricAlias; + } + return `${source.configuration.logAlias},${source.configuration.metricAlias}`; +}; + export const useSource = ({ sourceId }: { sourceId: string }) => { const apolloClient = useApolloClient(); const [source, setSource] = useState(undefined); @@ -108,13 +121,12 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { [apolloClient, sourceId] ); - const derivedIndexPattern = useMemo( - () => ({ + const createDerivedIndexPattern = (type: 'logs' | 'metrics' | 'both') => { + return { fields: source ? source.status.indexFields : [], - title: source ? `${source.configuration.logAlias}` : 'unknown-index', - }), - [source] - ); + title: pickIndexPattern(source, type), + }; + }; const isLoading = useMemo( () => @@ -146,7 +158,7 @@ export const useSource = ({ sourceId }: { sourceId: string }) => { return { createSourceConfiguration, - derivedIndexPattern, + createDerivedIndexPattern, logIndicesExist, isLoading, isLoadingSource: loadSourceRequest.state === 'pending', diff --git a/x-pack/legacy/plugins/infra/public/containers/with_source/with_source.tsx b/x-pack/legacy/plugins/infra/public/containers/with_source/with_source.tsx index 3ed3852484e71..0512888ecd4ea 100644 --- a/x-pack/legacy/plugins/infra/public/containers/with_source/with_source.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/with_source/with_source.tsx @@ -15,7 +15,7 @@ interface WithSourceProps { children: RendererFunction<{ configuration?: SourceQuery.Query['source']['configuration']; create: (sourceProperties: UpdateSourceInput) => Promise | undefined; - derivedIndexPattern: StaticIndexPattern; + createDerivedIndexPattern: (type: 'logs' | 'metrics' | 'both') => StaticIndexPattern; exists?: boolean; hasFailed: boolean; isLoading: boolean; @@ -33,7 +33,7 @@ interface WithSourceProps { export const WithSource: React.FunctionComponent = ({ children }) => { const { createSourceConfiguration, - derivedIndexPattern, + createDerivedIndexPattern, source, sourceExists, sourceId, @@ -50,7 +50,7 @@ export const WithSource: React.FunctionComponent = ({ children return children({ create: createSourceConfiguration, configuration: source && source.configuration, - derivedIndexPattern, + createDerivedIndexPattern, exists: sourceExists, hasFailed: hasFailedLoadingSource, isLoading, diff --git a/x-pack/legacy/plugins/infra/public/graphql/introspection.json b/x-pack/legacy/plugins/infra/public/graphql/introspection.json index c937d4f365e62..055fac61cb93d 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/infra/public/graphql/introspection.json @@ -137,39 +137,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "metadataByNode", - "description": "A hierarchy of metadata entries by node", - "args": [ - { - "name": "nodeId", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "defaultValue": null - }, - { - "name": "nodeType", - "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "ENUM", "name": "InfraNodeType", "ofType": null } - }, - "defaultValue": null - } - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraNodeMetadata", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "logEntriesAround", "description": "A consecutive span of log entries surrounding a point in time", @@ -1086,115 +1053,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "ENUM", - "name": "InfraNodeType", - "description": "", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": [ - { "name": "pod", "description": "", "isDeprecated": false, "deprecationReason": null }, - { - "name": "container", - "description": "", - "isDeprecated": false, - "deprecationReason": null - }, - { "name": "host", "description": "", "isDeprecated": false, "deprecationReason": null } - ], - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraNodeMetadata", - "description": "One metadata entry for a node.", - "fields": [ - { - "name": "id", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "ID", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "name", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "features", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "OBJECT", "name": "InfraNodeFeature", "ofType": null } - } - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "InfraNodeFeature", - "description": "", - "fields": [ - { - "name": "name", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "source", - "description": "", - "args": [], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [], - "enumValues": null, - "possibleTypes": null - }, { "kind": "INPUT_OBJECT", "name": "InfraTimeKeyInput", @@ -2039,6 +1897,25 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "InfraNodeType", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { "name": "pod", "description": "", "isDeprecated": false, "deprecationReason": null }, + { + "name": "container", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "host", "description": "", "isDeprecated": false, "deprecationReason": null } + ], + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "InfraSnapshotGroupbyInput", diff --git a/x-pack/legacy/plugins/infra/public/graphql/types.ts b/x-pack/legacy/plugins/infra/public/graphql/types.ts index 2da829dbf2936..7843a54b93bed 100644 --- a/x-pack/legacy/plugins/infra/public/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/public/graphql/types.ts @@ -28,8 +28,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A hierarchy of metadata entries by node */ - metadataByNode: InfraNodeMetadata; /** A consecutive span of log entries surrounding a point in time */ logEntriesAround: InfraLogEntryInterval; /** A consecutive span of log entries within an interval */ @@ -132,20 +130,6 @@ export interface InfraIndexField { /** Whether the field's values can be aggregated */ aggregatable: boolean; } -/** One metadata entry for a node. */ -export interface InfraNodeMetadata { - id: string; - - name: string; - - features: InfraNodeFeature[]; -} - -export interface InfraNodeFeature { - name: string; - - source: string; -} /** A consecutive sequence of log entries */ export interface InfraLogEntryInterval { /** The key corresponding to the start of the interval covered by the entries */ @@ -424,11 +408,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface MetadataByNodeInfraSourceArgs { - nodeId: string; - - nodeType: InfraNodeType; -} export interface LogEntriesAroundInfraSourceArgs { /** The sort key that corresponds to the point in time */ key: InfraTimeKeyInput; @@ -722,44 +701,6 @@ export namespace LogSummary { }; } -export namespace MetadataQuery { - export type Variables = { - sourceId: string; - nodeId: string; - nodeType: InfraNodeType; - }; - - export type Query = { - __typename?: 'Query'; - - source: Source; - }; - - export type Source = { - __typename?: 'InfraSource'; - - id: string; - - metadataByNode: MetadataByNode; - }; - - export type MetadataByNode = { - __typename?: 'InfraNodeMetadata'; - - name: string; - - features: Features[]; - }; - - export type Features = { - __typename?: 'InfraNodeFeature'; - - name: string; - - source: string; - }; -} - export namespace MetricsQuery { export type Variables = { sourceId: string; diff --git a/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx b/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx new file mode 100644 index 0000000000000..606f7d0aecdc0 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/hooks/use_http_request.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useState } from 'react'; +import { kfetch } from 'ui/kfetch'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { idx } from '@kbn/elastic-idx/target'; +import { KFetchError } from 'ui/kfetch/kfetch_error'; +import { useTrackedPromise } from '../utils/use_tracked_promise'; +export function useHTTPRequest( + pathname: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD', + body?: string, + decode: (response: any) => Response = response => response +) { + const [response, setResponse] = useState(null); + const [error, setError] = useState(null); + const [request, makeRequest] = useTrackedPromise( + { + cancelPreviousOn: 'resolution', + createPromise: () => + kfetch({ + method, + pathname, + body, + }), + onResolve: resp => setResponse(decode(resp)), + onReject: (e: unknown) => { + const err = e as KFetchError; + setError(err); + toastNotifications.addWarning({ + title: i18n.translate('xpack.infra.useHTTPRequest.error.title', { + defaultMessage: `Error while fetching resource`, + }), + text: ( +
+
+ {i18n.translate('xpack.infra.useHTTPRequest.error.status', { + defaultMessage: `Error`, + })} +
+ {idx(err.res, r => r.statusText)} ({idx(err.res, r => r.status)}) +
+ {i18n.translate('xpack.infra.useHTTPRequest.error.url', { + defaultMessage: `URL`, + })} +
+ {idx(err.res, r => r.url)} +
+ ), + }); + }, + }, + [pathname, body, method] + ); + + const loading = useMemo(() => request.state === 'pending', [request.state]); + + return { + response, + error, + loading, + makeRequest, + }; +} diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx index 11b5799fe7286..3cc40be7e5fe0 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/index.tsx @@ -68,11 +68,11 @@ export const InfrastructurePage = injectI18n(({ match, intl }: InfrastructurePag path={`${match.path}/metrics-explorer`} render={props => ( - {({ configuration, derivedIndexPattern }) => ( + {({ configuration, createDerivedIndexPattern }) => ( diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/index.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/index.tsx index 5c28715e4e538..9b4efa44d483a 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/index.tsx @@ -38,7 +38,7 @@ export const SnapshotPage = injectUICapabilities( const { intl, uiCapabilities } = props; const { showIndicesConfiguration } = useContext(SourceConfigurationFlyoutState.Context); const { - derivedIndexPattern, + createDerivedIndexPattern, hasFailedLoadingSource, isLoading, loadSourceFailureMessage, @@ -84,7 +84,7 @@ export const SnapshotPage = injectUICapabilities( ) : metricIndicesExist ? ( <> - + diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx index a47b82d17d77e..f6c9bb27e36c0 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/page_content.tsx @@ -19,10 +19,10 @@ import { WithSource } from '../../../containers/with_source'; export const SnapshotPageContent: React.SFC = () => ( - {({ configuration, derivedIndexPattern, sourceId }) => ( + {({ configuration, createDerivedIndexPattern, sourceId }) => ( {({ wafflemap }) => ( - + {({ filterQueryAsJson, applyFilterQuery }) => ( {({ currentTimeRange, isAutoReloading }) => ( diff --git a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx index e70e79a18a66a..0d3fda093b223 100644 --- a/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/infrastructure/snapshot/toolbar.tsx @@ -26,10 +26,10 @@ export const SnapshotToolbar = injectI18n(({ intl }) => ( - {({ derivedIndexPattern }) => ( - + {({ createDerivedIndexPattern }) => ( + {({ isLoadingSuggestions, loadSuggestions, suggestions }) => ( - + {({ applyFilterQueryFromKueryExpression, filterQueryDraft, @@ -73,7 +73,7 @@ export const SnapshotToolbar = injectI18n(({ intl }) => ( - {({ derivedIndexPattern }) => ( + {({ createDerivedIndexPattern }) => ( {({ changeMetric, @@ -106,7 +106,7 @@ export const SnapshotToolbar = injectI18n(({ intl }) => ( groupBy={groupBy} nodeType={nodeType} onChange={changeGroupBy} - fields={derivedIndexPattern.fields} + fields={createDerivedIndexPattern('metrics').fields} onChangeCustomOptions={changeCustomOptions} customOptions={customOptions} /> diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx index 7f4b52604e469..007584d4cacc8 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/page_logs_content.tsx @@ -32,7 +32,7 @@ import { SourceConfigurationFlyoutState } from '../../components/source_configur import { LogHighlightsBridge } from '../../containers/logs/log_highlights'; export const LogsPageLogsContent: React.FunctionComponent = () => { - const { derivedIndexPattern, source, sourceId, version } = useContext(Source.Context); + const { createDerivedIndexPattern, source, sourceId, version } = useContext(Source.Context); const { intervalSize, textScale, textWrap } = useContext(LogViewConfiguration.Context); const { setFlyoutVisibility, @@ -45,6 +45,8 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { } = useContext(LogFlyoutState.Context); const { showLogsConfiguration } = useContext(SourceConfigurationFlyoutState.Context); + const derivedIndexPattern = createDerivedIndexPattern('logs'); + return ( <> diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx index 2255cbff3d0cd..8a132fd9e0c71 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/page_toolbar.tsx @@ -26,7 +26,8 @@ import { Source } from '../../containers/source'; import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion'; export const LogsToolbar = injectI18n(({ intl }) => { - const { derivedIndexPattern } = useContext(Source.Context); + const { createDerivedIndexPattern } = useContext(Source.Context); + const derivedIndexPattern = createDerivedIndexPattern('logs'); const { availableIntervalSizes, availableTextScales, @@ -80,6 +81,10 @@ export const LogsToolbar = injectI18n(({ intl }) => { })} suggestions={suggestions} value={filterQueryDraft ? filterQueryDraft.expression : ''} + aria-label={intl.formatMessage({ + id: 'xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel', + defaultMessage: 'Search for log entries', + })} /> )} diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx index 1929b3351acb7..54cd1542bc7cc 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/index.tsx @@ -15,7 +15,7 @@ import { } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { GraphQLFormattedError } from 'graphql'; -import React from 'react'; +import React, { useCallback, useContext } from 'react'; import { UICapabilities } from 'ui/capabilities'; import { injectUICapabilities } from 'ui/capabilities/react'; import euiStyled, { EuiTheme, withTheme } from '../../../../../common/eui_styled_components'; @@ -29,18 +29,18 @@ import { MetricsSideNav } from '../../components/metrics/side_nav'; import { MetricsTimeControls } from '../../components/metrics/time_controls'; import { ColumnarPage, PageContent } from '../../components/page'; import { SourceConfigurationFlyout } from '../../components/source_configuration'; -import { WithMetadata } from '../../containers/metadata/with_metadata'; import { WithMetrics } from '../../containers/metrics/with_metrics'; import { WithMetricsTime, WithMetricsTimeUrlState, } from '../../containers/metrics/with_metrics_time'; -import { WithSource } from '../../containers/with_source'; import { InfraNodeType, InfraTimerangeInput } from '../../graphql/types'; import { Error, ErrorPageBody } from '../error'; import { layoutCreators } from './layouts'; import { InfraMetricLayoutSection } from './layouts/types'; -import { MetricDetailPageProviders } from './page_providers'; +import { withMetricPageProviders } from './page_providers'; +import { useMetadata } from '../../containers/metadata/use_metadata'; +import { Source } from '../../containers/source'; const DetailPageContent = euiStyled(PageContent)` overflow: auto; @@ -63,206 +63,189 @@ interface Props { uiCapabilities: UICapabilities; } -export const MetricDetail = injectUICapabilities( - withTheme( - injectI18n( - class extends React.PureComponent { - public static displayName = 'MetricDetailPage'; +export const MetricDetail = withMetricPageProviders( + injectUICapabilities( + withTheme( + injectI18n(({ intl, uiCapabilities, match, theme }: Props) => { + const nodeId = match.params.node; + const nodeType = match.params.type as InfraNodeType; + const layoutCreator = layoutCreators[nodeType]; + if (!layoutCreator) { + return ( + + ); + } + const { sourceId } = useContext(Source.Context); + const layouts = layoutCreator(theme); + const { name, filteredLayouts, loading: metadataLoading } = useMetadata( + nodeId, + nodeType, + layouts, + sourceId + ); + const breadcrumbs = [ + { + href: '#/', + text: intl.formatMessage({ + id: 'xpack.infra.header.infrastructureTitle', + defaultMessage: 'Infrastructure', + }), + }, + { text: name }, + ]; - public render() { - const { intl, uiCapabilities } = this.props; - const nodeId = this.props.match.params.node; - const nodeType = this.props.match.params.type as InfraNodeType; - const layoutCreator = layoutCreators[nodeType]; - if (!layoutCreator) { - return ( - - ); - } - const layouts = layoutCreator(this.props.theme); + const handleClick = useCallback( + (section: InfraMetricLayoutSection) => () => { + const id = section.linkToId || section.id; + const el = document.getElementById(id); + if (el) { + el.scrollIntoView(); + } + }, + [] + ); - return ( - - - {({ sourceId }) => ( - - {({ - timeRange, - setTimeRange, - refreshInterval, - setRefreshInterval, - isAutoReloading, - setAutoReload, - }) => ( - - {({ name, filteredLayouts, loading: metadataLoading }) => { - const breadcrumbs = [ - { - href: '#/', - text: intl.formatMessage({ - id: 'xpack.infra.header.infrastructureTitle', - defaultMessage: 'Infrastructure', - }), - }, - { text: name }, - ]; - return ( - -
- - - + {({ + timeRange, + setTimeRange, + refreshInterval, + setRefreshInterval, + isAutoReloading, + setAutoReload, + }) => ( + +
+ + + + + + {({ metrics, error, loading, refetch }) => { + if (error) { + const invalidNodeError = error.graphQLErrors.some( + (err: GraphQLFormattedError) => + err.code === InfraMetricsErrorCodes.invalid_node + ); + + return ( + <> + + intl.formatMessage( { - id: 'xpack.infra.metricDetailPage.documentTitle', - defaultMessage: 'Infrastructure | Metrics | {name}', + id: 'xpack.infra.metricDetailPage.documentTitleError', + defaultMessage: '{previousTitle} | Uh oh', }, { - name, + previousTitle, } - )} - /> - - - {({ metrics, error, loading, refetch }) => { - if (error) { - const invalidNodeError = error.graphQLErrors.some( - (err: GraphQLFormattedError) => - err.code === InfraMetricsErrorCodes.invalid_node - ); - - return ( - <> - - intl.formatMessage( - { - id: - 'xpack.infra.metricDetailPage.documentTitleError', - defaultMessage: '{previousTitle} | Uh oh', - }, - { - previousTitle, - } - ) - } + ) + } + /> + {invalidNodeError ? ( + + ) : ( + + )} + + ); + } + return ( + + + + {({ measureRef, bounds: { width = 0 } }) => { + return ( + + + + + + + +

{name}

+
+
+ - {invalidNodeError ? ( - - ) : ( - - )} - - ); - } - return ( - - - - {({ measureRef, bounds: { width = 0 } }) => { - return ( - - - - - - - -

{name}

-
-
- -
-
-
- - - 0 && isAutoReloading - ? false - : loading - } - refetch={refetch} - onChangeRangeTime={setTimeRange} - isLiveStreaming={isAutoReloading} - stopLiveStreaming={() => setAutoReload(false)} - /> - -
-
- ); - }} -
-
- ); - }} -
-
- - ); - }} - - )} - - )} - - - ); - } + + + - private handleClick = (section: InfraMetricLayoutSection) => () => { - const id = section.linkToId || section.id; - const el = document.getElementById(id); - if (el) { - el.scrollIntoView(); - } - }; - } + + 0 && isAutoReloading ? false : loading + } + refetch={refetch} + onChangeRangeTime={setTimeRange} + isLiveStreaming={isAutoReloading} + stopLiveStreaming={() => setAutoReload(false)} + /> + + + + ); + }} + + + ); + }} +
+
+ + )} + + ); + }) ) ) ); diff --git a/x-pack/legacy/plugins/infra/public/pages/metrics/page_providers.tsx b/x-pack/legacy/plugins/infra/public/pages/metrics/page_providers.tsx index 49f07837024fc..6d45f52d541b9 100644 --- a/x-pack/legacy/plugins/infra/public/pages/metrics/page_providers.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/metrics/page_providers.tsx @@ -10,10 +10,14 @@ import { SourceConfigurationFlyoutState } from '../../components/source_configur import { MetricsTimeContainer } from '../../containers/metrics/with_metrics_time'; import { Source } from '../../containers/source'; -export const MetricDetailPageProviders: React.FunctionComponent = ({ children }) => ( +export const withMetricPageProviders = (Component: React.ComponentType) => ( + props: T +) => ( - {children} + + + ); diff --git a/x-pack/legacy/plugins/infra/server/graphql/index.ts b/x-pack/legacy/plugins/infra/server/graphql/index.ts index 552076c05e8e8..81400b74f0539 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/index.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/index.ts @@ -7,7 +7,6 @@ import { rootSchema } from '../../common/graphql/root/schema.gql'; import { sharedSchema } from '../../common/graphql/shared/schema.gql'; import { logEntriesSchema } from './log_entries/schema.gql'; -import { metadataSchema } from './metadata/schema.gql'; import { metricsSchema } from './metrics/schema.gql'; import { snapshotSchema } from './snapshot/schema.gql'; import { sourceStatusSchema } from './source_status/schema.gql'; @@ -16,7 +15,6 @@ import { sourcesSchema } from './sources/schema.gql'; export const schemas = [ rootSchema, sharedSchema, - metadataSchema, logEntriesSchema, snapshotSchema, sourcesSchema, diff --git a/x-pack/legacy/plugins/infra/server/graphql/metadata/resolvers.ts b/x-pack/legacy/plugins/infra/server/graphql/metadata/resolvers.ts deleted file mode 100644 index 8d1b386af548e..0000000000000 --- a/x-pack/legacy/plugins/infra/server/graphql/metadata/resolvers.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { InfraSourceResolvers } from '../../graphql/types'; -import { InfraMetadataDomain } from '../../lib/domains/metadata_domain'; -import { ChildResolverOf, InfraResolverOf } from '../../utils/typed_resolvers'; -import { QuerySourceResolver } from '../sources/resolvers'; - -type InfraSourceMetadataByNodeResolver = ChildResolverOf< - InfraResolverOf, - QuerySourceResolver ->; - -export const createMetadataResolvers = (libs: { - metadata: InfraMetadataDomain; -}): { - InfraSource: { - metadataByNode: InfraSourceMetadataByNodeResolver; - }; -} => ({ - InfraSource: { - async metadataByNode(source, args, { req }) { - const result = await libs.metadata.getMetadata(req, source.id, args.nodeId, args.nodeType); - return result; - }, - }, -}); diff --git a/x-pack/legacy/plugins/infra/server/graphql/metadata/schema.gql.ts b/x-pack/legacy/plugins/infra/server/graphql/metadata/schema.gql.ts deleted file mode 100644 index 8944e309d463d..0000000000000 --- a/x-pack/legacy/plugins/infra/server/graphql/metadata/schema.gql.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import gql from 'graphql-tag'; - -export const metadataSchema = gql` - "One metadata entry for a node." - type InfraNodeMetadata { - id: ID! - name: String! - features: [InfraNodeFeature!]! - } - - type InfraNodeFeature { - name: String! - source: String! - } - - extend type InfraSource { - "A hierarchy of metadata entries by node" - metadataByNode(nodeId: String!, nodeType: InfraNodeType!): InfraNodeMetadata! - } -`; diff --git a/x-pack/legacy/plugins/infra/server/graphql/types.ts b/x-pack/legacy/plugins/infra/server/graphql/types.ts index 619166b8b8596..e223ac7b334a2 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/types.ts +++ b/x-pack/legacy/plugins/infra/server/graphql/types.ts @@ -56,8 +56,6 @@ export interface InfraSource { configuration: InfraSourceConfiguration; /** The status of the source */ status: InfraSourceStatus; - /** A hierarchy of metadata entries by node */ - metadataByNode: InfraNodeMetadata; /** A consecutive span of log entries surrounding a point in time */ logEntriesAround: InfraLogEntryInterval; /** A consecutive span of log entries within an interval */ @@ -160,20 +158,6 @@ export interface InfraIndexField { /** Whether the field's values can be aggregated */ aggregatable: boolean; } -/** One metadata entry for a node. */ -export interface InfraNodeMetadata { - id: string; - - name: string; - - features: InfraNodeFeature[]; -} - -export interface InfraNodeFeature { - name: string; - - source: string; -} /** A consecutive sequence of log entries */ export interface InfraLogEntryInterval { /** The key corresponding to the start of the interval covered by the entries */ @@ -452,11 +436,6 @@ export interface SourceQueryArgs { /** The id of the source */ id: string; } -export interface MetadataByNodeInfraSourceArgs { - nodeId: string; - - nodeType: InfraNodeType; -} export interface LogEntriesAroundInfraSourceArgs { /** The sort key that corresponds to the point in time */ key: InfraTimeKeyInput; @@ -663,8 +642,6 @@ export namespace InfraSourceResolvers { configuration?: ConfigurationResolver; /** The status of the source */ status?: StatusResolver; - /** A hierarchy of metadata entries by node */ - metadataByNode?: MetadataByNodeResolver; /** A consecutive span of log entries surrounding a point in time */ logEntriesAround?: LogEntriesAroundResolver; /** A consecutive span of log entries within an interval */ @@ -711,17 +688,6 @@ export namespace InfraSourceResolvers { Parent = InfraSource, Context = InfraContext > = Resolver; - export type MetadataByNodeResolver< - R = InfraNodeMetadata, - Parent = InfraSource, - Context = InfraContext - > = Resolver; - export interface MetadataByNodeArgs { - nodeId: string; - - nodeType: InfraNodeType; - } - export type LogEntriesAroundResolver< R = InfraLogEntryInterval, Parent = InfraSource, @@ -1106,51 +1072,6 @@ export namespace InfraIndexFieldResolvers { Context = InfraContext > = Resolver; } -/** One metadata entry for a node. */ -export namespace InfraNodeMetadataResolvers { - export interface Resolvers { - id?: IdResolver; - - name?: NameResolver; - - features?: FeaturesResolver; - } - - export type IdResolver = Resolver< - R, - Parent, - Context - >; - export type NameResolver< - R = string, - Parent = InfraNodeMetadata, - Context = InfraContext - > = Resolver; - export type FeaturesResolver< - R = InfraNodeFeature[], - Parent = InfraNodeMetadata, - Context = InfraContext - > = Resolver; -} - -export namespace InfraNodeFeatureResolvers { - export interface Resolvers { - name?: NameResolver; - - source?: SourceResolver; - } - - export type NameResolver< - R = string, - Parent = InfraNodeFeature, - Context = InfraContext - > = Resolver; - export type SourceResolver< - R = string, - Parent = InfraNodeFeature, - Context = InfraContext - > = Resolver; -} /** A consecutive sequence of log entries */ export namespace InfraLogEntryIntervalResolvers { export interface Resolvers { diff --git a/x-pack/legacy/plugins/infra/server/infra_server.ts b/x-pack/legacy/plugins/infra/server/infra_server.ts index 25303a1a29de2..02a1d14152a2a 100644 --- a/x-pack/legacy/plugins/infra/server/infra_server.ts +++ b/x-pack/legacy/plugins/infra/server/infra_server.ts @@ -8,7 +8,6 @@ import { IResolvers, makeExecutableSchema } from 'graphql-tools'; import { initIpToHostName } from './routes/ip_to_hostname'; import { schemas } from './graphql'; import { createLogEntriesResolvers } from './graphql/log_entries'; -import { createMetadataResolvers } from './graphql/metadata'; import { createMetricResolvers } from './graphql/metrics/resolvers'; import { createSnapshotResolvers } from './graphql/snapshot'; import { createSourceStatusResolvers } from './graphql/source_status'; @@ -16,11 +15,11 @@ import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; import { initLegacyLoggingRoutes } from './logging_legacy'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; +import { initMetadataRoute } from './routes/metadata'; export const initInfraServer = (libs: InfraBackendLibs) => { const schema = makeExecutableSchema({ resolvers: [ - createMetadataResolvers(libs) as IResolvers, createLogEntriesResolvers(libs) as IResolvers, createSnapshotResolvers(libs) as IResolvers, createSourcesResolvers(libs) as IResolvers, @@ -35,4 +34,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initLegacyLoggingRoutes(libs.framework); initIpToHostName(libs); initMetricExplorerRoute(libs); + initMetadataRoute(libs); }; diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/adapter_types.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/adapter_types.ts deleted file mode 100644 index 3e44120a6c5c4..0000000000000 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/adapter_types.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { InfraSourceConfiguration } from '../../sources'; -import { InfraFrameworkRequest, InfraMetadataAggregationBucket } from '../framework'; - -export interface InfraMetricsAdapterResponse { - id: string; - name?: string; - buckets: InfraMetadataAggregationBucket[]; -} - -export interface InfraMetadataAdapter { - getMetricMetadata( - req: InfraFrameworkRequest, - sourceConfiguration: InfraSourceConfiguration, - nodeId: string, - nodeType: string - ): Promise; -} diff --git a/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/elasticsearch_metadata_adapter.ts b/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/elasticsearch_metadata_adapter.ts deleted file mode 100644 index d23b9b9bb142e..0000000000000 --- a/x-pack/legacy/plugins/infra/server/lib/adapters/metadata/elasticsearch_metadata_adapter.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { get } from 'lodash'; -import { InfraSourceConfiguration } from '../../sources'; -import { - InfraBackendFrameworkAdapter, - InfraFrameworkRequest, - InfraMetadataAggregationResponse, -} from '../framework'; -import { InfraMetadataAdapter, InfraMetricsAdapterResponse } from './adapter_types'; -import { NAME_FIELDS } from '../../constants'; - -export class ElasticsearchMetadataAdapter implements InfraMetadataAdapter { - private framework: InfraBackendFrameworkAdapter; - constructor(framework: InfraBackendFrameworkAdapter) { - this.framework = framework; - } - - public async getMetricMetadata( - req: InfraFrameworkRequest, - sourceConfiguration: InfraSourceConfiguration, - nodeId: string, - nodeType: 'host' | 'container' | 'pod' - ): Promise { - const idFieldName = getIdFieldName(sourceConfiguration, nodeType); - const metricQuery = { - allowNoIndices: true, - ignoreUnavailable: true, - index: sourceConfiguration.metricAlias, - body: { - query: { - bool: { - filter: { - term: { [idFieldName]: nodeId }, - }, - }, - }, - size: 0, - aggs: { - nodeName: { - terms: { - field: NAME_FIELDS[nodeType], - size: 1, - }, - }, - metrics: { - terms: { - field: 'event.dataset', - size: 1000, - }, - }, - }, - }, - }; - - const response = await this.framework.callWithRequest< - any, - { metrics?: InfraMetadataAggregationResponse; nodeName?: InfraMetadataAggregationResponse } - >(req, 'search', metricQuery); - - const buckets = - response.aggregations && response.aggregations.metrics - ? response.aggregations.metrics.buckets - : []; - - return { - id: nodeId, - name: get(response, ['aggregations', 'nodeName', 'buckets', 0, 'key'], nodeId), - buckets, - }; - } -} - -const getIdFieldName = (sourceConfiguration: InfraSourceConfiguration, nodeType: string) => { - switch (nodeType) { - case 'host': - return sourceConfiguration.fields.host; - case 'container': - return sourceConfiguration.fields.container; - default: - return sourceConfiguration.fields.pod; - } -}; diff --git a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts index f1691c9f3c7dd..91f049a08ec96 100644 --- a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts @@ -10,12 +10,10 @@ import { InfraKibanaConfigurationAdapter } from '../adapters/configuration/kiban import { FrameworkFieldsAdapter } from '../adapters/fields/framework_fields_adapter'; import { InfraKibanaBackendFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter'; import { InfraKibanaLogEntriesAdapter } from '../adapters/log_entries/kibana_log_entries_adapter'; -import { ElasticsearchMetadataAdapter } from '../adapters/metadata/elasticsearch_metadata_adapter'; import { KibanaMetricsAdapter } from '../adapters/metrics/kibana_metrics_adapter'; import { InfraElasticsearchSourceStatusAdapter } from '../adapters/source_status'; import { InfraFieldsDomain } from '../domains/fields_domain'; import { InfraLogEntriesDomain } from '../domains/log_entries_domain'; -import { InfraMetadataDomain } from '../domains/metadata_domain'; import { InfraMetricsDomain } from '../domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from '../infra_types'; import { InfraSnapshot } from '../snapshot'; @@ -35,9 +33,6 @@ export function compose(server: Server): InfraBackendLibs { const snapshot = new InfraSnapshot({ sources, framework }); const domainLibs: InfraDomainLibs = { - metadata: new InfraMetadataDomain(new ElasticsearchMetadataAdapter(framework), { - sources, - }), fields: new InfraFieldsDomain(new FrameworkFieldsAdapter(framework), { sources, }), diff --git a/x-pack/legacy/plugins/infra/server/lib/constants.ts b/x-pack/legacy/plugins/infra/server/lib/constants.ts index c964e691058cd..4f2fa561da0c5 100644 --- a/x-pack/legacy/plugins/infra/server/lib/constants.ts +++ b/x-pack/legacy/plugins/infra/server/lib/constants.ts @@ -20,3 +20,5 @@ export const IP_FIELDS = { [InfraNodeType.pod]: 'kubernetes.pod.ip', [InfraNodeType.container]: 'container.ip_address', }; + +export const CLOUD_METRICS_MODULES = ['aws']; diff --git a/x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/metadata_domain.ts b/x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/metadata_domain.ts deleted file mode 100644 index 5fb86df5a633a..0000000000000 --- a/x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/metadata_domain.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { InfraFrameworkRequest, InfraMetadataAggregationBucket } from '../../adapters/framework'; -import { InfraMetadataAdapter } from '../../adapters/metadata'; -import { InfraSources } from '../../sources'; - -export class InfraMetadataDomain { - constructor( - private readonly adapter: InfraMetadataAdapter, - private readonly libs: { sources: InfraSources } - ) {} - - public async getMetadata( - req: InfraFrameworkRequest, - sourceId: string, - nodeId: string, - nodeType: string - ) { - const { configuration } = await this.libs.sources.getSourceConfiguration(req, sourceId); - const metricsPromise = this.adapter.getMetricMetadata(req, configuration, nodeId, nodeType); - - const metrics = await metricsPromise; - - const metricMetadata = pickMetadata(metrics.buckets).map(entry => { - return { name: entry, source: 'metrics' }; - }); - - const id = metrics.id; - const name = metrics.name || id; - return { id, name, features: metricMetadata }; - } -} - -const pickMetadata = (buckets: InfraMetadataAggregationBucket[]): string[] => { - if (buckets) { - const metadata = buckets.map(bucket => bucket.key); - return metadata; - } else { - return []; - } -}; diff --git a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts index c63a82c137e13..394d893ede4f4 100644 --- a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts @@ -9,14 +9,12 @@ import { InfraConfigurationAdapter } from './adapters/configuration'; import { InfraBackendFrameworkAdapter, InfraFrameworkRequest } from './adapters/framework'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; -import { InfraMetadataDomain } from './domains/metadata_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; import { InfraSnapshot } from './snapshot'; import { InfraSourceStatus } from './source_status'; import { InfraSources } from './sources'; export interface InfraDomainLibs { - metadata: InfraMetadataDomain; fields: InfraFieldsDomain; logEntries: InfraLogEntriesDomain; metrics: InfraMetricsDomain; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/index.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/index.ts new file mode 100644 index 0000000000000..34b3448b86074 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/index.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom, { boomify } from 'boom'; +import { get } from 'lodash'; +import { + InfraMetadata, + InfraMetadataWrappedRequest, + InfraMetadataFeature, + InfraMetadataRequestRT, + InfraMetadataRT, +} from '../../../common/http_api/metadata_api'; +import { InfraBackendLibs } from '../../lib/infra_types'; +import { getMetricMetadata } from './lib/get_metric_metadata'; +import { pickFeatureName } from './lib/pick_feature_name'; +import { getCloudMetricsMetadata } from './lib/get_cloud_metric_metadata'; +import { getNodeInfo } from './lib/get_node_info'; +import { throwErrors } from '../../../common/runtime_types'; + +export const initMetadataRoute = (libs: InfraBackendLibs) => { + const { framework } = libs; + + framework.registerRoute>({ + method: 'POST', + path: '/api/infra/metadata', + handler: async req => { + try { + const { nodeId, nodeType, sourceId } = InfraMetadataRequestRT.decode( + req.payload + ).getOrElseL(throwErrors(Boom.badRequest)); + + const { configuration } = await libs.sources.getSourceConfiguration(req, sourceId); + const metricsMetadata = await getMetricMetadata( + framework, + req, + configuration, + nodeId, + nodeType + ); + const metricFeatures = pickFeatureName(metricsMetadata.buckets).map( + nameToFeature('metrics') + ); + + const info = await getNodeInfo(framework, req, configuration, nodeId, nodeType); + const cloudInstanceId = get(info, 'cloud.instance.id'); + + const cloudMetricsMetadata = cloudInstanceId + ? await getCloudMetricsMetadata(framework, req, configuration, cloudInstanceId) + : { buckets: [] }; + const cloudMetricsFeatures = pickFeatureName(cloudMetricsMetadata.buckets).map( + nameToFeature('metrics') + ); + + const id = metricsMetadata.id; + const name = metricsMetadata.name || id; + return InfraMetadataRT.decode({ + id, + name, + features: [...metricFeatures, ...cloudMetricsFeatures], + info, + }).getOrElseL(throwErrors(Boom.badImplementation)); + } catch (error) { + throw boomify(error); + } + }, + }); +}; + +const nameToFeature = (source: string) => (name: string): InfraMetadataFeature => ({ + name, + source, +}); diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts new file mode 100644 index 0000000000000..58b3beab42886 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_cloud_metric_metadata.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + InfraBackendFrameworkAdapter, + InfraFrameworkRequest, + InfraMetadataAggregationBucket, + InfraMetadataAggregationResponse, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { CLOUD_METRICS_MODULES } from '../../../lib/constants'; + +export interface InfraCloudMetricsAdapterResponse { + buckets: InfraMetadataAggregationBucket[]; +} + +export const getCloudMetricsMetadata = async ( + framework: InfraBackendFrameworkAdapter, + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + instanceId: string +): Promise => { + const metricQuery = { + allowNoIndices: true, + ignoreUnavailable: true, + index: sourceConfiguration.metricAlias, + body: { + query: { + bool: { + filter: [{ match: { 'cloud.instance.id': instanceId } }], + should: CLOUD_METRICS_MODULES.map(module => ({ match: { 'event.module': module } })), + }, + }, + size: 0, + aggs: { + metrics: { + terms: { + field: 'event.dataset', + size: 1000, + }, + }, + }, + }, + }; + + const response = await framework.callWithRequest< + {}, + { + metrics?: InfraMetadataAggregationResponse; + } + >(req, 'search', metricQuery); + + const buckets = + response.aggregations && response.aggregations.metrics + ? response.aggregations.metrics.buckets + : []; + + return { buckets }; +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts new file mode 100644 index 0000000000000..5f6bdd30fa2b8 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_id_field_name.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InfraSourceConfiguration } from '../../../lib/sources'; + +export const getIdFieldName = (sourceConfiguration: InfraSourceConfiguration, nodeType: string) => { + switch (nodeType) { + case 'host': + return sourceConfiguration.fields.host; + case 'container': + return sourceConfiguration.fields.container; + default: + return sourceConfiguration.fields.pod; + } +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts new file mode 100644 index 0000000000000..812bc27fffc8a --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_metric_metadata.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { + InfraFrameworkRequest, + InfraMetadataAggregationBucket, + InfraBackendFrameworkAdapter, + InfraMetadataAggregationResponse, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { getIdFieldName } from './get_id_field_name'; +import { NAME_FIELDS } from '../../../lib/constants'; + +export interface InfraMetricsAdapterResponse { + id: string; + name?: string; + buckets: InfraMetadataAggregationBucket[]; +} + +export const getMetricMetadata = async ( + framework: InfraBackendFrameworkAdapter, + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + nodeId: string, + nodeType: 'host' | 'pod' | 'container' +): Promise => { + const idFieldName = getIdFieldName(sourceConfiguration, nodeType); + + const metricQuery = { + allowNoIndices: true, + ignoreUnavailable: true, + index: sourceConfiguration.metricAlias, + body: { + query: { + bool: { + must_not: [{ match: { 'event.dataset': 'aws.ec2' } }], + filter: [ + { + match: { [idFieldName]: nodeId }, + }, + ], + }, + }, + size: 0, + aggs: { + nodeName: { + terms: { + field: NAME_FIELDS[nodeType], + size: 1, + }, + }, + metrics: { + terms: { + field: 'event.dataset', + size: 1000, + }, + }, + }, + }, + }; + + const response = await framework.callWithRequest< + {}, + { + metrics?: InfraMetadataAggregationResponse; + nodeName?: InfraMetadataAggregationResponse; + } + >(req, 'search', metricQuery); + + const buckets = + response.aggregations && response.aggregations.metrics + ? response.aggregations.metrics.buckets + : []; + + return { + id: nodeId, + name: get(response, ['aggregations', 'nodeName', 'buckets', 0, 'key'], nodeId), + buckets, + }; +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts new file mode 100644 index 0000000000000..5af25515a42ed --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_node_info.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'lodash'; +import { + InfraFrameworkRequest, + InfraBackendFrameworkAdapter, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { InfraNodeType } from '../../../graphql/types'; +import { InfraMetadataInfo } from '../../../../common/http_api/metadata_api'; +import { getPodNodeName } from './get_pod_node_name'; +import { CLOUD_METRICS_MODULES } from '../../../lib/constants'; +import { getIdFieldName } from './get_id_field_name'; + +export const getNodeInfo = async ( + framework: InfraBackendFrameworkAdapter, + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + nodeId: string, + nodeType: 'host' | 'pod' | 'container' +): Promise => { + // If the nodeType is a Kubernetes pod then we need to get the node info + // from a host record instead of a pod. This is due to the fact that any host + // can report pod details and we can't rely on the host/cloud information associated + // with the kubernetes.pod.uid. We need to first lookup the `kubernetes.node.name` + // then use that to lookup the host's node information. + if (nodeType === InfraNodeType.pod) { + const kubernetesNodeName = await getPodNodeName( + framework, + req, + sourceConfiguration, + nodeId, + nodeType + ); + if (kubernetesNodeName) { + return getNodeInfo( + framework, + req, + sourceConfiguration, + kubernetesNodeName, + InfraNodeType.host + ); + } + return {}; + } + const params = { + allowNoIndices: true, + ignoreUnavailable: true, + terminateAfter: 1, + index: sourceConfiguration.metricAlias, + body: { + size: 1, + _source: ['host.*', 'cloud.*'], + query: { + bool: { + must_not: CLOUD_METRICS_MODULES.map(module => ({ match: { 'event.module': module } })), + filter: [{ match: { [getIdFieldName(sourceConfiguration, nodeType)]: nodeId } }], + }, + }, + }, + }; + const response = await framework.callWithRequest<{ _source: InfraMetadataInfo }, {}>( + req, + 'search', + params + ); + const firstHit = first(response.hits.hits); + if (firstHit) { + return firstHit._source; + } + return {}; +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts new file mode 100644 index 0000000000000..893707a4660ee --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/get_pod_node_name.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, get } from 'lodash'; +import { + InfraFrameworkRequest, + InfraBackendFrameworkAdapter, +} from '../../../lib/adapters/framework'; +import { InfraSourceConfiguration } from '../../../lib/sources'; +import { getIdFieldName } from './get_id_field_name'; + +export const getPodNodeName = async ( + framework: InfraBackendFrameworkAdapter, + req: InfraFrameworkRequest, + sourceConfiguration: InfraSourceConfiguration, + nodeId: string, + nodeType: 'host' | 'pod' | 'container' +): Promise => { + const params = { + allowNoIndices: true, + ignoreUnavailable: true, + terminateAfter: 1, + index: sourceConfiguration.metricAlias, + body: { + size: 1, + _source: ['kubernetes.node.name'], + query: { + bool: { + filter: [ + { match: { [getIdFieldName(sourceConfiguration, nodeType)]: nodeId } }, + { exists: { field: `kubernetes.node.name` } }, + ], + }, + }, + }, + }; + const response = await framework.callWithRequest< + { _source: { kubernetes: { node: { name: string } } } }, + {} + >(req, 'search', params); + const firstHit = first(response.hits.hits); + if (firstHit) { + return get(firstHit, '_source.kubernetes.node.name'); + } +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/metadata/lib/pick_feature_name.ts b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/pick_feature_name.ts new file mode 100644 index 0000000000000..8b6bb49d9f645 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/metadata/lib/pick_feature_name.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { InfraMetadataAggregationBucket } from '../../../lib/adapters/framework'; + +export const pickFeatureName = (buckets: InfraMetadataAggregationBucket[]): string[] => { + if (buckets) { + const metadata = buckets.map(bucket => bucket.key); + return metadata; + } else { + return []; + } +}; diff --git a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.js.snap b/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.js.snap index 9045f3b44a7f1..e82685d62a316 100644 --- a/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.js.snap +++ b/x-pack/legacy/plugins/license_management/__jest__/__snapshots__/upload_license.test.js.snap @@ -860,6 +860,7 @@ exports[`UploadLicense should display a modal when license requires acknowledgem >
- + + +
@@ -1314,6 +1317,7 @@ exports[`UploadLicense should display an error when ES says license is expired 1 >
- + + +
@@ -1772,6 +1778,7 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 >
- + + +
@@ -2230,6 +2239,7 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] >
- + + +
@@ -2684,6 +2696,7 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` >
- + + +
diff --git a/x-pack/legacy/plugins/maps/common/constants.js b/x-pack/legacy/plugins/maps/common/constants.js index 0856710adbbad..9e7575b3de0f9 100644 --- a/x-pack/legacy/plugins/maps/common/constants.js +++ b/x-pack/legacy/plugins/maps/common/constants.js @@ -37,6 +37,8 @@ export const ES_SIZE_LIMIT = 10000; export const FEATURE_ID_PROPERTY_NAME = '__kbn__feature_id__'; export const FEATURE_VISIBLE_PROPERTY_NAME = '__kbn__isvisible__'; +export const MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER = '_'; + export const ES_GEO_FIELD_TYPE = { GEO_POINT: 'geo_point', GEO_SHAPE: 'geo_shape' diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js index d386be39818ca..43f2276f2a969 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js +++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js @@ -6,7 +6,7 @@ import _ from 'lodash'; import chrome from 'ui/chrome'; -import 'ui/listen'; +import 'ui/directives/listen'; import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js index d2fab0b862880..510adbfc035ef 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_addpanel/flyout_footer/view.js @@ -22,6 +22,7 @@ export const FlyoutFooter = ({ const nextButton = showNextButton ? ( { + _viewLayer = async (source, options = {}) => { if (!source) { this.setState({ layer: null }); this.props.removeTransientLayer(); return; } - - const layerOptions = this.state.layer - ? { style: this.state.layer.getCurrentStyle().getDescriptor() } - : {}; - const newLayer = source.createDefaultLayer(layerOptions, this.props.mapColors); - this.setState({ layer: newLayer }, () => - this.props.viewLayer(this.state.layer)); + const layerInitProps = { + ...options, + ...(this.state.layer && { style: this.state.layer.getCurrentStyle().getDescriptor() }) + }; + const newLayer = source.createDefaultLayer(layerInitProps, this.props.mapColors); + this.setState( + { layer: newLayer }, + () => this.props.viewLayer(this.state.layer) + ); }; _clearLayerData = ({ keepSourceType = false }) => { diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index 1e3617ff076a7..ad74b57389355 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -22,8 +22,7 @@ import { i18n } from '@kbn/i18n'; import { indexPatternService } from '../../../kibana_services'; import { Storage } from 'ui/storage'; -import { data } from 'plugins/data/setup'; -const { QueryBar } = data.query.ui; +import { QueryBar } from 'plugins/data'; const settings = chrome.getUiSettingsClient(); const localStorage = new Storage(window.localStorage); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js index 13cb77948f383..deafb561f1182 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js @@ -14,8 +14,7 @@ import { EuiFormHelpText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { data } from 'plugins/data/setup'; -const { QueryBar } = data.query.ui; +import { QueryBar } from 'plugins/data'; import { Storage } from 'ui/storage'; const settings = chrome.getUiSettingsClient(); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/mb.utils.test.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/mb.utils.test.js new file mode 100644 index 0000000000000..d38dc1cd6de58 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/mb.utils.test.js @@ -0,0 +1,327 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { removeOrphanedSourcesAndLayers, syncLayerOrderForSingleLayer } from './utils'; +import _ from 'lodash'; + +class MockMbMap { + + constructor(style) { + this._style = _.cloneDeep(style); + } + + getStyle() { + return _.cloneDeep(this._style); + } + + moveLayer(mbLayerId, nextMbLayerId) { + + const indexOfLayerToMove = this._style.layers.findIndex(layer => { + return layer.id === mbLayerId; + }); + + const layerToMove = this._style.layers[indexOfLayerToMove]; + this._style.layers.splice(indexOfLayerToMove, 1); + + const indexOfNextLayer = this._style.layers.findIndex(layer => { + return layer.id === nextMbLayerId; + }); + + this._style.layers.splice(indexOfNextLayer, 0, layerToMove); + + } + + removeSource(sourceId) { + delete this._style.sources[sourceId]; + } + + removeLayer(layerId) { + const layerToRemove = this._style.layers.findIndex(layer => { + return layer.id === layerId; + }); + this._style.layers.splice(layerToRemove, 1); + } +} + + +class MockLayer { + constructor(layerId, mbSourceIds, mbLayerIdsToSource) { + this._mbSourceIds = mbSourceIds; + this._mbLayerIdsToSource = mbLayerIdsToSource; + this._layerId = layerId; + } + getId() { + return this._layerId; + } + getMbSourceIds() { + return this._mbSourceIds; + } + getMbLayersIdsToSource() { + return this._mbLayerIdsToSource; + } + + getMbLayerIds() { + return this._mbLayerIdsToSource.map(({ id }) => id); + } + + ownsMbLayerId(mbLayerId) { + return this._mbLayerIdsToSource.some(mbLayerToSource => { + return mbLayerToSource.id === mbLayerId; + }); + } + + ownsMbSourceId(mbSourceId) { + return this._mbSourceIds.some(id => mbSourceId === id); + } + +} + + +function getMockStyle(orderedMockLayerList) { + + const mockStyle = { + sources: {}, + layers: [] + }; + + orderedMockLayerList.forEach(mockLayer => { + mockLayer.getMbSourceIds().forEach((mbSourceId) => { + mockStyle.sources[mbSourceId] = {}; + }); + mockLayer.getMbLayersIdsToSource().forEach(({ id, source }) => { + mockStyle.layers.push({ + id: id, + source: source + }); + }); + }); + + return mockStyle; +} + + +function makeSingleSourceMockLayer(layerId) { + return new MockLayer( + layerId, + [layerId], + [{ id: layerId + '_fill', source: layerId }, { id: layerId + '_line', source: layerId }] + ); +} + +function makeMultiSourceMockLayer(layerId) { + const source1 = layerId + '_source1'; + const source2 = layerId + '_source2'; + return new MockLayer( + layerId, + [source1, source2], + [ + { id: source1 + '_fill', source: source1 }, + { id: source2 + '_line', source: source2 }, + { id: source1 + '_line', source: source1 }, + { id: source1 + '_point', source: source1 } + ] + ); +} + +describe('mb/utils', () => { + + test('should remove foo and bar layer', async () => { + + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + + }); + + + test('should remove foo and bar layer (multisource)', async () => { + + const bazLayer = makeMultiSourceMockLayer('baz'); + const fooLayer = makeMultiSourceMockLayer('foo'); + const barLayer = makeMultiSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + + }); + + test('should not remove anything', async () => { + + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerList = [bazLayer, fooLayer, barLayer]; + const nextLayerList = [bazLayer, fooLayer, barLayer]; + + const currentStyle = getMockStyle(currentLayerList); + const mockMbMap = new MockMbMap(currentStyle); + + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerList); + const removedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerList); + expect(removedStyle).toEqual(nextStyle); + + }); + + test('should move bar layer in front of foo layer', async () => { + + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerOrder = [fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + + }); + + + + test('should fail at moving multiple layers (this tests a limitation of the sync)', async () => { + + //This is a known limitation of the layer order syncing. + //It assumes only a single layer will have moved. + //In practice, the Maps app will likely not cause multiple layers to move at once: + // - the UX only allows dragging a single layer + // - redux triggers a updates frequently enough + //But this is conceptually "wrong", as the sync does not actually operate in the same way as all the other mb-syncing methods + + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + const foozLayer = makeSingleSourceMockLayer('foo'); + const bazLayer = makeSingleSourceMockLayer('baz'); + + const currentLayerOrder = [fooLayer, barLayer, foozLayer, bazLayer]; + const nextLayerListOrder = [bazLayer, barLayer, foozLayer, fooLayer]; + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + const isSyncSuccesful = _.isEqual(orderedStyle, nextStyle); + expect(isSyncSuccesful).toEqual(false); + + }); + + + test('should move bar layer in front of foo layer (multi source)', async () => { + + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeMultiSourceMockLayer('bar'); + + const currentLayerOrder = [fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + + }); + + test('should move bar layer in front of foo layer, but after baz layer', async () => { + + + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerOrder = [bazLayer, fooLayer, barLayer]; + const nextLayerListOrder = [bazLayer, barLayer, fooLayer]; + + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + + }); + + test('should reorder foo and bar and remove baz', async () => { + + + const bazLayer = makeSingleSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeSingleSourceMockLayer('bar'); + + const currentLayerOrder = [bazLayer, fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerListOrder); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + + }); + + test('should reorder foo and bar and remove baz, when having multi-source multi-layer data', async () => { + + + const bazLayer = makeMultiSourceMockLayer('baz'); + const fooLayer = makeSingleSourceMockLayer('foo'); + const barLayer = makeMultiSourceMockLayer('bar'); + + const currentLayerOrder = [bazLayer, fooLayer, barLayer]; + const nextLayerListOrder = [barLayer, fooLayer]; + + + const currentStyle = getMockStyle(currentLayerOrder); + const mockMbMap = new MockMbMap(currentStyle); + removeOrphanedSourcesAndLayers(mockMbMap, nextLayerListOrder); + syncLayerOrderForSingleLayer(mockMbMap, nextLayerListOrder); + const orderedStyle = mockMbMap.getStyle(); + + const nextStyle = getMockStyle(nextLayerListOrder); + expect(orderedStyle).toEqual(nextStyle); + + }); + + +}); diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js index b32d5a9b9e627..19fe8f95089af 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js @@ -5,57 +5,19 @@ */ import _ from 'lodash'; -import mapboxgl from 'mapbox-gl'; -import chrome from 'ui/chrome'; -import { MAKI_SPRITE_PATH } from '../../../../common/constants'; - -function relativeToAbsolute(url) { - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} - -export async function createMbMapInstance({ node, initialView, scrollZoom }) { - const makiUrl = relativeToAbsolute(chrome.addBasePath(MAKI_SPRITE_PATH)); - return new Promise((resolve) => { - const options = { - attributionControl: false, - container: node, - style: { - version: 8, - sources: {}, - layers: [], - sprite: makiUrl - }, - scrollZoom, - preserveDrawingBuffer: chrome.getInjected('preserveDrawingBuffer', false) - }; - if (initialView) { - options.zoom = initialView.zoom; - options.center = { - lng: initialView.lon, - lat: initialView.lat - }; - } - const mbMap = new mapboxgl.Map(options); - mbMap.dragRotate.disable(); - mbMap.touchZoomRotate.disableRotation(); - mbMap.addControl( - new mapboxgl.NavigationControl({ showCompass: false }), 'top-left' - ); - mbMap.on('load', () => { - resolve(mbMap); - }); - }); -} export function removeOrphanedSourcesAndLayers(mbMap, layerList) { - const layerIds = layerList.map((layer) => layer.getId()); + const mbStyle = mbMap.getStyle(); const mbSourcesToRemove = []; for (const sourceId in mbStyle.sources) { - if (layerIds.indexOf(sourceId) === -1) { - mbSourcesToRemove.push(sourceId); + if (mbStyle.sources.hasOwnProperty(sourceId)) { + const layer = layerList.find(layer => { + return layer.ownsMbSourceId(sourceId); + }); + if (!layer) { + mbSourcesToRemove.push(sourceId); + } } } const mbLayersToRemove = []; @@ -73,32 +35,62 @@ export function removeOrphanedSourcesAndLayers(mbMap, layerList) { } -export function syncLayerOrder(mbMap, layerList) { - if (layerList && layerList.length) { - const mbLayers = mbMap.getStyle().layers.slice(); - const currentLayerOrder = _.uniq( // Consolidate layers and remove suffix - mbLayers.map(({ id }) => id.substring(0, id.lastIndexOf('_')))); - const newLayerOrder = layerList.map(l => l.getId()) - .filter(layerId => currentLayerOrder.includes(layerId)); - let netPos = 0; - let netNeg = 0; - const movementArr = currentLayerOrder.reduce((accu, id, idx) => { - const movement = newLayerOrder.findIndex(newOId => newOId === id) - idx; - movement > 0 ? netPos++ : movement < 0 && netNeg++; - accu.push({ id, movement }); - return accu; - }, []); - if (netPos === 0 && netNeg === 0) { return; } - const movedLayer = (netPos >= netNeg) && movementArr.find(l => l.movement < 0).id || +/** + * This is function assumes only a single layer moved in the layerList, compared to mbMap + * It is optimized to minimize the amount of mbMap.moveLayer calls. + * @param mbMap + * @param layerList + */ +export function syncLayerOrderForSingleLayer(mbMap, layerList) { + + if (!layerList || layerList.length === 0) { + return; + } + + + const mbLayers = mbMap.getStyle().layers.slice(); + const layerIds = mbLayers.map(mbLayer => { + const layer = layerList.find(layer => layer.ownsMbLayerId(mbLayer.id)); + return layer.getId(); + }); + + const currentLayerOrderLayerIds = _.uniq(layerIds); + + const newLayerOrderLayerIdsUnfiltered = layerList.map(l => l.getId()); + const newLayerOrderLayerIds = newLayerOrderLayerIdsUnfiltered.filter(layerId => currentLayerOrderLayerIds.includes(layerId)); + + let netPos = 0; + let netNeg = 0; + const movementArr = currentLayerOrderLayerIds.reduce((accu, id, idx) => { + const movement = newLayerOrderLayerIds.findIndex(newOId => newOId === id) - idx; + movement > 0 ? netPos++ : movement < 0 && netNeg++; + accu.push({ id, movement }); + return accu; + }, []); + if (netPos === 0 && netNeg === 0) { + return; + } + const movedLayerId = (netPos >= netNeg) && movementArr.find(l => l.movement < 0).id || (netPos < netNeg) && movementArr.find(l => l.movement > 0).id; - const nextLayerIdx = newLayerOrder.findIndex(layerId => layerId === movedLayer) + 1; - const nextLayerId = nextLayerIdx === newLayerOrder.length ? null : - mbLayers.find(({ id }) => id.startsWith(newLayerOrder[nextLayerIdx])).id; + const nextLayerIdx = newLayerOrderLayerIds.findIndex(layerId => layerId === movedLayerId) + 1; - mbLayers.forEach(({ id }) => { - if (id.startsWith(movedLayer)) { - mbMap.moveLayer(id, nextLayerId); - } + let nextMbLayerId; + if (nextLayerIdx === newLayerOrderLayerIds.length) { + nextMbLayerId = null; + } else { + const foundLayer = mbLayers.find(({ id: mbLayerId }) => { + const layerId = newLayerOrderLayerIds[nextLayerIdx]; + const layer = layerList.find(layer => layer.getId() === layerId); + return layer.ownsMbLayerId(mbLayerId); }); + nextMbLayerId = foundLayer.id; } + + const movedLayer = layerList.find(layer => layer.getId() === movedLayerId); + mbLayers.forEach(({ id: mbLayerId }) => { + if (movedLayer.ownsMbLayerId(mbLayerId)) { + mbMap.moveLayer(mbLayerId, nextMbLayerId); + } + }); + } diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js index befc0d82c26b3..fe667722840fa 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/view.js @@ -8,11 +8,12 @@ import _ from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom'; import { ResizeChecker } from 'ui/resize_checker'; -import { syncLayerOrder, removeOrphanedSourcesAndLayers, createMbMapInstance } from './utils'; +import { syncLayerOrderForSingleLayer, removeOrphanedSourcesAndLayers } from './utils'; import { DECIMAL_DEGREES_PRECISION, FEATURE_ID_PROPERTY_NAME, - ZOOM_PRECISION + ZOOM_PRECISION, + MAKI_SPRITE_PATH } from '../../../../common/constants'; import mapboxgl from 'mapbox-gl'; import MapboxDraw from '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw-unminified'; @@ -20,6 +21,13 @@ import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; import { FeatureTooltip } from '../feature_tooltip'; import { DRAW_TYPE } from '../../../actions/map_actions'; import { createShapeFilterWithMeta, createExtentFilterWithMeta } from '../../../elasticsearch_geo_utils'; +import chrome from 'ui/chrome'; + +function relativeToAbsolute(url) { + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} const mbDrawModes = MapboxDraw.modes; mbDrawModes.draw_rectangle = DrawRectangle; @@ -340,13 +348,45 @@ export class MBMapContainer extends React.Component { this._mbDrawControl.changeMode(mbDrawMode); } + + async _createMbMapInstance() { + const initialView = this.props.goto ? this.props.goto.center : null; + const makiUrl = relativeToAbsolute(chrome.addBasePath(MAKI_SPRITE_PATH)); + return new Promise((resolve) => { + const options = { + attributionControl: false, + container: this.refs.mapContainer, + style: { + version: 8, + sources: {}, + layers: [], + sprite: makiUrl + }, + scrollZoom: this.props.scrollZoom, + preserveDrawingBuffer: chrome.getInjected('preserveDrawingBuffer', false) + }; + if (initialView) { + options.zoom = initialView.zoom; + options.center = { + lng: initialView.lon, + lat: initialView.lat + }; + } + const mbMap = new mapboxgl.Map(options); + mbMap.dragRotate.disable(); + mbMap.touchZoomRotate.disableRotation(); + mbMap.addControl( + new mapboxgl.NavigationControl({ showCompass: false }), 'top-left' + ); + mbMap.on('load', () => { + resolve(mbMap); + }); + }); + } + async _initializeMap() { try { - this._mbMap = await createMbMapInstance({ - node: this.refs.mapContainer, - initialView: this.props.goto ? this.props.goto.center : null, - scrollZoom: this.props.scrollZoom - }); + this._mbMap = await this._createMbMapInstance(); } catch(error) { this.props.setMapInitError(error.message); return; @@ -518,7 +558,7 @@ export class MBMapContainer extends React.Component { layer.syncLayerWithMB(this._mbMap); }); - syncLayerOrder(this._mbMap, this.props.layerList); + syncLayerOrderForSingleLayer(this._mbMap, this.props.layerList); }; _syncMbMapWithInspector = () => { diff --git a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js index da555d35d70ca..e8ddee196e1f3 100644 --- a/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js +++ b/x-pack/legacy/plugins/maps/public/embeddable/map_embeddable.js @@ -15,6 +15,7 @@ import { Embeddable, executeTriggerActions } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/index'; +import { onlyDisabledFiltersChanged } from '../../../../../../src/legacy/core_plugins/data/public'; import { I18nContext } from 'ui/i18n'; import { GisMap } from '../connected_components/gis_map'; @@ -65,7 +66,7 @@ export class MapEmbeddable extends Embeddable { onContainerStateChanged(containerState) { if (!_.isEqual(containerState.timeRange, this._prevTimeRange) || !_.isEqual(containerState.query, this._prevQuery) || - !_.isEqual(containerState.filters, this._prevFilters)) { + !onlyDisabledFiltersChanged(containerState.filters, this._prevFilters)) { this._dispatchSetQuery(containerState); } diff --git a/x-pack/legacy/plugins/maps/public/inspector/views/map_view.js b/x-pack/legacy/plugins/maps/public/inspector/views/map_view.js index e1bdcbdf191b7..5be15e1edccb7 100644 --- a/x-pack/legacy/plugins/maps/public/inspector/views/map_view.js +++ b/x-pack/legacy/plugins/maps/public/inspector/views/map_view.js @@ -6,8 +6,6 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; - -import { InspectorView } from 'ui/inspector'; import { MapDetails } from './map_details'; import { i18n } from '@kbn/i18n'; @@ -38,14 +36,12 @@ class MapViewComponent extends Component { render() { return ( - - - + ); } } diff --git a/x-pack/legacy/plugins/maps/public/kibana_services.js b/x-pack/legacy/plugins/maps/public/kibana_services.js index 515e20cff9369..23a1380181c73 100644 --- a/x-pack/legacy/plugins/maps/public/kibana_services.js +++ b/x-pack/legacy/plugins/maps/public/kibana_services.js @@ -8,7 +8,7 @@ import { uiModules } from 'ui/modules'; import { SearchSourceProvider } from 'ui/courier'; import { getRequestInspectorStats, getResponseInspectorStats } from 'ui/courier/utils/courier_inspector_utils'; export { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import { data } from 'plugins/data/setup'; +import { setup as data } from '../../../../../src/legacy/core_plugins/data/public/legacy'; export const indexPatternService = data.indexPatterns.indexPatterns; diff --git a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js index d2b71aec8c8ba..7ccd98b88996c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js @@ -37,13 +37,17 @@ export class HeatmapLayer extends VectorLayer { } _getHeatmapLayerId() { - return this.getId() + '_heatmap'; + return this.makeMbLayerId('heatmap'); } getMbLayerIds() { return [this._getHeatmapLayerId()]; } + ownsMbLayerId(mbLayerId) { + return this._getHeatmapLayerId() === mbLayerId; + } + syncLayerWithMB(mbMap) { super._syncSourceBindingWithMb(mbMap); diff --git a/x-pack/legacy/plugins/maps/public/layers/layer.js b/x-pack/legacy/plugins/maps/public/layers/layer.js index d4851a9c414e9..f0186d91ab1a0 100644 --- a/x-pack/legacy/plugins/maps/public/layers/layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/layer.js @@ -9,7 +9,7 @@ import { EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { DataRequest } from './util/data_request'; -import { SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; import uuid from 'uuid/v4'; import { copyPersistentState } from '../reducers/util'; import { i18n } from '@kbn/i18n'; @@ -73,6 +73,10 @@ export class AbstractLayer { return clonedDescriptor; } + makeMbLayerId(layerNameSuffix) { + return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`; + } + isJoinable() { return this._source.isJoinable(); } @@ -264,6 +268,14 @@ export class AbstractLayer { throw new Error('Should implement AbstractLayer#getMbLayerIds'); } + ownsMbLayerId() { + throw new Error('Should implement AbstractLayer#ownsMbLayerId'); + } + + ownsMbSourceId() { + throw new Error('Should implement AbstractLayer#ownsMbSourceId'); + } + canShowTooltip() { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/constants.js b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/constants.js new file mode 100644 index 0000000000000..779ac2861e9f4 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/constants.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_APPLY_GLOBAL_QUERY = false; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js index 34faf4b29d340..ed6c06f58b6ad 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/client_file_source/geojson_file_source.js @@ -6,11 +6,18 @@ import { AbstractVectorSource } from '../vector_source'; import React from 'react'; -import { ES_GEO_FIELD_TYPE, GEOJSON_FILE } from '../../../../common/constants'; +import { + ES_GEO_FIELD_TYPE, + GEOJSON_FILE, + ES_SIZE_LIMIT +} from '../../../../common/constants'; import { ClientFileCreateSourceEditor } from './create_client_file_source_editor'; import { ESSearchSource } from '../es_search_source'; import uuid from 'uuid/v4'; import _ from 'lodash'; +import { + DEFAULT_APPLY_GLOBAL_QUERY +} from './constants'; export class GeojsonFileSource extends AbstractVectorSource { @@ -20,6 +27,9 @@ export class GeojsonFileSource extends AbstractVectorSource { static icon = 'importAction'; static isIndexingSource = true; static isBeta = true; + static layerDefaults = { + applyGlobalQuery: DEFAULT_APPLY_GLOBAL_QUERY + } static createDescriptor(geoJson, name) { // Wrap feature as feature collection if needed @@ -56,12 +66,15 @@ export class GeojsonFileSource extends AbstractVectorSource { if (!indexPatternId || !geoField) { addAndViewSource(null); } else { + // Only turn on bounds filter for large doc counts + const filterByMapBounds = indexDataResp.docCount > ES_SIZE_LIMIT; const source = new ESSearchSource({ id: uuid(), indexPatternId, geoField, + filterByMapBounds }, inspectorAdapters); - addAndViewSource(source); + addAndViewSource(source, this.layerDefaults); importSuccessHandler(indexResponses); } }; @@ -107,8 +120,17 @@ export class GeojsonFileSource extends AbstractVectorSource { } async getGeoJsonWithMeta() { + const copiedPropsFeatures = this._descriptor.featureCollection.features + .map(feature => ({ + type: 'Feature', + geometry: feature.geometry, + properties: feature.properties ? { ...feature.properties } : {} + })); return { - data: this._descriptor.featureCollection, + data: { + type: 'FeatureCollection', + features: copiedPropsFeatures + }, meta: {} }; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 1d52dcc147c5b..d0cf7420d84a7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -284,7 +284,7 @@ export class ESSearchSource extends AbstractESSource { searchSource.setField('size', 1); const query = { language: 'kuery', - query: `_id:${docId}` + query: `_id:"${docId}"` }; searchSource.setField('query', query); searchSource.setField('fields', this._descriptor.tooltipProperties); diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.js b/x-pack/legacy/plugins/maps/public/layers/tile_layer.js index ed4b2cb36d209..928a005460198 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.js @@ -48,13 +48,21 @@ export class TileLayer extends AbstractLayer { } _getMbLayerId() { - return this.getId() + '_raster'; + return this.makeMbLayerId('raster'); } getMbLayerIds() { return [this._getMbLayerId()]; } + ownsMbLayerId(mbLayerId) { + return this._getMbLayerId() === mbLayerId; + } + + ownsMbSourceId(mbSourceId) { + return this.getId() === mbSourceId; + } + syncLayerWithMB(mbMap) { const source = mbMap.getSource(this.getId()); diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js index 829ce8ea599f8..17146956af8d1 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.js @@ -196,7 +196,12 @@ export class VectorLayer extends AbstractLayer { if (!featureCollection) { return null; } - const bbox = turf.bbox(featureCollection); + + const visibleFeatures = featureCollection.features.filter(feature => feature.properties[FEATURE_VISIBLE_PROPERTY_NAME]); + const bbox = turf.bbox({ + type: 'FeatureCollection', + features: visibleFeatures + }); return { min_lon: bbox[0], min_lat: bbox[1], @@ -206,11 +211,14 @@ export class VectorLayer extends AbstractLayer { } async getBounds(dataFilters) { - if (this._source.isBoundsAware()) { - const searchFilters = this._getSearchFilters(dataFilters); - return await this._source.getBoundsForFilters(searchFilters); + + const isStaticLayer = !this._source.isBoundsAware() || !this._source.isFilterByMapBounds(); + if (isStaticLayer) { + return this._getBoundsBasedOnData(); } - return this._getBoundsBasedOnData(); + + const searchFilters = this._getSearchFilters(dataFilters); + return await this._source.getBoundsForFilters(searchFilters); } async getLeftJoinFields() { @@ -676,25 +684,36 @@ export class VectorLayer extends AbstractLayer { } _getMbPointLayerId() { - return this.getId() + '_circle'; + return this.makeMbLayerId('circle'); } _getMbSymbolLayerId() { - return this.getId() + '_symbol'; + return this.makeMbLayerId('symbol'); } _getMbLineLayerId() { - return this.getId() + '_line'; + return this.makeMbLayerId('line'); } _getMbPolygonLayerId() { - return this.getId() + '_fill'; + return this.makeMbLayerId('fill'); } getMbLayerIds() { return [this._getMbPointLayerId(), this._getMbSymbolLayerId(), this._getMbLineLayerId(), this._getMbPolygonLayerId()]; } + ownsMbLayerId(mbLayerId) { + return this._getMbPointLayerId() === mbLayerId || + this._getMbLineLayerId() === mbLayerId || + this._getMbPolygonLayerId() === mbLayerId || + this._getMbSymbolLayerId() === mbLayerId; + } + + ownsMbSourceId(mbSourceId) { + return this.getId() === mbSourceId; + } + _addJoinsToSourceTooltips(tooltipsFromSource) { for (let i = 0; i < tooltipsFromSource.length; i++) { const tooltipProperty = tooltipsFromSource[i]; diff --git a/x-pack/legacy/plugins/maps/public/reducers/map.js b/x-pack/legacy/plugins/maps/public/reducers/map.js index 3f30e5cbe27d5..fca277515affd 100644 --- a/x-pack/legacy/plugins/maps/public/reducers/map.js +++ b/x-pack/legacy/plugins/maps/public/reducers/map.js @@ -114,7 +114,6 @@ const INITIAL_STATE = { }; - export function map(state = INITIAL_STATE, action) { switch (action.type) { case UPDATE_DRAW_STATE: diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_usage_collector.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_usage_collector.js index 9c76be739fbe2..58996a1afe41f 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_usage_collector.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_usage_collector.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { TASK_ID, scheduleTask, registerMapsTelemetryTask } from './telemetry_task'; export function initTelemetryCollection(server) { - registerMapsTelemetryTask(server.taskManager); + registerMapsTelemetryTask(server); scheduleTask(server, server.taskManager); registerMapsUsageCollector(server); } @@ -26,7 +26,7 @@ async function fetch(server) { bool: { filter: { term: { - _id: TASK_ID + _id: `task:${TASK_ID}` } } } diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.js index 7fbbe8ef77ff5..e3f54335eeeed 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.js @@ -13,37 +13,45 @@ export const TASK_ID = `Maps-${TELEMETRY_TASK_TYPE}`; export function scheduleTask(server, taskManager) { const { kbnServer } = server.plugins.xpack_main.status.plugin; - kbnServer.afterPluginsInit(async () => { - try { - await taskManager.schedule({ - id: TASK_ID, - taskType: TELEMETRY_TASK_TYPE, - state: { stats: {}, runs: 0 }, - }); - }catch(e) { - server.log(['warning', 'maps'], `Error scheduling telemetry task, received ${e.message}`); - } + kbnServer.afterPluginsInit(() => { + // The code block below can't await directly within "afterPluginsInit" + // callback due to circular dependency. The server isn't "ready" until + // this code block finishes. Migrations wait for server to be ready before + // executing. Saved objects repository waits for migrations to finish before + // finishing the request. To avoid this, we'll await within a separate + // function block. + (async () => { + try { + await taskManager.schedule({ + id: TASK_ID, + taskType: TELEMETRY_TASK_TYPE, + state: { stats: {}, runs: 0 }, + }); + }catch(e) { + server.log(['warning', 'maps'], `Error scheduling telemetry task, received ${e.message}`); + } + })(); }); } -export function registerMapsTelemetryTask(taskManager) { +export function registerMapsTelemetryTask(server) { + const taskManager = server.taskManager; taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Maps telemetry fetch task', type: TELEMETRY_TASK_TYPE, timeout: '1m', numWorkers: 2, - createTaskRunner: telemetryTaskRunner(), + createTaskRunner: telemetryTaskRunner(server), }, }); } -export function telemetryTaskRunner() { +export function telemetryTaskRunner(server) { - return ({ kbnServer, taskInstance }) => { + return ({ taskInstance }) => { const { state } = taskInstance; const prevState = state; - const { server } = kbnServer; let mapsTelemetry = {}; const callCluster = server.plugins.elasticsearch.getCluster('admin') @@ -73,5 +81,5 @@ export function getNextMidnight() { const nextMidnight = new Date(); nextMidnight.setHours(0, 0, 0, 0); nextMidnight.setDate(nextMidnight.getDate() + 1); - return nextMidnight.toISOString(); + return nextMidnight; } diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.test.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.test.js index 58d53d35260c7..71e92b9af5d7c 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.test.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/telemetry_task.test.js @@ -20,16 +20,15 @@ describe('telemetryTaskRunner', () => { }); test('Returns empty stats on error', async () => { - const kbnServer = { server: mockKbnServer }; const getNextMidnight = () => moment() .add(1, 'days') .startOf('day') - .toISOString(); + .toDate(); - const getRunner = telemetryTaskRunner(); + const getRunner = telemetryTaskRunner(mockKbnServer); const runResult = await getRunner( - { kbnServer, taskInstance: mockTaskInstance } + { taskInstance: mockTaskInstance } ).run(); expect(runResult).toMatchObject({ diff --git a/x-pack/legacy/plugins/maps/server/routes.js b/x-pack/legacy/plugins/maps/server/routes.js index bae7ce3f17563..5e7c498e2a5ed 100644 --- a/x-pack/legacy/plugins/maps/server/routes.js +++ b/x-pack/legacy/plugins/maps/server/routes.js @@ -26,7 +26,7 @@ export function initRoutes(server, licenseUid) { const serverConfig = server.config(); const mapConfig = serverConfig.get('map'); - const emsClient = new server.plugins.tile_map.ems_client.EMSClient({ + const emsClient = new server.plugins.tile_map.EMSClient({ language: i18n.getLocale(), kbnVersion: serverConfig.get('pkg.version'), manifestServiceUrl: mapConfig.manifestServiceUrl, diff --git a/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts b/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts new file mode 100644 index 0000000000000..09173247237ac --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/constants/aggregation_types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum ML_JOB_AGGREGATION { + COUNT = 'count', + HIGH_COUNT = 'high_count', + LOW_COUNT = 'low_count', + MEAN = 'mean', + HIGH_MEAN = 'high_mean', + LOW_MEAN = 'low_mean', + SUM = 'sum', + HIGH_SUM = 'high_sum', + LOW_SUM = 'low_sum', + MEDIAN = 'median', + HIGH_MEDIAN = 'high_median', + LOW_MEDIAN = 'low_median', + MIN = 'min', + MAX = 'max', + DISTINCT_COUNT = 'distinct_count', +} + +export enum KIBANA_AGGREGATION { + COUNT = 'count', + AVG = 'avg', + MAX = 'max', + MIN = 'min', + SUM = 'sum', + MEDIAN = 'median', + CARDINALITY = 'cardinality', +} + +export enum ES_AGGREGATION { + COUNT = 'count', + AVG = 'avg', + MAX = 'max', + MIN = 'min', + SUM = 'sum', + PERCENTILES = 'percentiles', + CARDINALITY = 'cardinality', +} diff --git a/x-pack/legacy/plugins/ml/common/constants/anomalies.ts b/x-pack/legacy/plugins/ml/common/constants/anomalies.ts new file mode 100644 index 0000000000000..bbf3616c05880 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/constants/anomalies.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum ANOMALY_SEVERITY { + CRITICAL = 'critical', + MAJOR = 'major', + MINOR = 'minor', + WARNING = 'warning', + LOW = 'low', + UNKNOWN = 'unknown', +} + +export enum ANOMALY_THRESHOLD { + CRITICAL = 75, + MAJOR = 50, + MINOR = 25, + WARNING = 3, + LOW = 0, +} diff --git a/x-pack/legacy/plugins/ml/common/constants/states.js b/x-pack/legacy/plugins/ml/common/constants/states.js deleted file mode 100644 index 4584171c713f2..0000000000000 --- a/x-pack/legacy/plugins/ml/common/constants/states.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - - -export const DATAFEED_STATE = { - STARTED: 'started', - STARTING: 'starting', - STOPPED: 'stopped', - STOPPING: 'stopping', - DELETED: 'deleted', -}; - -export const FORECAST_REQUEST_STATE = { - FAILED: 'failed', - FINISHED: 'finished', - SCHEDULED: 'scheduled', - STARTED: 'started', -}; - -export const JOB_STATE = { - CLOSED: 'closed', - CLOSING: 'closing', - FAILED: 'failed', - OPENED: 'opened', - OPENING: 'opening', - DELETED: 'deleted', -}; diff --git a/x-pack/legacy/plugins/ml/common/constants/states.ts b/x-pack/legacy/plugins/ml/common/constants/states.ts new file mode 100644 index 0000000000000..30c3e44b7f434 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/constants/states.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum DATAFEED_STATE { + STARTED = 'started', + STARTING = 'starting', + STOPPED = 'stopped', + STOPPING = 'stopping', + DELETED = 'deleted', +} + +export enum FORECAST_REQUEST_STATE { + FAILED = 'failed', + FINISHED = 'finished', + SCHEDULED = 'scheduled', + STARTED = 'started', +} + +export enum JOB_STATE { + CLOSED = 'closed', + CLOSING = 'closing', + FAILED = 'failed', + OPENED = 'opened', + OPENING = 'opening', + DELETED = 'deleted', +} diff --git a/x-pack/legacy/plugins/ml/common/constants/validation.js b/x-pack/legacy/plugins/ml/common/constants/validation.ts similarity index 75% rename from x-pack/legacy/plugins/ml/common/constants/validation.js rename to x-pack/legacy/plugins/ml/common/constants/validation.ts index 914a1ab3215db..c71db4dca3c99 100644 --- a/x-pack/legacy/plugins/ml/common/constants/validation.js +++ b/x-pack/legacy/plugins/ml/common/constants/validation.ts @@ -4,15 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ - - - -export const VALIDATION_STATUS = { - ERROR: 'error', - INFO: 'info', - SUCCESS: 'success', - WARNING: 'warning' -}; +export enum VALIDATION_STATUS { + ERROR = 'error', + INFO = 'info', + SUCCESS = 'success', + WARNING = 'warning', +} export const SKIP_BUCKET_SPAN_ESTIMATION = true; diff --git a/x-pack/legacy/plugins/ml/common/types/fields.ts b/x-pack/legacy/plugins/ml/common/types/fields.ts new file mode 100644 index 0000000000000..7e09f14515a13 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/types/fields.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ES_FIELD_TYPES } from '../../common/constants/field_types'; +import { ML_JOB_AGGREGATION } from '../../common/constants/aggregation_types'; + +export const EVENT_RATE_FIELD_ID = '__ml_event_rate_count__'; + +export type FieldId = string; +export type AggId = ML_JOB_AGGREGATION; +export type SplitField = Field | null; +export type DslName = string; + +export interface Field { + id: FieldId; + name: string; + type: ES_FIELD_TYPES; + aggregatable: boolean; + aggIds?: AggId[]; + aggs?: Aggregation[]; +} + +export interface Aggregation { + id: AggId; + title: string; + kibanaName: string; + dslName: DslName; + type: string; + mlModelPlotAgg: { + min: string; + max: string; + }; + fieldIds?: FieldId[]; + fields?: Field[]; +} + +export interface NewJobCaps { + fields: Field[]; + aggs: Aggregation[]; +} + +export interface AggFieldPair { + agg: Aggregation; + field: Field; + by?: { + field: SplitField; + value: string | null; + }; +} + +export interface AggFieldNamePair { + agg: string; + field: string; + by?: { + field: string | null; + value: string | null; + }; +} diff --git a/x-pack/legacy/plugins/ml/common/types/kibana.ts b/x-pack/legacy/plugins/ml/common/types/kibana.ts index 88e5f68fb4a38..86db2ce59d7e7 100644 --- a/x-pack/legacy/plugins/ml/common/types/kibana.ts +++ b/x-pack/legacy/plugins/ml/common/types/kibana.ts @@ -4,4 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +// custom edits or fixes for default kibana types which are incomplete + +export type IndexPatternTitle = string; + export type callWithRequestType = (action: string, params?: any) => Promise; + +export interface Route { + id: string; + k7Breadcrumbs: () => any; +} diff --git a/x-pack/legacy/plugins/ml/common/types/privileges.ts b/x-pack/legacy/plugins/ml/common/types/privileges.ts index e229acc27de61..4e735cab1a7cf 100644 --- a/x-pack/legacy/plugins/ml/common/types/privileges.ts +++ b/x-pack/legacy/plugins/ml/common/types/privileges.ts @@ -68,3 +68,10 @@ export function getDefaultPrivileges(): Privileges { canStartStopDataFrame: false, }; } + +export interface PrivilegesResponse { + capabilities: Privileges; + upgradeInProgress: boolean; + isPlatinumOrTrialLicense: boolean; + mlFeatureEnabledInSpace: boolean; +} diff --git a/x-pack/legacy/plugins/ml/common/util/anomaly_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.d.ts new file mode 100644 index 0000000000000..adeb6dc7dd5b9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.d.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ANOMALY_SEVERITY } from '../constants/anomalies'; + +export function getSeverity(normalizedScore: number): string; +export function getSeverityType(normalizedScore: number): ANOMALY_SEVERITY; +export function getSeverityColor(normalizedScore: number): string; diff --git a/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js index 371d1d176394a..d201a971dda5c 100644 --- a/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js +++ b/x-pack/legacy/plugins/ml/common/util/anomaly_utils.js @@ -4,25 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ - - /* -* Contains functions for operations commonly performed on anomaly data -* to extract information for display in dashboards. -*/ + * Contains functions for operations commonly performed on anomaly data + * to extract information for display in dashboards. + */ -import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { CONDITIONS_NOT_SUPPORTED_FUNCTIONS } from '../constants/detector_rule'; import { MULTI_BUCKET_IMPACT } from '../constants/multi_bucket_impact'; +import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../constants/anomalies'; // List of function descriptions for which actual values from record level results should be displayed. -const DISPLAY_ACTUAL_FUNCTIONS = ['count', 'distinct_count', 'lat_long', 'mean', 'max', 'min', 'sum', - 'median', 'varp', 'info_content', 'time']; +const DISPLAY_ACTUAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; // List of function descriptions for which typical values from record level results should be displayed. -const DISPLAY_TYPICAL_FUNCTIONS = ['count', 'distinct_count', 'lat_long', 'mean', 'max', 'min', 'sum', - 'median', 'varp', 'info_content', 'time']; +const DISPLAY_TYPICAL_FUNCTIONS = [ + 'count', + 'distinct_count', + 'lat_long', + 'mean', + 'max', + 'min', + 'sum', + 'median', + 'varp', + 'info_content', + 'time', +]; let severityTypes; @@ -31,26 +51,44 @@ function getSeverityTypes() { return severityTypes; } - return severityTypes = { - critical: { id: 'critical', label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { - defaultMessage: 'critical', - }) }, - major: { id: 'major', label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { - defaultMessage: 'major', - }) }, - minor: { id: 'minor', label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { - defaultMessage: 'minor', - }) }, - warning: { id: 'warning', label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { - defaultMessage: 'warning', - }) }, - unknown: { id: 'unknown', label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { - defaultMessage: 'unknown', - }) }, - low: { id: 'low', label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { - defaultMessage: 'low', - }) }, - }; + return (severityTypes = { + critical: { + id: ANOMALY_SEVERITY.CRITICAL, + label: i18n.translate('xpack.ml.anomalyUtils.severity.criticalLabel', { + defaultMessage: 'critical', + }), + }, + major: { + id: ANOMALY_SEVERITY.MAJOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.majorLabel', { + defaultMessage: 'major', + }), + }, + minor: { + id: ANOMALY_SEVERITY.MINOR, + label: i18n.translate('xpack.ml.anomalyUtils.severity.minorLabel', { + defaultMessage: 'minor', + }), + }, + warning: { + id: ANOMALY_SEVERITY.WARNING, + label: i18n.translate('xpack.ml.anomalyUtils.severity.warningLabel', { + defaultMessage: 'warning', + }), + }, + unknown: { + id: ANOMALY_SEVERITY.UNKNOWN, + label: i18n.translate('xpack.ml.anomalyUtils.severity.unknownLabel', { + defaultMessage: 'unknown', + }), + }, + low: { + id: ANOMALY_SEVERITY.LOW, + label: i18n.translate('xpack.ml.anomalyUtils.severityWithLow.lowLabel', { + defaultMessage: 'low', + }), + }, + }); } // Returns a severity label (one of critical, major, minor, warning or unknown) @@ -58,34 +96,50 @@ function getSeverityTypes() { export function getSeverity(normalizedScore) { const severityTypesList = getSeverityTypes(); - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return severityTypesList.critical; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return severityTypesList.major; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return severityTypesList.minor; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return severityTypesList.warning; } else { return severityTypesList.unknown; } } +export function getSeverityType(normalizedScore) { + if (normalizedScore >= 75) { + return ANOMALY_SEVERITY.CRITICAL; + } else if (normalizedScore >= 50) { + return ANOMALY_SEVERITY.MAJOR; + } else if (normalizedScore >= 25) { + return ANOMALY_SEVERITY.MINOR; + } else if (normalizedScore >= 3) { + return ANOMALY_SEVERITY.WARNING; + } else if (normalizedScore >= 0) { + return ANOMALY_SEVERITY.LOW; + } else { + return ANOMALY_SEVERITY.UNKNOWN; + } +} + // Returns a severity label (one of critical, major, minor, warning, low or unknown) // for the supplied normalized anomaly score (a value between 0 and 100), where scores // less than 3 are assigned a severity of 'low'. export function getSeverityWithLow(normalizedScore) { const severityTypesList = getSeverityTypes(); - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return severityTypesList.critical; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return severityTypesList.major; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return severityTypesList.minor; - } else if (normalizedScore >= 3) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { return severityTypesList.warning; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return severityTypesList.low; } else { return severityTypesList.unknown; @@ -95,15 +149,15 @@ export function getSeverityWithLow(normalizedScore) { // Returns a severity RGB color (one of critical, major, minor, warning, low_warning or unknown) // for the supplied normalized anomaly score (a value between 0 and 100). export function getSeverityColor(normalizedScore) { - if (normalizedScore >= 75) { + if (normalizedScore >= ANOMALY_THRESHOLD.CRITICAL) { return '#fe5050'; - } else if (normalizedScore >= 50) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MAJOR) { return '#fba740'; - } else if (normalizedScore >= 25) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.MINOR) { return '#fdec25'; - } else if (normalizedScore >= 3) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.WARNING) { return '#8bc8fb'; - } else if (normalizedScore >= 0) { + } else if (normalizedScore >= ANOMALY_THRESHOLD.LOW) { return '#d2e9f7'; } else { return '#ffffff'; @@ -139,15 +193,15 @@ export function getMultiBucketImpactLabel(multiBucketImpact) { export function getEntityFieldName(record) { // Analyses with by and over fields, will have a top-level by_field_name, but // the by_field_value(s) will be in the nested causes array. - if (_.has(record, 'by_field_name') && _.has(record, 'by_field_value')) { + if (record.by_field_name !== undefined && record.by_field_value !== undefined) { return record.by_field_name; } - if (_.has(record, 'over_field_name')) { + if (record.over_field_name !== undefined) { return record.over_field_name; } - if (_.has(record, 'partition_field_name')) { + if (record.partition_field_name !== undefined) { return record.partition_field_name; } @@ -158,15 +212,15 @@ export function getEntityFieldName(record) { // obtained from Elasticsearch. The function looks first for a by_field, then over_field, // then partition_field, returning undefined if none of these fields are present. export function getEntityFieldValue(record) { - if (_.has(record, 'by_field_value')) { + if (record.by_field_value !== undefined) { return record.by_field_value; } - if (_.has(record, 'over_field_value')) { + if (record.over_field_value !== undefined) { return record.over_field_value; } - if (_.has(record, 'partition_field_value')) { + if (record.partition_field_value !== undefined) { return record.partition_field_value; } @@ -181,7 +235,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.partition_field_name, fieldValue: record.partition_field_value, - fieldType: 'partition' + fieldType: 'partition', }); } @@ -189,7 +243,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.over_field_name, fieldValue: record.over_field_value, - fieldType: 'over' + fieldType: 'over', }); } @@ -200,7 +254,7 @@ export function getEntityFieldList(record) { entityFields.push({ fieldName: record.by_field_name, fieldValue: record.by_field_value, - fieldType: 'by' + fieldType: 'by', }); } @@ -211,22 +265,24 @@ export function getEntityFieldList(record) { // Note that the 'function' field in a record contains what the user entered e.g. 'high_count', // whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. export function showActualForFunction(functionDescription) { - return _.indexOf(DISPLAY_ACTUAL_FUNCTIONS, functionDescription) > -1; + return DISPLAY_ACTUAL_FUNCTIONS.indexOf(functionDescription) > -1; } // Returns whether typical values should be displayed for a record with the specified function description. // Note that the 'function' field in a record contains what the user entered e.g. 'high_count', // whereas the 'function_description' field holds a ML-built display hint for function e.g. 'count'. export function showTypicalForFunction(functionDescription) { - return _.indexOf(DISPLAY_TYPICAL_FUNCTIONS, functionDescription) > -1; + return DISPLAY_TYPICAL_FUNCTIONS.indexOf(functionDescription) > -1; } // Returns whether a rule can be configured against the specified anomaly. export function isRuleSupported(record) { // A rule can be configured with a numeric condition if the function supports it, // and/or with scope if there is a partitioning fields. - return (CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1) || - (getEntityFieldName(record) !== undefined); + return ( + CONDITIONS_NOT_SUPPORTED_FUNCTIONS.indexOf(record.function) === -1 || + getEntityFieldName(record) !== undefined + ); } // Two functions for converting aggregation type names. @@ -272,5 +328,5 @@ export const aggregationTypeTransform = { } return newAggType; - } + }, }; diff --git a/x-pack/legacy/plugins/ml/common/util/group_color_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/group_color_utils.d.ts new file mode 100644 index 0000000000000..4a1a6ebb8fdf3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/group_color_utils.d.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export function tabColor(name: string): string; diff --git a/x-pack/legacy/plugins/ml/common/util/job_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/job_utils.d.ts new file mode 100644 index 0000000000000..0217ddcdc2cfe --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/job_utils.d.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ValidationMessage { + id: string; +} +export interface ValidationResults { + messages: ValidationMessage[]; + valid: boolean; + contains: (id: string) => boolean; + find: (id: string) => ValidationMessage | undefined; +} +export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: number): number; + +// TODO - use real types for job. Job interface first needs to move to a common location +export function isTimeSeriesViewJob(job: any): boolean; +export function basicJobValidation( + job: any, + fields: any[] | undefined, + limits: any, + skipMmlCheck?: boolean +): ValidationResults; + +export const ML_MEDIAN_PERCENTS: number; + +export const ML_DATA_PREVIEW_COUNT: number; diff --git a/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts b/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts new file mode 100644 index 0000000000000..b3537b94d1c70 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/parse_interval.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface Duration { + asSeconds(): number; +} +export function parseInterval(interval: string): Duration; diff --git a/x-pack/legacy/plugins/ml/common/util/string_utils.d.ts b/x-pack/legacy/plugins/ml/common/util/string_utils.d.ts new file mode 100644 index 0000000000000..f8dbc00643d07 --- /dev/null +++ b/x-pack/legacy/plugins/ml/common/util/string_utils.d.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function renderTemplate(str: string, data: string): string; +export function stringHash(str: string): string; diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js deleted file mode 100644 index 5f766a31b3ae3..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotation_flyout/annotation_flyout_directive.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * angularjs wrapper directive for the AnnotationsTable React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { AnnotationFlyout } from './index'; - -import 'angular'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { I18nProvider } from '@kbn/i18n/react'; - -module.directive('mlAnnotationFlyout', function () { - - function link(scope, element) { - ReactDOM.render( - - {React.createElement(AnnotationFlyout)} - , - element[0] - ); - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - } - - return { - scope: false, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js deleted file mode 100644 index d53271aba5511..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/__tests__/annotations_table_directive.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import jobConfig from '../../../../../common/types/__mocks__/job_config_farequote'; - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { ml } from '../../../../services/ml_api_service'; - -describe('ML - ', () => { - let $scope; - let $compile; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Plain initialization doesn\'t throw an error', () => { - expect(() => { - $compile('')($scope); - }).to.not.throwError(); - }); - - it('Initialization with empty annotations array doesn\'t throw an error', () => { - expect(() => { - $compile('')($scope); - }).to.not.throwError(); - }); - - it('Initialization with job config doesn\'t throw an error', () => { - const getAnnotationsStub = sinon.stub(ml.annotations, 'getAnnotations').resolves({ annotations: [] }); - - expect(() => { - $scope.jobs = [jobConfig]; - $compile('')($scope); - }).to.not.throwError(); - - getAnnotationsStub.restore(); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js deleted file mode 100644 index 5e8cece162997..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/annotations_table_directive.js +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * angularjs wrapper directive for the AnnotationsTable React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { AnnotationsTable } from './annotations_table'; - -import 'angular'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import chrome from 'ui/chrome'; -const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - -import { I18nContext } from 'ui/i18n'; - -module.directive('mlAnnotationTable', function () { - - function link(scope, element) { - function renderReactComponent() { - if (typeof scope.jobs === 'undefined' && typeof scope.annotations === 'undefined') { - return; - } - - const props = { - annotations: scope.annotations, - jobs: scope.jobs, - isSingleMetricViewerLinkVisible: scope.drillDown, - isNumberBadgeVisible: scope.numberBadge - }; - - ReactDOM.render( - - {React.createElement(AnnotationsTable, props)} - , - element[0] - ); - } - - renderReactComponent(); - - scope.$on('render', () => { - renderReactComponent(); - }); - - function renderFocusChart() { - renderReactComponent(); - } - - if (mlAnnotationsEnabled) { - scope.$watchCollection('annotations', renderFocusChart); - } - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - - } - - return { - scope: { - annotations: '=', - drillDown: '=', - jobs: '=', - numberBadge: '=' - }, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js index 6364275ce2e55..965f201f5e8f1 100644 --- a/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js +++ b/x-pack/legacy/plugins/ml/public/components/annotations/annotations_table/index.js @@ -5,5 +5,3 @@ */ export { AnnotationsTable } from './annotations_table'; - -import './annotations_table_directive'; diff --git a/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js b/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js deleted file mode 100644 index 5772bc98959e4..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/anomalies_table/anomalies_table_directive.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { wrapInI18nContext } from 'ui/i18n'; -import { uiModules } from 'ui/modules'; -import { timefilter } from 'ui/timefilter'; -const module = uiModules.get('apps/ml', ['react']); - -import { AnomaliesTable } from './anomalies_table'; - -module.directive('mlAnomaliesTable', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - wrapInI18nContext(AnomaliesTable), - [ - ['filter', { watchDepth: 'reference' }], - ['tableData', { watchDepth: 'reference' }] - ], - { restrict: 'E' }, - { - timefilter - } - ); -}); diff --git a/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js b/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js index d16092d1f6189..1999654055c71 100644 --- a/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js +++ b/x-pack/legacy/plugins/ml/public/components/anomalies_table/index.js @@ -5,4 +5,4 @@ */ -import './anomalies_table_directive'; +export { AnomaliesTable } from './anomalies_table'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js deleted file mode 100644 index c4f12776ad965..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/checkbox_showcharts_service.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { showCharts$ } from './checkbox_showcharts'; - -module.service('mlCheckboxShowChartsService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync()); -}); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js index 7c11d47bae2df..b7957b807591c 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/checkbox_showcharts/index.js @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - -import './checkbox_showcharts_service'; +export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/index.js b/x-pack/legacy/plugins/ml/public/components/controls/index.js index 275c6e6a6790d..26cb89d672632 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/index.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ - - -import './checkbox_showcharts'; -import './select_interval'; -import './select_severity'; +export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; +export { interval$, SelectInterval } from './select_interval'; +export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js deleted file mode 100644 index 67b39d9bf85fc..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/__tests__/select_interval_directive.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; - -import { interval$ } from '../select_interval'; - -describe('ML - mlSelectIntervalService', () => { - let appState; - - beforeEach(ngMock.module('kibana', (stateManagementConfigProvider) => { - stateManagementConfigProvider.enable(); - })); - beforeEach(ngMock.module(($provide) => { - appState = { - fetch: () => {}, - save: () => {} - }; - - $provide.factory('AppState', () => () => appState); - })); - - it('initializes AppState with correct default value', (done) => { - ngMock.inject(($injector) => { - $injector.get('mlSelectIntervalService'); - const defaultValue = { display: 'Auto', val: 'auto' }; - - expect(appState.mlSelectInterval).to.eql(defaultValue); - expect(interval$.getValue()).to.eql(defaultValue); - - done(); - }); - }); - - it('restores AppState to interval$ observable', (done) => { - ngMock.inject(($injector) => { - const restoreValue = { display: '1 day', val: 'day' }; - appState.mlSelectInterval = restoreValue; - - $injector.get('mlSelectIntervalService'); - - expect(appState.mlSelectInterval).to.eql(restoreValue); - expect(interval$.getValue()).to.eql(restoreValue); - - done(); - }); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js index 8fe80d63bb99c..a38c71d89d07b 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/index.js @@ -5,4 +5,4 @@ */ -import './select_interval_directive'; +export { interval$, SelectInterval } from './select_interval'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js deleted file mode 100644 index 4391d33951932..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_interval/select_interval_directive.js +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { SelectInterval, interval$ } from './select_interval'; - -module.service('mlSelectIntervalService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync()); -}) - .directive('mlSelectInterval', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - SelectInterval, - undefined, - { restrict: 'E' } - ); - }); diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js index 618edf599e509..7c841156009f3 100644 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js +++ b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/index.js @@ -5,4 +5,4 @@ */ -import './select_severity_directive'; +export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js b/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js deleted file mode 100644 index 6ab94225cea48..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/controls/select_severity/select_severity_directive.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - -import 'ngreact'; - -import { wrapInI18nContext } from 'ui/i18n'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml', ['react']); - -import { subscribeAppStateToObservable } from '../../../util/app_state_utils'; -import { SelectSeverity, severity$ } from './select_severity'; - -module.service('mlSelectSeverityService', function (AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync()); -}) - .directive('mlSelectSeverity', function ($injector) { - const reactDirective = $injector.get('reactDirective'); - - return reactDirective( - wrapInI18nContext(SelectSeverity), - undefined, - { restrict: 'E' }, - ); - }); diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap similarity index 100% rename from x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/index.test.tsx.snap rename to x-pack/legacy/plugins/ml/public/components/full_time_range_selector/__snapshots__/full_time_range_selector.test.tsx.snap diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.test.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx similarity index 96% rename from x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.test.tsx rename to x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx index 41e8fa014907e..4da2a040e9975 100644 --- a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.test.tsx +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.test.tsx @@ -20,12 +20,12 @@ jest.mock('./full_time_range_selector_service', () => ({ })); describe('FullTimeRangeSelector', () => { - const indexPattern: IndexPattern = { + const indexPattern = ({ id: '0844fc70-5ab5-11e9-935e-836737467b0f', fields: [], title: 'test-index-pattern', timeFieldName: '@timestamp', - }; + } as unknown) as IndexPattern; const query: Query = { language: 'kuery', diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx new file mode 100644 index 0000000000000..93ad4a5d80867 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { Query } from 'src/legacy/core_plugins/data/public'; +import { IndexPattern } from 'ui/index_patterns'; +import { EuiButton } from '@elastic/eui'; +import { setFullTimeRange } from './full_time_range_selector_service'; + +interface Props { + indexPattern: IndexPattern; + query: Query; + disabled: boolean; + callback?: (a: any) => void; +} + +// Component for rendering a button which automatically sets the range of the time filter +// to the time range of data in the index(es) mapped to the supplied Kibana index pattern or query. +export const FullTimeRangeSelector: FC = ({ indexPattern, query, disabled, callback }) => { + // wrapper around setFullTimeRange to allow for the calling of the optional callBack prop + async function setRange(i: IndexPattern, q: Query) { + const fullTimeRange = await setFullTimeRange(i, q); + if (typeof callback === 'function') { + callback(fullTimeRange); + } + } + return ( + setRange(indexPattern, query)}> + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts index 32606d2db425e..9ed908f9ffcee 100644 --- a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/full_time_range_selector_service.ts @@ -11,26 +11,56 @@ import { IndexPattern } from 'ui/index_patterns'; import { toastNotifications } from 'ui/notify'; import { timefilter } from 'ui/timefilter'; import { Query } from 'src/legacy/core_plugins/data/public'; -import { ml } from '../../services/ml_api_service'; +import dateMath from '@elastic/datemath'; +import { ml, GetTimeFieldRangeResponse } from '../../services/ml_api_service'; -export function setFullTimeRange(indexPattern: IndexPattern, query: Query) { - return ml - .getTimeFieldRange({ +export interface TimeRange { + from: number; + to: number; +} + +export async function setFullTimeRange( + indexPattern: IndexPattern, + query: Query +): Promise { + try { + const resp = await ml.getTimeFieldRange({ index: indexPattern.title, timeFieldName: indexPattern.timeFieldName, query, - }) - .then(resp => { - timefilter.setTime({ - from: moment(resp.start.epoch).toISOString(), - to: moment(resp.end.epoch).toISOString(), - }); - }) - .catch(resp => { - toastNotifications.addDanger( - i18n.translate('xpack.ml.fullTimeRangeSelector.errorSettingTimeRangeNotification', { - defaultMessage: 'An error occurred setting the time range.', - }) - ); }); + timefilter.setTime({ + from: moment(resp.start.epoch).toISOString(), + to: moment(resp.end.epoch).toISOString(), + }); + return resp; + } catch (resp) { + toastNotifications.addDanger( + i18n.translate('xpack.ml.fullTimeRangeSelector.errorSettingTimeRangeNotification', { + defaultMessage: 'An error occurred setting the time range.', + }) + ); + return resp; + } +} + +export function getTimeFilterRange(): TimeRange { + let from = 0; + let to = 0; + const fromString = timefilter.getTime().from; + const toString = timefilter.getTime().to; + if (typeof fromString === 'string' && typeof toString === 'string') { + const fromMoment = dateMath.parse(fromString); + const toMoment = dateMath.parse(toString); + if (typeof fromMoment !== 'undefined' && typeof toMoment !== 'undefined') { + const fromMs = fromMoment.valueOf(); + const toMs = toMoment.valueOf(); + from = fromMs; + to = toMs; + } + } + return { + to, + from, + }; } diff --git a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx index 9066fe0a0e8b9..0c60f74c9068b 100644 --- a/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx +++ b/x-pack/legacy/plugins/ml/public/components/full_time_range_selector/index.tsx @@ -3,33 +3,5 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import React from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPattern } from 'ui/index_patterns'; -import { EuiButton } from '@elastic/eui'; -import { Query } from 'src/legacy/core_plugins/data/public'; -import { setFullTimeRange } from './full_time_range_selector_service'; - -interface Props { - indexPattern: IndexPattern; - query: Query; - disabled: boolean; -} - -// Component for rendering a button which automatically sets the range of the time filter -// to the time range of data in the index(es) mapped to the supplied Kibana index pattern or query. -export const FullTimeRangeSelector: React.SFC = ({ indexPattern, query, disabled }) => { - return ( - setFullTimeRange(indexPattern, query)}> - - - ); -}; +export { FullTimeRangeSelector } from './full_time_range_selector'; +export { getTimeFilterRange, TimeRange } from './full_time_range_selector_service'; diff --git a/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js b/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js index 07d95019813e5..0b64ce2367a0d 100644 --- a/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js +++ b/x-pack/legacy/plugins/ml/public/components/influencers_list/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './influencers_list'; +export { InfluencersList } from './influencers_list'; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js b/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js index d906b49beef7f..bbb02917c7061 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/custom_selection_table/custom_selection_table.js @@ -387,7 +387,7 @@ CustomSelectionTable.propTypes = { items: PropTypes.array.isRequired, onTableChange: PropTypes.func.isRequired, selectedId: PropTypes.array, - singleSelection: PropTypes.string, + singleSelection: PropTypes.bool, sortableProperties: PropTypes.object, - timeseriesOnly: PropTypes.string + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/index.js b/x-pack/legacy/plugins/ml/public/components/job_selector/index.js index 31427150f9261..c5e5e17b97804 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/index.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/index.js @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - - - -import './job_selector_react_wrapper_directive'; +export { JobSelector } from './job_selector'; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js index b0061204bd3fa..5f1d85869b3bc 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_select_service_utils.js @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference } from 'lodash'; +import { difference, isEqual } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; import { toastNotifications } from 'ui/notify'; -import { mlJobService } from '../../services/job_service'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import d3 from 'd3'; +import { mlJobService } from '../../services/job_service'; function warnAboutInvalidJobIds(invalidIds) { if (invalidIds.length > 0) { @@ -34,6 +35,30 @@ function getInvalidJobIds(ids) { }); } +export const jobSelectServiceFactory = (globalState) => { + const { jobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false }); + + // Subscribe to changes to globalState and trigger + // a jobSelectService update if the job selection changed. + const listener = () => { + const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); + const oldSelectedJobIds = jobSelectService.getValue().selection; + + if (newJobIds && !(isEqual(oldSelectedJobIds, newJobIds))) { + jobSelectService.next({ selection: newJobIds, groups: newSelectedGroups }); + } + }; + + globalState.on('save_with_changes', listener); + + const unsubscribeFromGlobalState = () => { + globalState.off('save_with_changes', listener); + }; + + return { jobSelectService, unsubscribeFromGlobalState }; +}; + function loadJobIdsFromGlobalState(globalState) { // jobIds, groups // fetch to get the latest state globalState.fetch(); diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js index 0d0f7b0c0d5bd..0d00516921e00 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector.js @@ -72,13 +72,13 @@ const BADGE_LIMIT = 10; const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels export function JobSelector({ - config, + dateFormatTz, globalState, jobSelectService, selectedJobIds, selectedGroups, singleSelection, - timeseriesOnly + timeseriesOnly, }) { const [jobs, setJobs] = useState([]); const [groups, setGroups] = useState([]); @@ -114,8 +114,6 @@ export function JobSelector({ // Not wrapping it would cause this dependency to change on every render const handleResize = useCallback(() => { if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); // get all cols in flyout table const tableHeaderCols = flyoutEl.current.flyout.querySelectorAll('table thead th'); // get the width of the last col @@ -126,7 +124,7 @@ export function JobSelector({ setGroups(updatedGroups); setGanttBarWidth(derivedWidth); } - }, [config, jobs]); + }, [dateFormatTz, jobs]); useEffect(() => { // Ensure ganttBar width gets calculated on resize @@ -151,8 +149,6 @@ export function JobSelector({ function handleJobSelectionClick() { showFlyout(); - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); ml.jobs.jobsWithTimerange(dateFormatTz) .then((resp) => { @@ -381,6 +377,6 @@ JobSelector.propTypes = { globalState: PropTypes.object, jobSelectService: PropTypes.object, selectedJobIds: PropTypes.array, - singleSelection: PropTypes.string, - timeseriesOnly: PropTypes.string + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js deleted file mode 100644 index 49c5140acd15a..0000000000000 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_react_wrapper_directive.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * AngularJS directive wrapper for rendering Job Selector React component. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import _ from 'lodash'; - -import { JobSelector } from './job_selector'; -import { getSelectedJobIds } from './job_select_service_utils'; -import { BehaviorSubject } from 'rxjs'; -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module - .directive('mlJobSelectorReactWrapper', function (globalState, config, mlJobSelectService) { - function link(scope, element, attrs) { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - - const props = { - config, - globalState, - jobSelectService: mlJobSelectService, - selectedJobIds: jobIds, - selectedGroups, - timeseriesOnly: attrs.timeseriesonly, - singleSelection: attrs.singleselection - }; - - ReactDOM.render(React.createElement(JobSelector, props), - element[0] - ); - - element.on('$destroy', () => { - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - } - - return { - scope: false, - link, - }; - }) - .service('mlJobSelectService', function (globalState) { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - const mlJobSelectService = new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false }); - - // Subscribe to changes to globalState and trigger - // a mlJobSelectService update if the job selection changed. - globalState.on('save_with_changes', () => { - const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); - const oldSelectedJobIds = mlJobSelectService.getValue().selection; - - if (newJobIds && !(_.isEqual(oldSelectedJobIds, newJobIds))) { - mlJobSelectService.next({ selection: newJobIds, groups: newSelectedGroups }); - } - }); - - return mlJobSelectService; - }); diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js index ab8c024bae7f3..a754fbfab5ca6 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.js @@ -232,7 +232,7 @@ export function JobSelectorTable({ return ( {jobs.length === 0 && } - {jobs.length !== 0 && singleSelection === 'true' && renderJobsTable()} + {jobs.length !== 0 && singleSelection === true && renderJobsTable()} {jobs.length !== 0 && singleSelection === undefined && renderTabs()} ); @@ -244,6 +244,6 @@ JobSelectorTable.propTypes = { jobs: PropTypes.array, onSelection: PropTypes.func.isRequired, selectedIds: PropTypes.array.isRequired, - singleSelection: PropTypes.string, - timeseriesOnly: PropTypes.string + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool }; diff --git a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js index 044fa3bd4c4fe..af300e51eef99 100644 --- a/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js +++ b/x-pack/legacy/plugins/ml/public/components/job_selector/job_selector_table/job_selector_table.test.js @@ -109,21 +109,21 @@ describe('JobSelectorTable', () => { describe('Single Selection', () => { test('Does not render tabs', () => { - const singleSelectionProps = { ...props, singleSelection: 'true' }; + const singleSelectionProps = { ...props, singleSelection: true }; const { queryByRole } = render(); const tabs = queryByRole('tab'); expect(tabs).toBeNull(); }); test('incoming selectedId is selected in the table', () => { - const singleSelectionProps = { ...props, singleSelection: 'true' }; + const singleSelectionProps = { ...props, singleSelection: true }; const { getByTestId } = render(); const radioButton = getByTestId('price-by-day-radio-button'); expect(radioButton.firstChild.checked).toEqual(true); }); test('job cannot be selected if it is not a single metric viewer job', () => { - const timeseriesOnlyProps = { ...props, singleSelection: 'true', timeseriesOnly: 'true' }; + const timeseriesOnlyProps = { ...props, singleSelection: true, timeseriesOnly: true }; const { getByTestId } = render(); const radioButton = getByTestId('non-timeseries-job-radio-button'); expect(radioButton.firstChild.disabled).toEqual(true); @@ -179,4 +179,3 @@ describe('JobSelectorTable', () => { }); }); - diff --git a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap index d9b6f0cf4d2a2..217aa113fba4d 100644 --- a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/__snapshots__/filter_bar.test.js.snap @@ -28,7 +28,7 @@ exports[`FilterBar snapshot suggestions not shown 1`] = ` onClick={[Function]} onKeyDown={[Function]} onKeyUp={[Function]} - placeholder="Filter by influencer fields… (E.g. Test placeholder)" + placeholder="Test placeholder" spellCheck={false} style={ Object { @@ -93,7 +93,7 @@ exports[`FilterBar snapshot suggestions shown 1`] = ` onClick={[Function]} onKeyDown={[Function]} onKeyUp={[Function]} - placeholder="Filter by influencer fields… (E.g. Test placeholder)" + placeholder="Test placeholder" spellCheck={false} style={ Object { diff --git a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/filter_bar.js b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/filter_bar.js index 5867fae3bda5a..19b170485817f 100644 --- a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/filter_bar.js +++ b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/filter_bar/filter_bar.js @@ -9,7 +9,6 @@ import PropTypes from 'prop-types'; import { Suggestions } from '../suggestions'; import { ClickOutside } from '../click_outside'; import { EuiFieldSearch, EuiProgress, keyCodes } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; export class FilterBar extends Component { state = { @@ -165,17 +164,7 @@ export class FilterBar extends Component { style={{ backgroundImage: 'none' }} - placeholder={i18n.translate( - 'xpack.ml.explorer.kueryBar.filterPlaceholder', - { - defaultMessage: - 'Filter by influencer fields… (E.g. {queryExample})', - values: { - queryExample: - `${this.props.placeholder}` - } - } - )} + placeholder={this.props.placeholder} inputRef={node => { if (node) { this.inputRef = node; diff --git a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/utils.js b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/utils.js index 04bd60d72e272..041be6a267d10 100644 --- a/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/utils.js +++ b/x-pack/legacy/plugins/ml/public/components/kql_filter_bar/utils.js @@ -81,7 +81,7 @@ export function getKqlQueryValues(inputValue, indexPattern) { } return { - influencersFilterQuery: query, + filterQuery: query, filteredFields, queryString: inputValue, isAndOperator, diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js index 7c8764c45b964..dcdaec159cc0a 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js @@ -12,11 +12,6 @@ import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); import 'ui/directives/kbn_href'; -import chrome from 'ui/chrome'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; - -import { NavigationMenuContext } from '../../util/context_utils'; import { NavigationMenu } from './navigation_menu'; @@ -25,12 +20,7 @@ module.directive('mlNavMenu', function () { restrict: 'E', transclude: true, link: function (scope, element, attrs) { - ReactDOM.render( - - - , - element[0] - ); + ReactDOM.render(, element[0]); element.on('$destroy', () => { ReactDOM.unmountComponentAtNode(element[0]); diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx index 54f24d0765f5c..b93f438b469df 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/top_nav/top_nav.tsx @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useContext, useState, useEffect } from 'react'; +import React, { FC, Fragment, useState, useEffect } from 'react'; import { EuiSuperDatePicker } from '@elastic/eui'; import { TimeHistory, TimeRange } from 'ui/timefilter/time_history'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; -import { NavigationMenuContext } from '../../../util/context_utils'; +import { useUiContext } from '../../../contexts/ui/use_ui_context'; interface Duration { start: string; @@ -28,9 +28,8 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistory) { } export const TopNav: FC = () => { - const navigationMenuContext = useContext(NavigationMenuContext); - const timefilter = navigationMenuContext.timefilter; - const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(navigationMenuContext.timeHistory); + const { chrome, timefilter, timeHistory } = useUiContext(); + const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(timeHistory); const [refreshInterval, setRefreshInterval] = useState(timefilter.getRefreshInterval()); const [time, setTime] = useState(timefilter.getTime()); @@ -42,7 +41,7 @@ export const TopNav: FC = () => { timefilter.isTimeRangeSelectorEnabled ); - const dateFormat = navigationMenuContext.chrome.getUiSettingsClient().get('dateFormat'); + const dateFormat = chrome.getUiSettingsClient().get('dateFormat'); useEffect(() => { timefilter.on('refreshIntervalUpdate', timefilterUpdateListener); diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap b/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap index 44b0091971f8a..2eda2462a6aed 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/__snapshots__/validate_job_view.test.js.snap @@ -1,167 +1,173 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ValidateJob renders button and modal with a message 1`] = ` -
- - - - +
+ - } - > - - - - - - + + } + > + - - , + "fieldName": "airline", + "id": "over_field_low_cardinality", + "status": "warning", + "text": "Cardinality of over_field \\"airline\\" is low and therefore less suitable for population analysis.", + "url": "https://www.elastic.co/blog/sizing-machine-learning-with-elasticsearch", } } /> - - -
+ + + + + + + , + } + } + /> + +
+
+ `; exports[`ValidateJob renders the button 1`] = ` -
- - - -
+ +
+ + + +
+
`; exports[`ValidateJob renders the button and modal with a success message 1`] = ` -
- - - - - } - > - + +
+ - - - - - , + + - - -
+ /> + } + > + + + + + + + , + } + } + /> + +
+
+ `; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts new file mode 100644 index 0000000000000..43e0a5f3eac78 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.d.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FC } from 'react'; +declare const ValidateJob: FC<{ + getJobConfig: any; + getDuration: any; + mlJobService: any; + embedded?: boolean; + setIsValid?: (valid: boolean) => void; + idFilterList?: string[]; +}>; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js index d4ad8f946924b..88e85cc5e530f 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.js @@ -10,7 +10,8 @@ import PropTypes from 'prop-types'; import React, { - Component + Component, + Fragment } from 'react'; import { @@ -172,13 +173,19 @@ class ValidateJob extends Component { this.state = getDefaultState(); } + componentDidMount() { + if(this.props.embedded === true) { + this.validate(); + } + } + closeModal = () => { const newState = getDefaultState(); newState.ui.iconType = this.state.ui.iconType; this.setState(newState); }; - openModal = () => { + validate = () => { const job = this.props.getJobConfig(); const getDuration = this.props.getDuration; const duration = (typeof getDuration === 'function') ? getDuration() : undefined; @@ -204,6 +211,9 @@ class ValidateJob extends Component { data, title: job.job_id }); + if (typeof this.props.setIsValid === 'function') { + this.props.setIsValid(data.messages.some(m => m.status === VALIDATION_STATUS.ERROR) === false); + } }); // wait for 250ms before triggering the loading indicator @@ -231,62 +241,79 @@ class ValidateJob extends Component { // default to false if not explicitly set to true const isCurrentJobConfig = (this.props.isCurrentJobConfig !== true) ? false : true; const isDisabled = (this.props.isDisabled !== true) ? false : true; + const embedded = (this.props.embedded === true); + const idFilterList = this.props.idFilterList || []; return ( -
- - - - - {!isDisabled && this.state.ui.isModalVisible && - } - > - {this.state.data.messages.map( - (m, i) => - )} - - - - + + {embedded === false && +
+ - - - ) - }} + id="xpack.ml.validateJob.validateJobButtonLabel" + defaultMessage="Validate Job" /> - - + + + {!isDisabled && this.state.ui.isModalVisible && + } + > + { + this.state.data.messages + .filter(m => idFilterList.includes(m.id) === false) + .map((m, i) => ) + } + + + + + + + + ) + }} + /> + + + } +
+ } + {embedded === true && +
+ { + this.state.data.messages + .filter(m => idFilterList.includes(m.id) === false) + .map((m, i) => ) + } +
} -
+ ); } } @@ -297,7 +324,10 @@ ValidateJob.propTypes = { getJobConfig: PropTypes.func.isRequired, isCurrentJobConfig: PropTypes.bool, isDisabled: PropTypes.bool, - mlJobService: PropTypes.object.isRequired + mlJobService: PropTypes.object.isRequired, + embedded: PropTypes.bool, + setIsValid: PropTypes.func, + idFilterList: PropTypes.array, }; export { ValidateJob }; diff --git a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js index 8ec8e9af3c319..b87211f01ca03 100644 --- a/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js +++ b/x-pack/legacy/plugins/ml/public/components/validate_job/validate_job_view.test.js @@ -42,7 +42,7 @@ describe('ValidateJob', () => { }); test('renders the button and modal with a success message', () => { - test1.wrapper.instance().openModal(); + test1.wrapper.instance().validate(); test1.p.then(() => { test1.wrapper.update(); expect(test1.wrapper).toMatchSnapshot(); @@ -63,7 +63,7 @@ describe('ValidateJob', () => { }); test('renders button and modal with a message', () => { - test2.wrapper.instance().openModal(); + test2.wrapper.instance().validate(); test2.p.then(() => { test2.wrapper.update(); expect(test2.wrapper).toMatchSnapshot(); diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_pattern.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_pattern.ts new file mode 100644 index 0000000000000..a9f30a904d54b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_pattern.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from 'ui/index_patterns'; + +export const indexPatternMock = ({ + id: 'the-index-pattern-id', + title: 'the-index-pattern-title', + fields: [], +} as unknown) as IndexPattern; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_patterns.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_patterns.ts new file mode 100644 index 0000000000000..778872d8183ee --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/index_patterns.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPatterns } from 'ui/index_patterns'; + +export const indexPatternsMock = (new (class { + fieldFormats = []; + config = {}; + savedObjectsClient = {}; + refreshSavedObjectsCache = {}; + clearCache = jest.fn(); + get = jest.fn(); + getDefault = jest.fn(); + getFields = jest.fn(); + getIds = jest.fn(); + getTitles = jest.fn(); + make = jest.fn(); +})() as unknown) as IndexPatterns; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_config.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_config.ts new file mode 100644 index 0000000000000..247764a197bbf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_config.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const kibanaConfigMock = { + get: (key: string): T => ({} as T), + has: (key: string) => false, + set: (key: string, value: any) => {}, +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_context_value.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_context_value.ts new file mode 100644 index 0000000000000..8f54c7f1ad656 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/kibana_context_value.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { indexPatternMock } from './index_pattern'; +import { indexPatternsMock } from './index_patterns'; +import { kibanaConfigMock } from './kibana_config'; +import { savedSearchMock } from './saved_search'; + +export const kibanaContextValueMock = { + combinedQuery: { + query: 'the-query-string', + language: 'the-query-language', + }, + currentIndexPattern: indexPatternMock, + currentSavedSearch: savedSearchMock, + indexPatterns: indexPatternsMock, + kbnBaseUrl: 'url', + kibanaConfig: kibanaConfigMock, +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/saved_search.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/saved_search.ts new file mode 100644 index 0000000000000..311e6688f7aa9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/__mocks__/saved_search.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const savedSearchMock = { + id: 'the-saved-search-id', + title: 'the-saved-search-title', + searchSource: {}, + columns: [], + sort: [], + destroy: () => {}, +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/index.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/index.ts new file mode 100644 index 0000000000000..629e52797fb42 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + KibanaContext, + KibanaContextValue, + SavedSearchQuery, + KibanaConfigTypeFix, +} from './kibana_context'; +export { useKibanaContext } from './use_kibana_context'; +export { useCurrentIndexPattern } from './use_current_index_pattern'; +export { useCurrentSavedSearch } from './use_current_saved_search'; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/kibana_context.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/kibana_context.ts new file mode 100644 index 0000000000000..afa2a53ef5dcf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/kibana_context.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { KibanaConfig } from 'src/legacy/server/kbn_server'; +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; + +import { IndexPattern, IndexPatterns } from 'ui/index_patterns'; + +// set() method is missing in original d.ts +export interface KibanaConfigTypeFix extends KibanaConfig { + set(key: string, value: any): void; +} + +export interface KibanaContextValue { + combinedQuery: any; + currentIndexPattern: IndexPattern; + currentSavedSearch: SavedSearch; + indexPatterns: IndexPatterns; + kbnBaseUrl: string; + kibanaConfig: KibanaConfigTypeFix; +} + +export type SavedSearchQuery = object; + +// This context provides dependencies which can be injected +// via angularjs only (like services, currentIndexPattern etc.). +// Because we cannot just import these dependencies, the default value +// for the context is just {} and of type `Partial` +// for the angularjs based dependencies. Therefore, the +// actual dependencies are set like we did previously with KibanaContext +// in the wrapping angularjs directive. In the custom hook we check if +// the dependencies are present with error reporting if they weren't +// added properly. That's why in tests, these custom hooks must not +// be mocked, instead ` needs +// to be used. This guarantees that we have both properly set up +// TypeScript support and runtime checks for these dependencies. +// Multiple custom hooks can be created to access subsets of +// the overall context value if necessary too, +// see useCurrentIndexPattern() for example. +export const KibanaContext = React.createContext>({}); diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_index_pattern.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_index_pattern.ts new file mode 100644 index 0000000000000..62be409882dff --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_index_pattern.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext } from './kibana_context'; + +export const useCurrentIndexPattern = () => { + const context = useContext(KibanaContext); + + if (context.currentIndexPattern === undefined) { + throw new Error('currentIndexPattern is undefined'); + } + + return context.currentIndexPattern; +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_saved_search.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_saved_search.ts new file mode 100644 index 0000000000000..1147b905f237e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_current_saved_search.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext } from './kibana_context'; + +export const useCurrentSavedSearch = () => { + const context = useContext(KibanaContext); + + if (context.currentSavedSearch === undefined) { + throw new Error('currentSavedSearch is undefined'); + } + + return context.currentSavedSearch; +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/kibana/use_kibana_context.ts b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_kibana_context.ts new file mode 100644 index 0000000000000..1cc670875dff3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/kibana/use_kibana_context.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext, KibanaContextValue } from './kibana_context'; + +export const useKibanaContext = () => { + const context = useContext(KibanaContext); + + if ( + context.combinedQuery === undefined || + context.currentIndexPattern === undefined || + context.currentSavedSearch === undefined || + context.indexPatterns === undefined || + context.kbnBaseUrl === undefined || + context.kibanaConfig === undefined + ) { + throw new Error('required attribute is undefined'); + } + + return context as KibanaContextValue; +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/mocks.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/mocks.ts new file mode 100644 index 0000000000000..fb44ee602804d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/mocks.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const uiChromeMock = { + getBasePath: () => 'basePath', + getUiSettingsClient: () => { + return { + get: (key: string) => { + switch (key) { + case 'dateFormat': + case 'timepicker:timeDefaults': + return {}; + case 'timepicker:refreshIntervalDefaults': + return { pause: false, value: 0 }; + default: + throw new Error(`Unexpected config key: ${key}`); + } + }, + }; + }, +}; + +export const uiTimefilterMock = { + getRefreshInterval: () => '30s', + getTime: () => ({ from: 0, to: 0 }), + on: (event: string, reload: () => void) => {}, +}; + +export const uiTimeHistoryMock = { + get: () => [{ from: 0, to: 0 }], +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_chrome_context.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_chrome_context.ts new file mode 100644 index 0000000000000..929a9454f9682 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_chrome_context.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiChromeMock } from './mocks'; + +export const useUiChromeContext = () => uiChromeMock; diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_context.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_context.ts new file mode 100644 index 0000000000000..ec2a812e9fe78 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/__mocks__/use_ui_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uiChromeMock, uiTimefilterMock, uiTimeHistoryMock } from './mocks'; + +export const useUiContext = () => ({ + chrome: uiChromeMock, + timefilter: uiTimefilterMock, + timeHistory: uiTimeHistoryMock, +}); diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/index.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/index.ts new file mode 100644 index 0000000000000..18cbb49181e38 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// We only export UiContext but not any custom hooks, because if we'd import them +// from here, mocking the hook from jest tests won't work as expected. +export { UiContext } from './ui_context'; diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/ui_context.tsx b/x-pack/legacy/plugins/ml/public/contexts/ui/ui_context.tsx new file mode 100644 index 0000000000000..52a90f795e5bd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/ui_context.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import chrome from 'ui/chrome'; +import { timefilter } from 'ui/timefilter'; +import { timeHistory } from 'ui/timefilter/time_history'; + +// This provides ui/* based imports via React Context. +// Because these dependencies can use regular imports, +// they are just passed on as the default value +// of the Context which means it's not necessary +// to add ... to the +// wrapping angular directive, reducing a lot of boilerplate. +// The custom hooks like useUiContext() need to be mocked in +// tests because we rely on the properly set up default value. +// Different custom hooks can be created to access parts only +// from the full context value, see useUiChromeContext() as an example. +export const UiContext = React.createContext({ + chrome, + timefilter, + timeHistory, +}); diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_chrome_context.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_chrome_context.ts new file mode 100644 index 0000000000000..1765bdb23df7f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_chrome_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { UiContext } from './ui_context'; + +export const useUiChromeContext = () => { + return useContext(UiContext).chrome; +}; diff --git a/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_context.ts b/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_context.ts new file mode 100644 index 0000000000000..156a42d9f3c50 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/contexts/ui/use_ui_context.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { UiContext } from './ui_context'; + +export const useUiContext = () => { + return useContext(UiContext); +}; diff --git a/x-pack/legacy/plugins/ml/public/data_frame/common/index.ts b/x-pack/legacy/plugins/ml/public/data_frame/common/index.ts index 7610843fa53a6..6c86e3d25321a 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/common/index.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/common/index.ts @@ -16,13 +16,6 @@ export { MAX_COLUMNS, } from './fields'; export { DropDownLabel, DropDownOption, Label } from './dropdown'; -export { - KibanaContext, - KibanaContextValue, - isKibanaContext, - NullableKibanaContextValue, - SavedSearchQuery, -} from './kibana_context'; export { isTransformIdValid, refreshTransformList$, diff --git a/x-pack/legacy/plugins/ml/public/data_frame/common/kibana_context.ts b/x-pack/legacy/plugins/ml/public/data_frame/common/kibana_context.ts deleted file mode 100644 index 94e8986753f50..0000000000000 --- a/x-pack/legacy/plugins/ml/public/data_frame/common/kibana_context.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -import { IndexPattern } from 'ui/index_patterns'; - -export interface KibanaContextValue { - combinedQuery: any; - currentIndexPattern: IndexPattern; - currentSavedSearch: any; - indexPatterns: any; - kbnBaseUrl: string; - kibanaConfig: any; -} - -export type SavedSearchQuery = object; - -// Because we're only getting the actual contextvalue within a wrapping angular component, -// we need to initialize here with `null` because TypeScript doesn't allow createContext() -// without a default value. The nullable union type takes care of allowing -// the actual required type and `null`. -export type NullableKibanaContextValue = KibanaContextValue | null; -export const KibanaContext = React.createContext(null); - -export function isKibanaContext(arg: any): arg is KibanaContextValue { - return ( - arg.combinedQuery !== undefined && - arg.currentIndexPattern !== undefined && - arg.currentSavedSearch !== undefined && - arg.indexPatterns !== undefined && - typeof arg.kbnBaseUrl === 'string' && - arg.kibanaConfig !== undefined - ); -} diff --git a/x-pack/legacy/plugins/ml/public/data_frame/common/navigation.ts b/x-pack/legacy/plugins/ml/public/data_frame/common/navigation.ts index cc88e3ba2dee2..746193f1e1262 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/common/navigation.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/common/navigation.ts @@ -6,8 +6,6 @@ import rison from 'rison-node'; -import chrome from 'ui/chrome'; - export function moveToDataFrameWizard() { window.location.href = '#/data_frames/new_transform'; } @@ -16,7 +14,7 @@ export function moveToDataFrameTransformList() { window.location.href = '#/data_frames'; } -export function moveToDiscover(indexPatternId: string, kbnBaseUrl: string) { +export function moveToDiscover(indexPatternId: string, baseUrl: string) { const _g = rison.encode({}); // Add the index pattern ID to the appState part of the URL. @@ -24,7 +22,6 @@ export function moveToDiscover(indexPatternId: string, kbnBaseUrl: string) { index: indexPatternId, }); - const baseUrl = chrome.addBasePath(kbnBaseUrl); const hash = `#/discover?_g=${_g}&_a=${_a}`; window.location.href = `${baseUrl}${hash}`; diff --git a/x-pack/legacy/plugins/ml/public/data_frame/common/request.test.ts b/x-pack/legacy/plugins/ml/public/data_frame/common/request.test.ts index f47fc47938c73..ae15ed11b8a23 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/common/request.test.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/common/request.test.ts @@ -133,8 +133,11 @@ describe('Data Frame: Common', () => { const pivotState: StepDefineExposedState = { aggList: { 'the-agg-name': agg }, groupByList: { 'the-group-by-name': groupBy }, - isAdvancedEditorEnabled: false, - search: 'the-query', + isAdvancedPivotEditorEnabled: false, + isAdvancedSourceEditorEnabled: false, + sourceConfigUpdated: false, + searchString: 'the-query', + searchQuery: 'the-search-query', valid: true, }; const transformDetailsState: StepDetailsExposedState = { @@ -164,7 +167,7 @@ describe('Data Frame: Common', () => { }, source: { index: ['the-index-pattern-title'], - query: { query_string: { default_operator: 'AND', query: 'the-query' } }, + query: { query_string: { default_operator: 'AND', query: 'the-search-query' } }, }, }); }); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/common/request.ts b/x-pack/legacy/plugins/ml/public/data_frame/common/request.ts index b22840802d811..ecef6fcc2f740 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/common/request.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/common/request.ts @@ -9,6 +9,7 @@ import { DefaultOperator } from 'elasticsearch'; import { IndexPattern } from 'ui/index_patterns'; import { dictionaryToArray } from '../../../common/types/common'; +import { SavedSearchQuery } from '../../contexts/kibana'; import { StepDefineExposedState } from '../pages/data_frame_new_pivot/components/step_define/step_define_form'; import { StepDetailsExposedState } from '../pages/data_frame_new_pivot/components/step_details/step_details_form'; @@ -24,7 +25,6 @@ import { import { PivotAggsConfig } from './pivot_aggs'; import { DateHistogramAgg, HistogramAgg, TermsAgg } from './pivot_group_by'; -import { SavedSearchQuery } from './kibana_context'; import { PreviewRequestBody, CreateRequestBody } from './transform'; export interface SimpleQuery { @@ -123,7 +123,7 @@ export function getCreateRequestBody( const request: CreateRequestBody = { ...getPreviewRequestBody( indexPatternTitle, - getPivotQuery(pivotState.search), + getPivotQuery(pivotState.searchQuery), dictionaryToArray(pivotState.groupByList), dictionaryToArray(pivotState.aggList) ), diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/access_denied/directive.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/access_denied/directive.tsx index ba43564d1573b..e16d5736db6ec 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/access_denied/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/access_denied/directive.tsx @@ -14,13 +14,8 @@ import uiChrome from 'ui/chrome'; const module = uiModules.get('apps/ml', ['react']); import { I18nContext } from 'ui/i18n'; -import chrome from 'ui/chrome'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { InjectorService } from '../../../../common/types/angular'; -import { NavigationMenuContext } from '../../../util/context_utils'; - import { Page } from './page'; module.directive('mlDataFrameAccessDenied', ($injector: InjectorService) => { @@ -41,9 +36,7 @@ module.directive('mlDataFrameAccessDenied', ($injector: InjectorService) => { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap index 1bdb1ee05349e..9179d9c8bcf61 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap @@ -5,16 +5,42 @@ exports[`Data Frame: Minimal initialization 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/expanded_row.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/expanded_row.tsx index a0fec331f2afe..f765059a9d95f 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/expanded_row.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/expanded_row.tsx @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; - import React from 'react'; import { EuiBadge, EuiText } from '@elastic/eui'; +import { idx } from '@kbn/elastic-idx'; import { getSelectableFields, EsDoc } from '../../../../common'; @@ -19,7 +18,8 @@ interface ExpandedRowProps { export const ExpandedRow: React.SFC = ({ item }) => { const keys = getSelectableFields([item]); const list = keys.map(k => { - const value = get(item._source, k, ''); + // split the attribute key string and use reduce with an idx check to access nested attributes. + const value = k.split('.').reduce((obj, i) => idx(obj, _ => _[i]), item._source) || ''; return ( {k}: diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.test.tsx index f827322629cc8..0e8d9809274f1 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.test.tsx @@ -7,7 +7,10 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { getPivotQuery, KibanaContext } from '../../../../common'; +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; + +import { getPivotQuery } from '../../../../common'; import { SourceIndexPreview } from './source_index_preview'; @@ -19,12 +22,6 @@ jest.mock('react', () => { describe('Data Frame: ', () => { test('Minimal initialization', () => { - const currentIndexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - fields: [], - }; - const props = { query: getPivotQuery('the-query'), }; @@ -33,16 +30,7 @@ describe('Data Frame: ', () => { // with the Provider being the outer most component. const wrapper = shallow(
- +
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx index 71602fa0f2eb9..2841f2e28492c 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent, useContext, useState } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; @@ -43,13 +43,13 @@ import { KBN_FIELD_TYPES } from '../../../../../../common/constants/field_types' import { Dictionary } from '../../../../../../common/types/common'; import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; +import { useCurrentIndexPattern } from '../../../../../contexts/kibana'; + import { - isKibanaContext, toggleSelectedField, EsDoc, EsFieldName, MAX_COLUMNS, - KibanaContext, PivotQuery, } from '../../../../common'; @@ -100,13 +100,7 @@ interface Props { export const SourceIndexPreview: React.SFC = React.memo(({ cellClick, query }) => { const [clearTable, setClearTable] = useState(false); - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } - - const indexPattern = kibanaContext.currentIndexPattern; + const indexPattern = useCurrentIndexPattern(); const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/use_source_index_data.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/use_source_index_data.ts index 1e1ecea8f62fb..bc7d0ada99b19 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/use_source_index_data.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/use_source_index_data.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; - import React, { useEffect, useState } from 'react'; import { SearchResponse } from 'elasticsearch'; -import { IndexPattern } from 'ui/index_patterns'; +import { idx } from '@kbn/elastic-idx'; + +import { StaticIndexPattern } from 'ui/index_patterns'; import { ml } from '../../../../../services/ml_api_service'; @@ -39,7 +39,7 @@ export interface UseSourceIndexDataReturnType { } export const useSourceIndexData = ( - indexPattern: IndexPattern, + indexPattern: StaticIndexPattern, query: PivotQuery, selectedFields: EsFieldName[], setSelectedFields: React.Dispatch> @@ -82,7 +82,7 @@ export const useSourceIndexData = ( [key: string]: any; }; flattenedFields.forEach(ff => { - item[ff] = get(doc._source, ff); + item[ff] = idx(doc._source, _ => _[ff]); if (item[ff] === undefined) { item[ff] = doc._source[`"${ff}"`]; } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/__snapshots__/step_create_form.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/__snapshots__/step_create_form.test.tsx.snap index f1e413a03dde8..26844efb711e5 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/__snapshots__/step_create_form.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/__snapshots__/step_create_form.test.tsx.snap @@ -5,16 +5,42 @@ exports[`Data Frame: Minimal initialization 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.test.tsx index 9705fe3aef774..c99d2c657e71f 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.test.tsx @@ -7,10 +7,13 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaContext } from '../../../../common'; +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; import { StepCreateForm } from './step_create_form'; +jest.mock('../../../../../contexts/ui/use_ui_chrome_context'); + // workaround to make React.memo() work with enzyme jest.mock('react', () => { const r = jest.requireActual('react'); @@ -27,26 +30,11 @@ describe('Data Frame: ', () => { onChange() {}, }; - const currentIndexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - fields: [], - }; - // Using a wrapping
element because shallow() would fail // with the Provider being the outer most component. const wrapper = shallow(
- +
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.tsx index 0037ffe4396e1..2b40edea7a8f3 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_create/step_create_form.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, SFC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, SFC, useEffect, useState } from 'react'; +import { idx } from '@kbn/elastic-idx'; import { i18n } from '@kbn/i18n'; import { toastNotifications } from 'ui/notify'; @@ -30,14 +31,11 @@ import { } from '@elastic/eui'; import { ml } from '../../../../../services/ml_api_service'; +import { useKibanaContext } from '../../../../../contexts/kibana/use_kibana_context'; +import { useUiChromeContext } from '../../../../../contexts/ui/use_ui_chrome_context'; import { PROGRESS_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list'; -import { - isKibanaContext, - KibanaContext, - moveToDataFrameTransformList, - moveToDiscover, -} from '../../../../common'; +import { moveToDataFrameTransformList, moveToDiscover } from '../../../../common'; export interface StepDetailsExposedState { created: boolean; @@ -72,11 +70,8 @@ export const StepCreateForm: SFC = React.memo( undefined ); - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } + const kibanaContext = useKibanaContext(); + const baseUrl = useUiChromeContext().addBasePath(kibanaContext.kbnBaseUrl); useEffect(() => { onChange({ created, started, indexPatternId }); @@ -155,6 +150,18 @@ export const StepCreateForm: SFC = React.memo( const id = await newIndexPattern.create(); + // id returns false if there's a duplicate index pattern. + if (id === false) { + toastNotifications.addDanger( + i18n.translate('xpack.ml.dataframe.stepCreateForm.duplicateIndexPatternErrorMessage', { + defaultMessage: + 'An error occurred creating the Kibana index pattern {indexPatternName}: The index pattern already exists.', + values: { indexPatternName }, + }) + ); + return; + } + // check if there's a default index pattern, if not, // set the newly created one as the default index pattern. if (!kibanaContext.kibanaConfig.get('defaultIndex')) { @@ -162,7 +169,7 @@ export const StepCreateForm: SFC = React.memo( } toastNotifications.addSuccess( - i18n.translate('xpack.ml.dataframe.stepCreateForm.reateIndexPatternSuccessMessage', { + i18n.translate('xpack.ml.dataframe.stepCreateForm.createIndexPatternSuccessMessage', { defaultMessage: 'Kibana index pattern {indexPatternName} created successfully.', values: { indexPatternName }, }) @@ -191,7 +198,12 @@ export const StepCreateForm: SFC = React.memo( try { const stats = await ml.dataFrame.getDataFrameTransformsStats(transformId); if (stats && Array.isArray(stats.transforms) && stats.transforms.length > 0) { - const percent = Math.round(stats.transforms[0].state.progress.percent_complete); + const percent = Math.round( + idx( + stats, + _ => _.transforms[0].checkpointing.next.checkpoint_progress.percent_complete + ) || 0 + ); setProgressPercentComplete(percent); if (percent >= 100) { clearInterval(interval); @@ -385,7 +397,7 @@ export const StepCreateForm: SFC = React.memo( defaultMessage: 'Use Discover to explore the data frame pivot.', } )} - onClick={() => moveToDiscover(indexPatternId, kibanaContext.kbnBaseUrl)} + onClick={() => moveToDiscover(indexPatternId, baseUrl)} /> )} diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/pivot_preview.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/pivot_preview.test.tsx.snap index e97a9695101d4..192d9d2cff625 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/pivot_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/pivot_preview.test.tsx.snap @@ -5,16 +5,42 @@ exports[`Data Frame: Minimal initialization 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_form.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_form.test.tsx.snap index 5c8717ba9e0e2..033eea8cf290e 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_form.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_form.test.tsx.snap @@ -5,16 +5,42 @@ exports[`Data Frame: Minimal initialization 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_summary.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_summary.test.tsx.snap index 32210592b79e3..c3b75584f0a51 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_summary.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/__snapshots__/step_define_summary.test.tsx.snap @@ -5,16 +5,42 @@ exports[`Data Frame: Minimal initialization 1`] = ` @@ -39,8 +65,11 @@ exports[`Data Frame: Minimal initialization 1`] = ` }, } } - isAdvancedEditorEnabled={false} - search="the-query" + isAdvancedPivotEditorEnabled={false} + isAdvancedSourceEditorEnabled={false} + searchQuery="the-search-query" + searchString="the-query" + sourceConfigUpdated={false} valid={true} /> diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/common.test.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/common.test.ts index 45280fee58a50..f96bab17df715 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/common.test.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/common.test.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IndexPattern } from 'ui/index_patterns'; - import { getPreviewRequestBody, PivotAggsConfig, @@ -16,13 +14,14 @@ import { } from '../../../../common'; import { getPivotPreviewDevConsoleStatement, getPivotDropdownOptions } from './common'; +import { IndexPattern } from 'ui/index_patterns'; describe('Data Frame: Define Pivot Common', () => { test('getPivotDropdownOptions()', () => { // The field name includes the characters []> as well as a leading and ending space charcter // which cannot be used for aggregation names. The test results verifies that the characters // should still be present in field and dropDownName values, but should be stripped for aggName values. - const indexPattern: IndexPattern = { + const indexPattern = { id: 'the-index-pattern-id', title: 'the-index-pattern-title', fields: [ @@ -34,7 +33,7 @@ describe('Data Frame: Define Pivot Common', () => { searchable: true, }, ], - }; + } as IndexPattern; const options = getPivotDropdownOptions(indexPattern); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.test.tsx index ca5a613eca334..a7350247c2dba 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.test.tsx @@ -7,9 +7,11 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; + import { getPivotQuery, - KibanaContext, PivotAggsConfig, PivotGroupByConfig, PIVOT_SUPPORTED_AGGS, @@ -26,12 +28,6 @@ jest.mock('react', () => { describe('Data Frame: ', () => { test('Minimal initialization', () => { - const currentIndexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - fields: [], - }; - const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, field: 'the-group-by-field', @@ -54,16 +50,7 @@ describe('Data Frame: ', () => { // with the Provider being the outer most component. const wrapper = shallow(
- +
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.tsx index 001cc5b046ddd..f82b69dcc0a83 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/pivot_preview.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { SFC, useContext, useEffect, useRef, useState } from 'react'; +import React, { SFC, useEffect, useRef, useState } from 'react'; import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; @@ -28,11 +28,11 @@ import { Dictionary, dictionaryToArray } from '../../../../../../common/types/co import { ES_FIELD_TYPES } from '../../../../../../common/constants/field_types'; import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; +import { useCurrentIndexPattern } from '../../../../../contexts/kibana'; + import { getFlattenedFields, - isKibanaContext, PreviewRequestBody, - KibanaContext, PivotAggsConfigDict, PivotGroupByConfig, PivotGroupByConfigDict, @@ -139,13 +139,8 @@ interface PivotPreviewProps { export const PivotPreview: SFC = React.memo(({ aggs, groupBy, query }) => { const [clearTable, setClearTable] = useState(false); - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } + const indexPattern = useCurrentIndexPattern(); - const indexPattern = kibanaContext.currentIndexPattern; const { dataFramePreviewData, dataFramePreviewMappings, diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.test.tsx index 03735a57169ad..e3f2e94e7cdd0 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.test.tsx @@ -7,8 +7,10 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; + import { - KibanaContext, PivotAggsConfigDict, PivotGroupByConfigDict, PIVOT_SUPPORTED_AGGS, @@ -24,26 +26,11 @@ jest.mock('react', () => { describe('Data Frame: ', () => { test('Minimal initialization', () => { - const currentIndexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - fields: [], - }; - // Using a wrapping
element because shallow() would fail // with the Provider being the outer most component. const wrapper = shallow(
- + {}} />
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.tsx index 6c2104be3d5e9..fea9f425a2582 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, SFC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, SFC, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; @@ -14,14 +14,12 @@ import { toastNotifications } from 'ui/notify'; import { EuiButton, EuiCodeEditor, - EuiConfirmModal, EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormHelpText, EuiFormRow, EuiLink, - EuiOverlayMask, EuiPanel, // @ts-ignore EuiSearchBar, @@ -35,15 +33,21 @@ import { AggListForm } from '../aggregation_list'; import { GroupByListForm } from '../group_by_list'; import { SourceIndexPreview } from '../source_index_preview'; import { PivotPreview } from './pivot_preview'; +// @ts-ignore: could not find declaration file for module +import { KqlFilterBar } from '../../../../../components/kql_filter_bar'; +import { SwitchModal } from './switch_modal'; + +import { + useKibanaContext, + KibanaContextValue, + SavedSearchQuery, +} from '../../../../../contexts/kibana'; import { AggName, DropDownLabel, getPivotQuery, getPreviewRequestBody, - isKibanaContext, - KibanaContext, - KibanaContextValue, PivotAggDict, PivotAggsConfig, PivotAggsConfigDict, @@ -52,7 +56,6 @@ import { PivotGroupByConfigDict, PivotSupportedGroupByAggs, PIVOT_SUPPORTED_AGGS, - SavedSearchQuery, } from '../../../../common'; import { getPivotDropdownOptions } from './common'; @@ -60,8 +63,11 @@ import { getPivotDropdownOptions } from './common'; export interface StepDefineExposedState { aggList: PivotAggsConfigDict; groupByList: PivotGroupByConfigDict; - isAdvancedEditorEnabled: boolean; - search: string | SavedSearchQuery; + isAdvancedPivotEditorEnabled: boolean; + isAdvancedSourceEditorEnabled: boolean; + searchString: string | SavedSearchQuery; + searchQuery: string | SavedSearchQuery; + sourceConfigUpdated: boolean; valid: boolean; } @@ -74,11 +80,17 @@ export function getDefaultStepDefineState( return { aggList: {} as PivotAggsConfigDict, groupByList: {} as PivotGroupByConfigDict, - isAdvancedEditorEnabled: false, - search: + isAdvancedPivotEditorEnabled: false, + isAdvancedSourceEditorEnabled: false, + searchString: kibanaContext.currentSavedSearch.id !== undefined ? kibanaContext.combinedQuery : defaultSearch, + searchQuery: + kibanaContext.currentSavedSearch.id !== undefined + ? kibanaContext.combinedQuery + : defaultSearch, + sourceConfigUpdated: false, valid: false, }; } @@ -187,27 +199,31 @@ interface Props { } export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } + const kibanaContext = useKibanaContext(); const indexPattern = kibanaContext.currentIndexPattern; const defaults = { ...getDefaultStepDefineState(kibanaContext), ...overrides }; // The search filter - const [search, setSearch] = useState(defaults.search); + const [searchString, setSearchString] = useState(defaults.searchString); + const [searchQuery, setSearchQuery] = useState(defaults.searchQuery); + const [useKQL] = useState(true); const addToSearch = (newSearch: string) => { - const currentDisplaySearch = search === defaultSearch ? emptySearch : search; - setSearch(`${currentDisplaySearch} ${newSearch}`.trim()); + const currentDisplaySearch = searchString === defaultSearch ? emptySearch : searchString; + setSearchString(`${currentDisplaySearch} ${newSearch}`.trim()); }; const searchHandler = (d: Record) => { - const newSearch = d.queryText === emptySearch ? defaultSearch : d.queryText; - setSearch(newSearch); + const { filterQuery, queryString } = d; + const newSearch = queryString === emptySearch ? defaultSearch : queryString; + const newSearchQuery = + filterQuery.match_all && Object.keys(filterQuery.match_all).length === 0 + ? defaultSearch + : filterQuery; + setSearchString(newSearch); + setSearchQuery(newSearchQuery); }; // The list of selected group by fields @@ -285,14 +301,30 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange const pivotAggsArr = dictionaryToArray(aggList); const pivotGroupByArr = dictionaryToArray(groupByList); - const pivotQuery = getPivotQuery(search); + const pivotQuery = useKQL ? getPivotQuery(searchQuery) : getPivotQuery(searchString); - // Advanced editor state + // Advanced editor for pivot config state const [isAdvancedEditorSwitchModalVisible, setAdvancedEditorSwitchModalVisible] = useState(false); - const [isAdvancedEditorApplyButtonEnabled, setAdvancedEditorApplyButtonEnabled] = useState(false); - const [isAdvancedEditorEnabled, setAdvancedEditorEnabled] = useState( - defaults.isAdvancedEditorEnabled + const [ + isAdvancedPivotEditorApplyButtonEnabled, + setAdvancedPivotEditorApplyButtonEnabled, + ] = useState(false); + const [isAdvancedPivotEditorEnabled, setAdvancedPivotEditorEnabled] = useState( + defaults.isAdvancedPivotEditorEnabled + ); + // Advanced editor for source config state + const [sourceConfigUpdated, setSourceConfigUpdated] = useState(defaults.sourceConfigUpdated); + const [ + isAdvancedSourceEditorSwitchModalVisible, + setAdvancedSourceEditorSwitchModalVisible, + ] = useState(false); + const [isAdvancedSourceEditorEnabled, setAdvancedSourceEditorEnabled] = useState( + defaults.isAdvancedSourceEditorEnabled ); + const [ + isAdvancedSourceEditorApplyButtonEnabled, + setAdvancedSourceEditorApplyButtonEnabled, + ] = useState(false); const previewRequest = getPreviewRequestBody( indexPattern.title, @@ -300,13 +332,35 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange pivotGroupByArr, pivotAggsArr ); + // pivot config const stringifiedPivotConfig = JSON.stringify(previewRequest.pivot, null, 2); const [advancedEditorConfigLastApplied, setAdvancedEditorConfigLastApplied] = useState( stringifiedPivotConfig ); const [advancedEditorConfig, setAdvancedEditorConfig] = useState(stringifiedPivotConfig); + // source config + const stringifiedSourceConfig = JSON.stringify(previewRequest.source.query, null, 2); + const [ + advancedEditorSourceConfigLastApplied, + setAdvancedEditorSourceConfigLastApplied, + ] = useState(stringifiedSourceConfig); + const [advancedEditorSourceConfig, setAdvancedEditorSourceConfig] = useState( + stringifiedSourceConfig + ); - const applyAdvancedEditorChanges = () => { + const applyAdvancedSourceEditorChanges = () => { + const sourceConfig = JSON.parse(advancedEditorSourceConfig); + const prettySourceConfig = JSON.stringify(sourceConfig, null, 2); + // Switched to editor so we clear out the search string as the bar won't be visible + setSearchString(emptySearch); + setSearchQuery(sourceConfig); + setSourceConfigUpdated(true); + setAdvancedEditorSourceConfig(prettySourceConfig); + setAdvancedEditorSourceConfigLastApplied(prettySourceConfig); + setAdvancedSourceEditorApplyButtonEnabled(false); + }; + + const applyAdvancedPivotEditorChanges = () => { const pivotConfig = JSON.parse(advancedEditorConfig); const newGroupByList: PivotGroupByConfigDict = {}; @@ -342,21 +396,35 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange }); } setAggList(newAggList); - const prettyPivotConfig = JSON.stringify(pivotConfig, null, 2); + setAdvancedEditorConfig(prettyPivotConfig); setAdvancedEditorConfigLastApplied(prettyPivotConfig); - setAdvancedEditorApplyButtonEnabled(false); + setAdvancedPivotEditorApplyButtonEnabled(false); }; const toggleAdvancedEditor = () => { setAdvancedEditorConfig(advancedEditorConfig); - setAdvancedEditorEnabled(!isAdvancedEditorEnabled); - setAdvancedEditorApplyButtonEnabled(false); - if (isAdvancedEditorEnabled === false) { + setAdvancedPivotEditorEnabled(!isAdvancedPivotEditorEnabled); + setAdvancedPivotEditorApplyButtonEnabled(false); + if (isAdvancedPivotEditorEnabled === false) { setAdvancedEditorConfigLastApplied(advancedEditorConfig); } }; + // If switching to KQL after updating via editor - reset search + const toggleAdvancedSourceEditor = (reset = false) => { + if (reset === true) { + setSearchQuery(defaultSearch); + setSearchString(defaultSearch); + setSourceConfigUpdated(false); + } + if (isAdvancedSourceEditorEnabled === false) { + setAdvancedEditorSourceConfigLastApplied(advancedEditorSourceConfig); + } + + setAdvancedSourceEditorEnabled(!isAdvancedSourceEditorEnabled); + setAdvancedSourceEditorApplyButtonEnabled(false); + }; // metadata.branch corresponds to the version used in documentation links. const docsUrl = `https://www.elastic.co/guide/en/elasticsearch/reference/${metadata.branch}/data-frame-transform-pivot.html`; @@ -374,6 +442,21 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange ); + const sourceDocsUrl = `https://www.elastic.co/guide/en/elasticsearch/reference/${metadata.branch}/query-dsl.html`; + const advancedSourceEditorHelpText = ( + + {i18n.translate('xpack.ml.dataframe.stepDefineForm.advancedSourceEditorHelpText', { + defaultMessage: + 'The advanced editor allows you to edit the source query clause of the data frame transform.', + })}{' '} + + {i18n.translate('xpack.ml.dataframe.stepDefineForm.advancedEditorHelpTextLink', { + defaultMessage: 'Learn more about available options.', + })} + + + ); + const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0; useEffect(() => { @@ -385,20 +468,31 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange ); const stringifiedPivotConfigUpdate = JSON.stringify(previewRequestUpdate.pivot, null, 2); + const stringifiedSourceConfigUpdate = JSON.stringify( + previewRequestUpdate.source.query, + null, + 2 + ); setAdvancedEditorConfig(stringifiedPivotConfigUpdate); + setAdvancedEditorSourceConfig(stringifiedSourceConfigUpdate); onChange({ aggList, groupByList, - isAdvancedEditorEnabled, - search, + isAdvancedPivotEditorEnabled, + isAdvancedSourceEditorEnabled, + searchString, + searchQuery, + sourceConfigUpdated, valid, }); }, [ JSON.stringify(pivotAggsArr), JSON.stringify(pivotGroupByArr), - isAdvancedEditorEnabled, - search, + isAdvancedPivotEditorEnabled, + isAdvancedSourceEditorEnabled, + searchString, + searchQuery, valid, ]); @@ -411,7 +505,7 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange - {kibanaContext.currentSavedSearch.id === undefined && typeof search === 'string' && ( + {kibanaContext.currentSavedSearch.id === undefined && typeof searchString === 'string' && ( = React.memo(({ overrides = {}, onChange {kibanaContext.currentIndexPattern.title} {!disabledQuery && ( - - + {!isAdvancedSourceEditorEnabled && ( + + + + )} + + )} + + )} + + {isAdvancedSourceEditorEnabled && ( + + + + { + setAdvancedEditorSourceConfig(d); + + // Disable the "Apply"-Button if the config hasn't changed. + if (advancedEditorSourceConfigLastApplied === d) { + setAdvancedSourceEditorApplyButtonEnabled(false); + return; + } + + // Try to parse the string passed on from the editor. + // If parsing fails, the "Apply"-Button will be disabled + try { + JSON.parse(d); + setAdvancedSourceEditorApplyButtonEnabled(true); + } catch (e) { + setAdvancedSourceEditorApplyButtonEnabled(false); + } + }} + setOptions={{ + fontSize: '12px', }} - onChange={searchHandler} + aria-label={i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedSourceEditorAriaLabel', + { + defaultMessage: 'Advanced query editor', + } + )} /> - - )} + + )} + {kibanaContext.currentSavedSearch.id === undefined && ( + + + + { + if (isAdvancedSourceEditorEnabled && sourceConfigUpdated) { + setAdvancedSourceEditorSwitchModalVisible(true); + return; + } + toggleAdvancedSourceEditor(); + }} + /> + {isAdvancedSourceEditorSwitchModalVisible && ( + setAdvancedSourceEditorSwitchModalVisible(false)} + onConfirm={() => { + setAdvancedSourceEditorSwitchModalVisible(false); + toggleAdvancedSourceEditor(true); + }} + type={'source'} + /> + )} + + {isAdvancedSourceEditorEnabled && ( + + {i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedSourceEditorApplyButtonText', + { + defaultMessage: 'Apply changes', + } + )} + + )} + + + )} {kibanaContext.currentSavedSearch.id !== undefined && ( = React.memo(({ overrides = {}, onChange )} - {!isAdvancedEditorEnabled && ( + {!isAdvancedPivotEditorEnabled && ( = React.memo(({ overrides = {}, onChange )} - {isAdvancedEditorEnabled && ( + {isAdvancedPivotEditorEnabled && ( = React.memo(({ overrides = {}, onChange // Disable the "Apply"-Button if the config hasn't changed. if (advancedEditorConfigLastApplied === d) { - setAdvancedEditorApplyButtonEnabled(false); + setAdvancedPivotEditorApplyButtonEnabled(false); return; } @@ -550,9 +744,9 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange // If parsing fails, the "Apply"-Button will be disabled try { JSON.parse(d); - setAdvancedEditorApplyButtonEnabled(true); + setAdvancedPivotEditorApplyButtonEnabled(true); } catch (e) { - setAdvancedEditorApplyButtonEnabled(false); + setAdvancedPivotEditorApplyButtonEnabled(false); } }} setOptions={{ @@ -561,7 +755,7 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange aria-label={i18n.translate( 'xpack.ml.dataframe.stepDefineForm.advancedEditorAriaLabel', { - defaultMessage: 'Advanced editor', + defaultMessage: 'Advanced pivot editor', } )} /> @@ -576,14 +770,14 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange label={i18n.translate( 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchLabel', { - defaultMessage: 'Advanced editor', + defaultMessage: 'Advanced pivot editor', } )} - checked={isAdvancedEditorEnabled} + checked={isAdvancedPivotEditorEnabled} onChange={() => { if ( - isAdvancedEditorEnabled && - (isAdvancedEditorApplyButtonEnabled || + isAdvancedPivotEditorEnabled && + (isAdvancedPivotEditorApplyButtonEnabled || advancedEditorConfig !== advancedEditorConfigLastApplied) ) { setAdvancedEditorSwitchModalVisible(true); @@ -594,52 +788,22 @@ export const StepDefineForm: SFC = React.memo(({ overrides = {}, onChange }} /> {isAdvancedEditorSwitchModalVisible && ( - - setAdvancedEditorSwitchModalVisible(false)} - onConfirm={() => { - setAdvancedEditorSwitchModalVisible(false); - toggleAdvancedEditor(); - }} - cancelButtonText={i18n.translate( - 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalCancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalConfirmButtonText', - { - defaultMessage: 'Disable advanced editor', - } - )} - buttonColor="danger" - defaultFocusedButton="confirm" - > -

- {i18n.translate( - 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalBodyText', - { - defaultMessage: `The changes in the advanced editor haven't been applied yet. By disabling the advanced editor you will lose your edits.`, - } - )} -

-
-
+ setAdvancedEditorSwitchModalVisible(false)} + onConfirm={() => { + setAdvancedEditorSwitchModalVisible(false); + toggleAdvancedEditor(); + }} + type={'pivot'} + /> )}
- {isAdvancedEditorEnabled && ( + {isAdvancedPivotEditorEnabled && ( {i18n.translate( 'xpack.ml.dataframe.stepDefineForm.advancedEditorApplyButtonText', diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.test.tsx index 48eb8596f2f3e..7015912fd2e29 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.test.tsx @@ -7,14 +7,15 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { KibanaContext } from '../../../../../contexts/kibana'; +import { kibanaContextValueMock } from '../../../../../contexts/kibana/__mocks__/kibana_context_value'; + import { - KibanaContext, PivotAggsConfig, PivotGroupByConfig, PIVOT_SUPPORTED_AGGS, PIVOT_SUPPORTED_GROUP_BY_AGGS, } from '../../../../common'; - import { StepDefineExposedState } from './step_define_form'; import { StepDefineSummary } from './step_define_summary'; @@ -26,12 +27,6 @@ jest.mock('react', () => { describe('Data Frame: ', () => { test('Minimal initialization', () => { - const currentIndexPattern = { - id: 'the-index-pattern-id', - title: 'the-index-pattern-title', - fields: [], - }; - const groupBy: PivotGroupByConfig = { agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS, field: 'the-group-by-field', @@ -47,8 +42,11 @@ describe('Data Frame: ', () => { const props: StepDefineExposedState = { aggList: { 'the-agg-name': agg }, groupByList: { 'the-group-by-name': groupBy }, - isAdvancedEditorEnabled: false, - search: 'the-query', + isAdvancedPivotEditorEnabled: false, + isAdvancedSourceEditorEnabled: false, + sourceConfigUpdated: false, + searchString: 'the-query', + searchQuery: 'the-search-query', valid: true, }; @@ -56,16 +54,7 @@ describe('Data Frame: ', () => { // with the Provider being the outer most component. const wrapper = shallow(
- +
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.tsx index c6d0f86775982..21250cb64ec70 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/step_define_summary.tsx @@ -4,42 +4,57 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, SFC, useContext } from 'react'; +import React, { Fragment, SFC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiFlexGroup, EuiFlexItem, EuiForm, EuiFormRow, EuiText } from '@elastic/eui'; +import { + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiText, +} from '@elastic/eui'; + +import { useKibanaContext } from '../../../../../contexts/kibana'; import { AggListSummary } from '../aggregation_list'; import { GroupByListSummary } from '../group_by_list'; import { PivotPreview } from './pivot_preview'; -import { getPivotQuery, isKibanaContext, KibanaContext } from '../../../../common'; +import { getPivotQuery } from '../../../../common'; import { StepDefineExposedState } from './step_define_form'; const defaultSearch = '*'; const emptySearch = ''; export const StepDefineSummary: SFC = ({ - search, + searchString, + searchQuery, groupByList, aggList, }) => { - const kibanaContext = useContext(KibanaContext); + const kibanaContext = useKibanaContext(); - if (!isKibanaContext(kibanaContext)) { - return null; + const pivotQuery = getPivotQuery(searchQuery); + let useCodeBlock = false; + let displaySearch; + // searchString set to empty once source config editor used - display query instead + if (searchString === emptySearch) { + displaySearch = JSON.stringify(searchQuery, null, 2); + useCodeBlock = true; + } else if (searchString === defaultSearch) { + displaySearch = emptySearch; + } else { + displaySearch = searchString; } - const pivotQuery = getPivotQuery(search); - - const displaySearch = search === defaultSearch ? emptySearch : search; - return ( - {kibanaContext.currentSavedSearch.id === undefined && typeof search === 'string' && ( + {kibanaContext.currentSavedSearch.id === undefined && typeof searchString === 'string' && ( = ({ > {kibanaContext.currentIndexPattern.title} - {displaySearch !== emptySearch && ( + {useCodeBlock === false && displaySearch !== emptySearch && ( = ({ {displaySearch} )} + {useCodeBlock === true && displaySearch !== emptySearch && ( + + + {displaySearch} + + + )} )} diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/switch_modal.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/switch_modal.tsx new file mode 100644 index 0000000000000..000f079dad2f3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/switch_modal.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +interface Props { + onCancel: () => void; + onConfirm: () => void; + type: 'pivot' | 'source'; +} + +const pivotModalTitle = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalTitle', + { + defaultMessage: 'Unapplied changes', + } +); +const sourceModalTitle = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedSourceEditorSwitchModalTitle', + { + defaultMessage: 'Edits will be lost', + } +); +const pivotModalMessage = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalBodyText', + { + defaultMessage: `The changes in the advanced editor haven't been applied yet. By disabling the advanced editor you will lose your edits.`, + } +); +const sourceModalMessage = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedSourceEditorSwitchModalBodyText', + { + defaultMessage: `By switching back to KQL query bar you will lose your edits.`, + } +); +const pivotModalConfirmButtonText = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalConfirmButtonText', + { + defaultMessage: 'Disable advanced editor', + } +); +const sourceModalConfirmButtonText = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedSourceEditorSwitchModalConfirmButtonText', + { + defaultMessage: 'Switch to KQL', + } +); +const cancelButtonText = i18n.translate( + 'xpack.ml.dataframe.stepDefineForm.advancedEditorSwitchModalCancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +export const SwitchModal: FC = ({ onCancel, onConfirm, type }) => ( + + +

{type === 'pivot' ? pivotModalMessage : sourceModalMessage}

+
+
+); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/use_pivot_preview_data.test.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/use_pivot_preview_data.test.tsx index b9dba2d455922..615d4f75744cb 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/use_pivot_preview_data.test.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_define/use_pivot_preview_data.test.tsx @@ -15,6 +15,8 @@ import { UsePivotPreviewDataReturnType, } from './use_pivot_preview_data'; +import { IndexPattern } from 'ui/index_patterns'; + jest.mock('../../../../../services/ml_api_service'); type Callback = () => void; @@ -46,7 +48,7 @@ describe('usePivotPreviewData', () => { test('indexPattern not defined', () => { testHook(() => { pivotPreviewObj = usePivotPreviewData( - { id: 'the-id', title: 'the-title', fields: [] }, + ({ id: 'the-id', title: 'the-title', fields: [] } as unknown) as IndexPattern, query, {}, {} @@ -62,7 +64,7 @@ describe('usePivotPreviewData', () => { test('indexPattern set triggers loading', () => { testHook(() => { pivotPreviewObj = usePivotPreviewData( - { id: 'the-id', title: 'the-title', fields: [] }, + ({ id: 'the-id', title: 'the-title', fields: [] } as unknown) as IndexPattern, query, {}, {} diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_details/step_details_form.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_details/step_details_form.tsx index de8aa08ee9e69..f5e97c43b6dda 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_details/step_details_form.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/step_details/step_details_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, SFC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, SFC, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { metadata } from 'ui/metadata'; @@ -12,17 +12,16 @@ import { toastNotifications } from 'ui/notify'; import { EuiLink, EuiSwitch, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { useKibanaContext } from '../../../../../contexts/kibana'; import { isValidIndexName } from '../../../../../../common/util/es_utils'; import { ml } from '../../../../../services/ml_api_service'; import { delayFormatRegex, - isKibanaContext, isTransformIdValid, DataFrameTransformId, DataFrameTransformPivotConfig, - KibanaContext, } from '../../../../common'; import { EsIndexName, IndexPatternTitle } from './common'; @@ -58,11 +57,7 @@ interface Props { } export const StepDetailsForm: SFC = React.memo(({ overrides = {}, onChange }) => { - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } + const kibanaContext = useKibanaContext(); const defaults = { ...getDefaultStepDetailsState(), ...overrides }; @@ -123,7 +118,7 @@ export const StepDetailsForm: SFC = React.memo(({ overrides = {}, onChang } try { - setIndexPatternTitles(await kibanaContext.indexPatterns.getTitles()); + setIndexPatternTitles((await kibanaContext.indexPatterns.getTitles(false)) as string[]); } catch (e) { toastNotifications.addDanger( i18n.translate('xpack.ml.dataframe.stepDetailsForm.errorGettingIndexPatternTitles', { diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/wizard/wizard.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/wizard/wizard.tsx index aff418c454e8c..7bd6b86bda5f8 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/wizard/wizard.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/wizard/wizard.tsx @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, SFC, useContext, useRef, useState } from 'react'; +import React, { Fragment, SFC, useRef, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { isKibanaContext, getCreateRequestBody, KibanaContext } from '../../../../common'; +import { useKibanaContext } from '../../../../../contexts/kibana'; + +import { getCreateRequestBody } from '../../../../common'; import { StepDefineExposedState, @@ -61,11 +63,7 @@ const StepDefine: SFC = ({ }; export const Wizard: SFC = React.memo(() => { - const kibanaContext = useContext(KibanaContext); - - if (!isKibanaContext(kibanaContext)) { - return null; - } + const kibanaContext = useKibanaContext(); const indexPattern = kibanaContext.currentIndexPattern; diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/directive.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/directive.tsx index 928d5dfce3760..4ed9d70b23dc5 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/directive.tsx @@ -7,16 +7,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import chrome from 'ui/chrome'; // @ts-ignore import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml', ['react']); -import { IndexPattern } from 'ui/index_patterns'; +import { IndexPattern, IndexPatterns } from 'ui/index_patterns'; import { I18nContext } from 'ui/i18n'; import { IPrivate } from 'ui/private'; import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; + import { InjectorService } from '../../../../common/types/angular'; // @ts-ignore @@ -28,8 +27,7 @@ type CreateSearchItems = () => { combinedQuery: any; }; -import { NavigationMenuContext } from '../../../util/context_utils'; -import { KibanaContext } from '../../common'; +import { KibanaConfigTypeFix, KibanaContext } from '../../../contexts/kibana'; import { Page } from './page'; module.directive('mlNewDataFrame', ($injector: InjectorService) => { @@ -37,10 +35,10 @@ module.directive('mlNewDataFrame', ($injector: InjectorService) => { scope: {}, restrict: 'E', link: (scope: ng.IScope, element: ng.IAugmentedJQuery) => { - const indexPatterns = $injector.get('indexPatterns'); + const indexPatterns = $injector.get('indexPatterns'); const kbnBaseUrl = $injector.get('kbnBaseUrl'); - const kibanaConfig = $injector.get('config'); - const Private: IPrivate = $injector.get('Private'); + const kibanaConfig = $injector.get('config'); + const Private = $injector.get('Private'); timefilter.disableTimeRangeSelector(); timefilter.disableAutoRefreshSelector(); @@ -59,11 +57,9 @@ module.directive('mlNewDataFrame', ($injector: InjectorService) => { ReactDOM.render( - - - - - + + + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__mocks__/data_frame_transform_list_row.json b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__mocks__/data_frame_transform_list_row.json index f2045ea78112b..5165c693326af 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__mocks__/data_frame_transform_list_row.json +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__mocks__/data_frame_transform_list_row.json @@ -2,42 +2,66 @@ "config": { "id": "fq_date_histogram_1m_1441", "source": { "index": ["farequote-2019"], "query": { "match_all": {} } }, - "dest": { "index": "fq_data_histogram_1m_1441" }, + "dest": { "index": "fq_date_histogram_1m_1441" }, "pivot": { "group_by": { - "date_histogram(@timestamp)": { - "date_histogram": { "field": "@timestamp", "interval": "1m" } + "@timestamp": { + "date_histogram": { "field": "@timestamp", "calendar_interval": "1m" } } }, - "aggregations": { "avg(response)": { "avg": { "field": "responsetime" } } } - } + "aggregations": { "responsetime.avg": { "avg": { "field": "responsetime" } } } + }, + "version": "8.0.0", + "create_time": 1564388146667, + "mode": "batch" }, "id": "fq_date_histogram_1m_1441", - "state": { - "task_state": "stopped", - "indexer_state": "stopped", - "current_position": { "date_histogram(@timestamp)": 1549929540000 }, - "checkpoint": 1 - }, - "stats": { - "pages_processed": 0, - "documents_processed": 0, - "documents_indexed": 0, - "trigger_count": 0, - "index_time_in_ms": 0, - "index_total": 0, - "index_failures": 0, - "search_time_in_ms": 0, - "search_total": 0, - "search_failures": 0 - }, "checkpointing": { - "current": { - "timestamp": "2019-06-28T16:09:23.539Z", - "timestamp_millis": 1561738163539, - "time_upper_bound": "2019-06-28T16:09:13.539Z", - "time_upper_bound_millis": 1561738153539 + "last": { + "checkpoint": 1, + "timestamp_millis": 1564388281199 + }, + "next": { + "checkpoint": 2, + "indexer_state": "stopped", + "checkpoint_progress": { + "total_docs": 86274, + "docs_remaining": 0, + "percent_complete": 100 + } }, "operations_behind": 0 + }, + "stats": { + "id": "fq_date_histogram_1m_1441", + "task_state": "stopped", + "stats": { + "pages_processed": 16, + "documents_processed": 86274, + "documents_indexed": 7200, + "trigger_count": 1, + "index_time_in_ms": 1310, + "index_total": 15, + "index_failures": 0, + "search_time_in_ms": 463, + "search_total": 16, + "search_failures": 0 + }, + "checkpointing": { + "last": { + "checkpoint": 1, + "timestamp_millis": 1564388281199 + }, + "next": { + "checkpoint": 2, + "indexer_state": "stopped", + "checkpoint_progress": { + "total_docs": 86274, + "docs_remaining": 0, + "percent_complete": 100 + } + }, + "operations_behind": 0 + } } } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap index 83cc9a5148698..9063535b4445d 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row.test.tsx.snap @@ -12,20 +12,12 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "stopped", - "title": "task_state", + "description": "fq_date_histogram_1m_1441", + "title": "id", }, Object { "description": "stopped", - "title": "indexer_state", - }, - Object { - "description": "{\\"date_histogram(@timestamp)\\":1549929540000}", - "title": "current_position", - }, - Object { - "description": "1", - "title": "checkpoint", + "title": "task_state", }, ], "position": "left", @@ -34,8 +26,12 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "{\\"timestamp\\":\\"2019-06-28T16:09:23.539Z\\",\\"timestamp_millis\\":1561738163539,\\"time_upper_bound\\":\\"2019-06-28T16:09:13.539Z\\",\\"time_upper_bound_millis\\":1561738153539}", - "title": "current", + "description": "{\\"checkpoint\\":1,\\"timestamp_millis\\":1564388281199}", + "title": "last", + }, + Object { + "description": "{\\"checkpoint\\":2,\\"indexer_state\\":\\"stopped\\",\\"checkpoint_progress\\":{\\"total_docs\\":86274,\\"docs_remaining\\":0,\\"percent_complete\\":100}}", + "title": "next", }, Object { "description": "0", @@ -48,27 +44,27 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "0", + "description": "16", "title": "pages_processed", }, Object { - "description": "0", + "description": "86274", "title": "documents_processed", }, Object { - "description": "0", + "description": "7200", "title": "documents_indexed", }, Object { - "description": "0", + "description": "1", "title": "trigger_count", }, Object { - "description": "0", + "description": "1310", "title": "index_time_in_ms", }, Object { - "description": "0", + "description": "15", "title": "index_total", }, Object { @@ -76,11 +72,11 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = "title": "index_failures", }, Object { - "description": "0", + "description": "463", "title": "search_time_in_ms", }, Object { - "description": "0", + "description": "16", "title": "search_total", }, Object { @@ -109,20 +105,12 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "stopped", - "title": "task_state", + "description": "fq_date_histogram_1m_1441", + "title": "id", }, Object { "description": "stopped", - "title": "indexer_state", - }, - Object { - "description": "{\\"date_histogram(@timestamp)\\":1549929540000}", - "title": "current_position", - }, - Object { - "description": "1", - "title": "checkpoint", + "title": "task_state", }, ], "position": "left", @@ -131,8 +119,12 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "{\\"timestamp\\":\\"2019-06-28T16:09:23.539Z\\",\\"timestamp_millis\\":1561738163539,\\"time_upper_bound\\":\\"2019-06-28T16:09:13.539Z\\",\\"time_upper_bound_millis\\":1561738153539}", - "title": "current", + "description": "{\\"checkpoint\\":1,\\"timestamp_millis\\":1564388281199}", + "title": "last", + }, + Object { + "description": "{\\"checkpoint\\":2,\\"indexer_state\\":\\"stopped\\",\\"checkpoint_progress\\":{\\"total_docs\\":86274,\\"docs_remaining\\":0,\\"percent_complete\\":100}}", + "title": "next", }, Object { "description": "0", @@ -145,27 +137,27 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = Object { "items": Array [ Object { - "description": "0", + "description": "16", "title": "pages_processed", }, Object { - "description": "0", + "description": "86274", "title": "documents_processed", }, Object { - "description": "0", + "description": "7200", "title": "documents_indexed", }, Object { - "description": "0", + "description": "1", "title": "trigger_count", }, Object { - "description": "0", + "description": "1310", "title": "index_time_in_ms", }, Object { - "description": "0", + "description": "15", "title": "index_total", }, Object { @@ -173,11 +165,11 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = "title": "index_failures", }, Object { - "description": "0", + "description": "463", "title": "search_time_in_ms", }, Object { - "description": "0", + "description": "16", "title": "search_total", }, Object { @@ -198,23 +190,25 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = "content": Minimal initialization 1`] = "match_all": Object {}, }, }, + "version": "8.0.0", } } />, @@ -244,23 +239,25 @@ exports[`Data Frame: Transform List Minimal initialization 1`] = "content": Minimal initialization 1`] = "match_all": Object {}, }, }, + "version": "8.0.0", } } />, diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap index 2f2048daa6b01..88f1d20f119e8 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_details_pane.test.tsx.snap @@ -2,7 +2,13 @@ exports[`Data Frame: Job List Expanded Row Minimal initialization 1`] = ` - + @@ -21,7 +27,13 @@ exports[`Data Frame: Job List Expanded Row Minimal in } /> - + `; diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap index a771d68d2fb7a..a4e25312b4c32 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/__snapshots__/expanded_row_json_pane.test.tsx.snap @@ -27,25 +27,28 @@ exports[`Data Frame: Transform List Expanded Row Minimal } }, \\"dest\\": { - \\"index\\": \\"fq_data_histogram_1m_1441\\" + \\"index\\": \\"fq_date_histogram_1m_1441\\" }, \\"pivot\\": { \\"group_by\\": { - \\"date_histogram(@timestamp)\\": { + \\"@timestamp\\": { \\"date_histogram\\": { \\"field\\": \\"@timestamp\\", - \\"interval\\": \\"1m\\" + \\"calendar_interval\\": \\"1m\\" } } }, \\"aggregations\\": { - \\"avg(response)\\": { + \\"responsetime.avg\\": { \\"avg\\": { \\"field\\": \\"responsetime\\" } } } - } + }, + \\"version\\": \\"8.0.0\\", + \\"create_time\\": 1564388146667, + \\"mode\\": \\"batch\\" }" />
diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/action_delete.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/action_delete.tsx index 0b6640aea328e..8787ef49492c5 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/action_delete.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/action_delete.tsx @@ -28,7 +28,7 @@ interface DeleteActionProps { } export const DeleteAction: SFC = ({ item }) => { - const disabled = item.state.task_state === DATA_FRAME_TASK_STATE.STARTED; + const disabled = item.stats.task_state === DATA_FRAME_TASK_STATE.STARTED; const canDeleteDataFrame: boolean = checkPermission('canDeleteDataFrame'); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/actions.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/actions.tsx index bcc057859b766..1aaa3df71f802 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/actions.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/actions.tsx @@ -26,7 +26,7 @@ export const getActions = () => { { isPrimary: true, render: (item: DataFrameTransformListRow) => { - if (item.state.task_state !== DATA_FRAME_TASK_STATE.STARTED) { + if (item.stats.task_state !== DATA_FRAME_TASK_STATE.STARTED) { return ; } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/columns.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/columns.tsx index a99aeb6839cd0..d6a9491b09e18 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/columns.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/columns.tsx @@ -5,6 +5,7 @@ */ import React, { Fragment } from 'react'; +import { idx } from '@kbn/elastic-idx'; import { i18n } from '@kbn/i18n'; import { EuiBadge, @@ -22,7 +23,7 @@ import { DATA_FRAME_TASK_STATE, DataFrameTransformListColumn, DataFrameTransformListRow, - DataFrameTransformState, + DataFrameTransformStats, } from './common'; import { getActions } from './actions'; @@ -33,8 +34,8 @@ enum TASK_STATE_COLOR { } export const getTaskStateBadge = ( - state: DataFrameTransformState['task_state'], - reason?: DataFrameTransformState['reason'] + state: DataFrameTransformStats['task_state'], + reason?: DataFrameTransformStats['reason'] ) => { const color = TASK_STATE_COLOR[state]; @@ -125,10 +126,10 @@ export const getColumns = ( }, { name: i18n.translate('xpack.ml.dataframe.status', { defaultMessage: 'Status' }), - sortable: (item: DataFrameTransformListRow) => item.state.task_state, + sortable: (item: DataFrameTransformListRow) => item.stats.task_state, truncateText: true, render(item: DataFrameTransformListRow) { - return getTaskStateBadge(item.state.task_state, item.state.reason); + return getTaskStateBadge(item.stats.task_state, item.stats.reason); }, width: '100px', }, @@ -146,14 +147,12 @@ export const getColumns = ( { name: i18n.translate('xpack.ml.dataframe.progress', { defaultMessage: 'Progress' }), sortable: (item: DataFrameTransformListRow) => - item.state.progress !== undefined ? item.state.progress.percent_complete : 0, + idx(item, _ => _.stats.checkpointing.next.checkpoint_progress.percent_complete) || 0, truncateText: true, render(item: DataFrameTransformListRow) { - let progress = 0; - - if (item.state.progress !== undefined) { - progress = Math.round(item.state.progress.percent_complete); - } + const progress = Math.round( + idx(item, _ => _.stats.checkpointing.next.checkpoint_progress.percent_complete) || 0 + ); const isBatchTransform = typeof item.config.sync === 'undefined'; @@ -174,10 +173,10 @@ export const getColumns = ( {!isBatchTransform && ( - {item.state.task_state === DATA_FRAME_TASK_STATE.STARTED && ( + {item.stats.task_state === DATA_FRAME_TASK_STATE.STARTED && ( )} - {item.state.task_state !== DATA_FRAME_TASK_STATE.STOPPED && ( + {item.stats.task_state === DATA_FRAME_TASK_STATE.STOPPED && ( )} diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.test.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.test.ts index af3ed6f373544..73483b516c7fd 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.test.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.test.ts @@ -6,22 +6,25 @@ import mockDataFrameTransformListRow from './__mocks__/data_frame_transform_list_row.json'; -import { DATA_FRAME_TASK_STATE, isCompletedBatchTransform } from './common'; +import { + DataFrameTransformListRow, + isCompletedBatchTransform, + DATA_FRAME_TASK_STATE, +} from './common'; describe('Data Frame: isCompletedBatchTransform()', () => { test('isCompletedBatchTransform()', () => { // check the transform config/state against the conditions // that will be used by isCompletedBatchTransform() // followed by a call to isCompletedBatchTransform() itself - expect(mockDataFrameTransformListRow.state.checkpoint === 1).toBe(true); - expect(mockDataFrameTransformListRow.sync === undefined).toBe(true); - expect(mockDataFrameTransformListRow.state.task_state === DATA_FRAME_TASK_STATE.STOPPED).toBe( - true - ); + const row = mockDataFrameTransformListRow as DataFrameTransformListRow; + expect(row.stats.checkpointing.last.checkpoint === 1).toBe(true); + expect(row.config.sync === undefined).toBe(true); + expect(row.stats.task_state === DATA_FRAME_TASK_STATE.STOPPED).toBe(true); expect(isCompletedBatchTransform(mockDataFrameTransformListRow)).toBe(true); // adapt the mock config to resemble a non-completed transform. - mockDataFrameTransformListRow.state.checkpoint = 0; + row.stats.checkpointing.last.checkpoint = 0; expect(isCompletedBatchTransform(mockDataFrameTransformListRow)).toBe(false); }); }); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.ts index 1fb90b2d60e20..bfe6b0ec32788 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/common.ts @@ -8,6 +8,12 @@ import { Dictionary } from '../../../../../../common/types/common'; import { DataFrameTransformId, DataFrameTransformPivotConfig } from '../../../../common'; +export enum DATA_FRAME_INDEXER_STATE { + FAILED = 'failed', + STARTED = 'started', + STOPPED = 'stopped', +} + export enum DATA_FRAME_TASK_STATE { FAILED = 'failed', STARTED = 'started', @@ -33,16 +39,37 @@ export interface Query { syntax: any; } -export interface DataFrameTransformState { - checkpoint: number; - current_position: Dictionary; - // indexer_state is a backend internal attribute - // and should not be considered in the UI. - indexer_state: DATA_FRAME_TASK_STATE; - progress?: { - docs_remaining: number; - percent_complete: number; - total_docs: number; +export interface DataFrameTransformStats { + id: DataFrameTransformId; + checkpointing: { + last: { + checkpoint: number; + timestamp_millis?: number; + }; + next?: { + checkpoint: number; + // indexer_state is a backend internal attribute + // and should not be considered in the UI. + indexer_state: DATA_FRAME_INDEXER_STATE; + checkpoint_progress?: { + total_docs: number; + docs_remaining: number; + percent_complete: number; + }; + }; + operations_behind: number; + }; + stats: { + documents_indexed: number; + documents_processed: number; + index_failures: number; + index_time_in_ms: number; + index_total: number; + pages_processed: number; + search_failures: number; + search_time_in_ms: number; + search_total: number; + trigger_count: number; }; reason?: string; // task_state is the attribute to check against if a transform @@ -50,23 +77,18 @@ export interface DataFrameTransformState { task_state: DATA_FRAME_TASK_STATE; } -export interface DataFrameTransformStats { - documents_indexed: number; - documents_processed: number; - index_failures: number; - index_time_in_ms: number; - index_total: number; - pages_processed: number; - search_failures: number; - search_time_in_ms: number; - search_total: number; - trigger_count: number; +export function isDataFrameTransformStats(arg: any): arg is DataFrameTransformStats { + return ( + typeof arg === 'object' && + arg !== null && + {}.hasOwnProperty.call(arg, 'task_state') && + Object.values(DATA_FRAME_TASK_STATE).includes(arg.task_state) + ); } export interface DataFrameTransformListRow { id: DataFrameTransformId; checkpointing: object; - state: DataFrameTransformState; stats: DataFrameTransformStats; config: DataFrameTransformPivotConfig; } @@ -85,8 +107,8 @@ export function isCompletedBatchTransform(item: DataFrameTransformListRow) { // If `checkpoint=1`, `sync` is missing from the config and state is stopped, // then this is a completed batch data frame transform. return ( - item.state.checkpoint === 1 && + item.stats.checkpointing.last.checkpoint === 1 && item.config.sync === undefined && - item.state.task_state === DATA_FRAME_TASK_STATE.STOPPED + item.stats.task_state === DATA_FRAME_TASK_STATE.STOPPED ); } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row.tsx index 7d71429f732c0..893c1c0c350ba 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row.tsx @@ -29,9 +29,13 @@ interface Props { } export const ExpandedRow: SFC = ({ item }) => { + const stateValues = { ...item.stats }; + delete stateValues.stats; + delete stateValues.checkpointing; + const state: SectionConfig = { title: 'State', - items: Object.entries(item.state).map(s => { + items: Object.entries(stateValues).map(s => { return { title: s[0].toString(), description: getItemDescription(s[1]) }; }), position: 'left', @@ -47,7 +51,7 @@ export const ExpandedRow: SFC = ({ item }) => { const stats: SectionConfig = { title: 'Stats', - items: Object.entries(item.stats).map(s => { + items: Object.entries(item.stats.stats).map(s => { return { title: s[0].toString(), description: getItemDescription(s[1]) }; }), position: 'right', diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row_details_pane.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row_details_pane.tsx index 4beb0f2cd1639..7ba05a5fe41ec 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row_details_pane.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/expanded_row_details_pane.tsx @@ -51,7 +51,7 @@ interface ExpandedRowDetailsPaneProps { export const ExpandedRowDetailsPane: SFC = ({ sections }) => { return ( - + {sections .filter(s => s.position === 'left') .map(s => ( @@ -61,7 +61,7 @@ export const ExpandedRowDetailsPane: SFC = ({ secti ))} - + {sections .filter(s => s.position === 'right') .map(s => ( diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/transform_list.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/transform_list.tsx index badc4eabd541d..4aaeb031fefd0 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/components/transform_list/transform_list.tsx @@ -152,7 +152,7 @@ export const DataFrameTransformList: SFC = () => { // filter other clauses, i.e. the mode and status filters if (Array.isArray(c.value)) { // the status value is an array of string(s) e.g. ['failed', 'stopped'] - ts = transforms.filter(transform => c.value.includes(transform.state.task_state)); + ts = transforms.filter(transform => c.value.includes(transform.stats.task_state)); } else { ts = transforms.filter(transform => transform.config.mode === c.value); } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/directive.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/directive.tsx index b1a862a914ab2..749fe1dc31536 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/directive.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/directive.tsx @@ -10,12 +10,8 @@ import ReactDOM from 'react-dom'; // @ts-ignore import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml', ['react']); -import chrome from 'ui/chrome'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { I18nContext } from 'ui/i18n'; -import { NavigationMenuContext } from '../../../util/context_utils'; import { Page } from './page'; module.directive('mlDataFramePage', () => { @@ -25,9 +21,7 @@ module.directive('mlDataFramePage', () => { link: (scope: ng.IScope, element: ng.IAugmentedJQuery) => { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/delete_transform.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/delete_transform.ts index 3ecf5067d3fa1..ad563a6e40baa 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/delete_transform.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/delete_transform.ts @@ -17,10 +17,10 @@ import { export const deleteTransform = async (d: DataFrameTransformListRow) => { try { - if (d.state.task_state === DATA_FRAME_TASK_STATE.FAILED) { + if (d.stats.task_state === DATA_FRAME_TASK_STATE.FAILED) { await ml.dataFrame.stopDataFrameTransform( d.config.id, - d.state.task_state === DATA_FRAME_TASK_STATE.FAILED, + d.stats.task_state === DATA_FRAME_TASK_STATE.FAILED, true ); } diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/get_transforms.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/get_transforms.ts index 3306009076522..81160a0aa5852 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/get_transforms.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/get_transforms.ts @@ -7,25 +7,17 @@ import { ml } from '../../../../../services/ml_api_service'; import { DataFrameTransformPivotConfig, - DataFrameTransformId, refreshTransformList$, REFRESH_TRANSFORM_LIST_STATE, } from '../../../../common'; import { DataFrameTransformListRow, - DataFrameTransformState, DataFrameTransformStats, DATA_FRAME_MODE, + isDataFrameTransformStats, } from '../../components/transform_list/common'; -interface DataFrameTransformStateStats { - id: DataFrameTransformId; - checkpointing: object; - state: DataFrameTransformState; - stats: DataFrameTransformStats; -} - interface GetDataFrameTransformsResponse { count: number; transforms: DataFrameTransformPivotConfig[]; @@ -34,7 +26,7 @@ interface GetDataFrameTransformsResponse { interface GetDataFrameTransformsStatsResponseOk { node_failures?: object; count: number; - transforms: DataFrameTransformStateStats[]; + transforms: DataFrameTransformStats[]; } const isGetDataFrameTransformsStatsResponseOk = ( @@ -90,7 +82,7 @@ export const getTransformsFactory = ( // A newly created transform might not have corresponding stats yet. // If that's the case we just skip the transform and don't add it to the transform list yet. - if (stats === undefined) { + if (!isDataFrameTransformStats(stats)) { return reducedtableRows; } @@ -104,8 +96,7 @@ export const getTransformsFactory = ( config, id: config.id, checkpointing: stats.checkpointing, - state: stats.state, - stats: stats.stats, + stats, }); return reducedtableRows; }, diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/start_transform.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/start_transform.ts index e131af94a258e..0e1f7562143c0 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/start_transform.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/start_transform.ts @@ -19,7 +19,7 @@ export const startTransform = async (d: DataFrameTransformListRow) => { try { await ml.dataFrame.startDataFrameTransform( d.config.id, - d.state.task_state === DATA_FRAME_TASK_STATE.FAILED + d.stats.task_state === DATA_FRAME_TASK_STATE.FAILED ); toastNotifications.addSuccess( i18n.translate('xpack.ml.dataframe.transformList.startTransformSuccessMessage', { diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/stop_transform.ts b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/stop_transform.ts index c5d6b348c38a2..61550291255c1 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/stop_transform.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/transform_management/services/transform_service/stop_transform.ts @@ -19,7 +19,7 @@ export const stopTransform = async (d: DataFrameTransformListRow) => { try { await ml.dataFrame.stopDataFrameTransform( d.config.id, - d.state.task_state === DATA_FRAME_TASK_STATE.FAILED, + d.stats.task_state === DATA_FRAME_TASK_STATE.FAILED, true ); toastNotifications.addSuccess( diff --git a/x-pack/legacy/plugins/ml/public/datavisualizer/selector/datavisualizer_selector.js b/x-pack/legacy/plugins/ml/public/datavisualizer/selector/datavisualizer_selector.js index 9e5df0de49426..b9bcaf5ed71c9 100644 --- a/x-pack/legacy/plugins/ml/public/datavisualizer/selector/datavisualizer_selector.js +++ b/x-pack/legacy/plugins/ml/public/datavisualizer/selector/datavisualizer_selector.js @@ -22,11 +22,8 @@ import { import { isFullLicense } from '../../license/check_license'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; -import chrome from 'ui/chrome'; import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; -import { NavigationMenuContext } from '../../util/context_utils'; import { NavigationMenu } from '../../components/navigation_menu/navigation_menu'; function startTrialDescription() { @@ -57,7 +54,7 @@ export const DatavisualizerSelector = injectI18n(function (props) { const startTrialVisible = isFullLicense() === false; return ( - + @@ -182,6 +179,6 @@ export const DatavisualizerSelector = injectI18n(function (props) { )} - + ); }); diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js index b78623cdbf106..241623b796bfa 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_influencers_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_influencers_found'; +export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js index b5ee3e5c94dbe..143cd82d7a829 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_jobs_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_jobs_found'; +export { ExplorerNoJobsFound } from './explorer_no_jobs_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js index b5a49dd4c7f83..d6f71dc131db7 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/explorer_no_results_found/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_results_found'; +export { ExplorerNoResultsFound } from './explorer_no_results_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/components/index.js b/x-pack/legacy/plugins/ml/public/explorer/components/index.js index fa4bead02c699..d4138f3ec6c66 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/components/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/components/index.js @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_no_influencers_found'; -export * from './explorer_no_jobs_found'; -export * from './explorer_no_results_found'; +export { ExplorerNoInfluencersFound } from './explorer_no_influencers_found'; +export { ExplorerNoJobsFound } from './explorer_no_jobs_found'; +export { ExplorerNoResultsFound } from './explorer_no_results_found'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer.html b/x-pack/legacy/plugins/ml/public/explorer/explorer.html deleted file mode 100644 index db5b12b7eeb9f..0000000000000 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
- - - - -
diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer.js b/x-pack/legacy/plugins/ml/public/explorer/explorer.js index 286a3bd2ebe16..7c7108daa1152 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer.js @@ -10,10 +10,11 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { Fragment } from 'react'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; import DragSelect from 'dragselect/dist/ds.min.js'; import { map } from 'rxjs/operators'; +import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, @@ -36,11 +37,14 @@ import { ExplorerSwimlane } from './explorer_swimlane'; import { KqlFilterBar } from '../components/kql_filter_bar'; import { formatHumanReadableDateTime } from '../util/date_utils'; import { getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets'; +import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$, explorer$ } from './explorer_dashboard_service'; import { mlResultsService } from 'plugins/ml/services/results_service'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; -import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts/checkbox_showcharts'; +import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; +import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts'; +import { JobSelector } from '../components/job_selector'; import { SelectInterval, interval$ } from '../components/controls/select_interval/select_interval'; import { SelectLimit, limit$ } from './select_limit/select_limit'; import { SelectSeverity, severity$ } from '../components/controls/select_severity/select_severity'; @@ -131,6 +135,14 @@ function mapSwimlaneOptionsToEuiOptions(options) { })); } +const ExplorerPage = ({ children, jobSelectorProps }) => ( + + + + {children} + +); + export const Explorer = injectI18n(injectObservablesAsProps( { annotationsRefresh: annotationsRefresh$, @@ -143,7 +155,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( class Explorer extends React.Component { static propTypes = { appStateHandler: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, dateFormatTz: PropTypes.string.isRequired, + globalState: PropTypes.object.isRequired, + jobSelectService: PropTypes.object.isRequired, MlTimeBuckets: PropTypes.func.isRequired, }; @@ -789,7 +804,18 @@ export const Explorer = injectI18n(injectObservablesAsProps( if (stateUpdate.influencers !== undefined && !noInfluencersConfigured) { for (const influencerName in stateUpdate.influencers) { if (stateUpdate.influencers[influencerName][0] && stateUpdate.influencers[influencerName][0].influencerFieldValue) { - stateUpdate.filterPlaceHolder = `${influencerName} : ${stateUpdate.influencers[influencerName][0].influencerFieldValue}`; + stateUpdate.filterPlaceHolder = + (i18n.translate( + 'xpack.ml.explorer.kueryBar.filterPlaceholder', + { + defaultMessage: + 'Filter by influencer fields… ({queryExample})', + values: { + queryExample: + `${influencerName} : ${stateUpdate.influencers[influencerName][0].influencerFieldValue}` + } + } + )); break; } } @@ -968,7 +994,7 @@ export const Explorer = injectI18n(injectObservablesAsProps( } applyInfluencersFilterQuery = ({ - influencersFilterQuery, + filterQuery: influencersFilterQuery, isAndOperator, filteredFields, queryString, @@ -1034,7 +1060,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( render() { const { + dateFormatTz, + globalState, intl, + jobSelectService, MlTimeBuckets, } = this.props; @@ -1065,23 +1094,34 @@ export const Explorer = injectI18n(injectObservablesAsProps( const swimlaneWidth = getSwimlaneContainerWidth(noInfluencersConfigured); + const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectorProps = { + dateFormatTz, + globalState, + jobSelectService, + selectedJobIds, + selectedGroups, + }; + if (loading === true) { return ( - + + + ); } if (noJobsFound) { - return ; + return ; } if (noJobsFound && hasResults === false) { - return ; + return ; } const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; @@ -1094,9 +1134,10 @@ export const Explorer = injectI18n(injectObservablesAsProps( ); return ( -
+ +
- {noInfluencersConfigured === false && + {noInfluencersConfigured === false && influencers !== undefined &&
} - {noInfluencersConfigured && ( -
- +
- )} + })} + position="right" + type="iInCircle" + /> +
+ )} - {noInfluencersConfigured === false && ( -
- + {noInfluencersConfigured === false && ( +
+ + + + +
+ )} + +
+ - -
- )} -
- - - - -
- -
- - {viewBySwimlaneOptions.length > 0 && ( - - - - + +
+ + {viewBySwimlaneOptions.length > 0 && ( + + + + + + + + + + + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && + swimlaneViewByFieldName === 'job ID' && ( + + )} +
+
+
+
+ + {showViewBySwimlane && ( +
- - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && - swimlaneViewByFieldName === 'job ID' && ( - - )} -
-
-
- - - {showViewBySwimlane && ( -
- -
- )} +
+ )} - {viewBySwimlaneDataLoading && ( - - )} + {viewBySwimlaneDataLoading && ( + + )} - {!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && ( - - )} -
- )} + {!showViewBySwimlane && !viewBySwimlaneDataLoading && swimlaneViewByFieldName !== null && ( + + )} + + )} - {annotationsData.length > 0 && ( - - - 0 && ( + + + + + - - - - - - )} + + + + )} - - - - - - - - - - - - - - - - {(anomalyChartRecords.length > 0 && selectedCells !== null) && ( - - - + + + + + + + + - )} - + + + + + + {(anomalyChartRecords.length > 0 && selectedCells !== null) && ( + + + + + + )} + - + -
- {this.props.showCharts && } -
+
+ {this.props.showCharts && } +
- + +
-
+ ); } } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js index 0cfc2b8463ef8..f8bcec79c96ce 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_charts/components/explorer_chart_label/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './explorer_chart_label'; +export { ExplorerChartLabel } from './explorer_chart_label'; diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js index da93a2bf54d1c..0b83e4d313f13 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_controller.js @@ -12,15 +12,10 @@ */ import $ from 'jquery'; -import moment from 'moment-timezone'; import { Subscription } from 'rxjs'; -import '../components/annotations/annotations_table'; -import '../components/anomalies_table'; import '../components/controls'; -import template from './explorer.html'; - import uiRoutes from 'ui/routes'; import { createJobs, @@ -34,14 +29,21 @@ import { explorer$ } from './explorer_dashboard_service'; import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; import { mlJobService } from '../services/job_service'; -import { refreshIntervalWatcher } from '../util/refresh_interval_watcher'; -import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; +import { getSelectedJobIds, jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils'; import { timefilter } from 'ui/timefilter'; +import { interval$ } from '../components/controls/select_interval'; +import { severity$ } from '../components/controls/select_severity'; +import { showCharts$ } from '../components/controls/checkbox_showcharts'; +import { subscribeAppStateToObservable } from '../util/app_state_utils'; + import { APP_STATE_ACTION, EXPLORER_ACTION } from './explorer_constants'; +const template = ``; + uiRoutes .when('/explorer/?', { + controller: 'MlExplorerController', template, k7Breadcrumbs: getAnomalyExplorerBreadcrumbs, resolve: { @@ -57,24 +59,13 @@ import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); module.controller('MlExplorerController', function ( - $injector, $scope, $timeout, + $rootScope, AppState, - Private, - config, globalState, ) { - - // Even if they are not used directly anymore in this controller but via imports - // in React components, because of the use of AppState and its dependency on angularjs - // these services still need to be required here to properly initialize. - $injector.get('mlCheckboxShowChartsService'); - $injector.get('mlSelectIntervalService'); - $injector.get('mlSelectLimitService'); - $injector.get('mlSelectSeverityService'); - - const mlJobSelectService = $injector.get('mlJobSelectService'); + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); const subscriptions = new Subscription(); // $scope should only contain what's actually still necessary for the angular part. @@ -83,10 +74,6 @@ module.controller('MlExplorerController', function ( timefilter.enableTimeRangeSelector(); timefilter.enableAutoRefreshSelector(); - // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. - const tzConfig = config.get('dateFormat:tz'); - $scope.dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); - $scope.MlTimeBuckets = MlTimeBuckets; let resizeTimeout = null; @@ -185,7 +172,7 @@ module.controller('MlExplorerController', function ( swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName, }); - subscriptions.add(mlJobSelectService.subscribe(({ selection }) => { + subscriptions.add(jobSelectService.subscribe(({ selection }) => { if (selection !== undefined) { $scope.jobSelectionUpdateInProgress = true; jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds: selection }); @@ -223,12 +210,15 @@ module.controller('MlExplorerController', function ( }); // Add a watcher for auto-refresh of the time filter to refresh all the data. - const refreshWatcher = Private(refreshIntervalWatcher); - refreshWatcher.init(async () => { + subscriptions.add(mlTimefilterRefresh$.subscribe(() => { if ($scope.jobSelectionUpdateInProgress === false) { explorer$.next({ action: EXPLORER_ACTION.RELOAD }); } - }); + })); + + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => $rootScope.$applyAsync())); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => $rootScope.$applyAsync())); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => $rootScope.$applyAsync())); // Redraw the swimlane when the window resizes or the global nav is toggled. function jqueryRedrawOnResize() { @@ -298,9 +288,9 @@ module.controller('MlExplorerController', function ( $scope.$on('$destroy', () => { subscriptions.unsubscribe(); - refreshWatcher.cancel(); $(window).off('resize', jqueryRedrawOnResize); // Cancel listening for updates to the global nav state. navListener(); + unsubscribeFromGlobalState(); }); }); diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js index 4cfaed04a12a7..0f6a8dcaa0b5a 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_react_wrapper_directive.js @@ -11,30 +11,47 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Explorer } from './explorer'; +import moment from 'moment-timezone'; import { uiModules } from 'ui/modules'; const module = uiModules.get('apps/ml'); import { I18nContext } from 'ui/i18n'; -import { mapScopeToProps } from './explorer_utils'; +import { jobSelectServiceFactory } from '../components/job_selector/job_select_service_utils'; + +import { Explorer } from './explorer'; import { EXPLORER_ACTION } from './explorer_constants'; import { explorer$ } from './explorer_dashboard_service'; -module.directive('mlExplorerReactWrapper', function () { +module.directive('mlExplorerReactWrapper', function (config, globalState) { function link(scope, element) { + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); + // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. + const tzConfig = config.get('dateFormat:tz'); + const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); + ReactDOM.render( - {React.createElement(Explorer, mapScopeToProps(scope))}, + + + , element[0] ); explorer$.next({ action: EXPLORER_ACTION.LOAD_JOBS }); - element.on('$destroy', () => { ReactDOM.unmountComponentAtNode(element[0]); scope.$destroy(); + unsubscribeFromGlobalState(); }); } diff --git a/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js b/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js index 39f294a567528..eee0f349dcd33 100644 --- a/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/explorer/explorer_utils.js @@ -58,15 +58,6 @@ export function getDefaultViewBySwimlaneData() { }; } -export function mapScopeToProps(scope) { - return { - appStateHandler: scope.appStateHandler, - dateFormatTz: scope.dateFormatTz, - mlJobSelectService: scope.mlJobSelectService, - MlTimeBuckets: scope.MlTimeBuckets, - }; -} - export async function getFilteredTopInfluencers( jobIds, earliestMs, diff --git a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/import_view/importer/csv_importer.js b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/import_view/importer/csv_importer.js index 3e0e003e70638..080f1e87e3d55 100644 --- a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/import_view/importer/csv_importer.js +++ b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/import_view/importer/csv_importer.js @@ -19,7 +19,6 @@ export class CsvImporter extends Importer { this.hasHeaderRow = results.has_header_row; this.columnNames = results.column_names; this.shouldTrimFields = (results.should_trim_fields || false); - this.mappings = results.mappings; } async read(csv) { diff --git a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js index 53d6de0f8b5ea..5db5228aa4425 100644 --- a/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js +++ b/x-pack/legacy/plugins/ml/public/file_datavisualizer/components/utils/index.js @@ -5,4 +5,10 @@ */ -export * from './utils'; +export { + createUrlOverrides, + hasImportPermission, + processResults, + readFile, + reduceData, +} from './utils'; diff --git a/x-pack/legacy/plugins/ml/public/file_datavisualizer/file_datavisualizer.js b/x-pack/legacy/plugins/ml/public/file_datavisualizer/file_datavisualizer.js index c6ee2e2761d9c..02d5761041a07 100644 --- a/x-pack/legacy/plugins/ml/public/file_datavisualizer/file_datavisualizer.js +++ b/x-pack/legacy/plugins/ml/public/file_datavisualizer/file_datavisualizer.js @@ -4,25 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FileDataVisualizerView } from './components/file_datavisualizer_view'; - -import React from 'react'; +import React, { Fragment } from 'react'; -import chrome from 'ui/chrome'; import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; -import { NavigationMenuContext } from '../util/context_utils'; import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; +import { FileDataVisualizerView } from './components/file_datavisualizer_view'; + export function FileDataVisualizerPage({ indexPatterns, kibanaConfig }) { timefilter.disableTimeRangeSelector(); timefilter.disableAutoRefreshSelector(); return ( - + - + ); } diff --git a/x-pack/legacy/plugins/ml/public/jobs/index.js b/x-pack/legacy/plugins/ml/public/jobs/index.js index cc1aeb85b8b59..9b0246240da68 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/index.js +++ b/x-pack/legacy/plugins/ml/public/jobs/index.js @@ -14,3 +14,4 @@ import './new_job/simple/population'; import './new_job/simple/recognize'; import './new_job/wizard'; import 'plugins/ml/components/validate_job'; +import './new_job_new'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/extract_job_details.js index 601ab270a5219..2d8fcb55d7f74 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/extract_job_details.js @@ -122,6 +122,14 @@ export function extractJobDetails(job) { if (job.node) { datafeed.items.push(['node', JSON.stringify(job.node)]); } + if (job.datafeed_config && job.datafeed_config.timing_stats) { + // remove the timing_stats list from the datafeed section + // so not to show it twice. + const i = datafeed.items.findIndex(item => item[0] === 'timing_stats'); + if (i >= 0) { + datafeed.items.splice(i, 1); + } + } const counts = { title: i18n.translate('xpack.ml.jobsList.jobDetails.countsTitle', { @@ -139,6 +147,17 @@ export function extractJobDetails(job) { items: filterObjects(job.model_size_stats).map(formatValues) }; + const datafeedTimingStats = { + title: i18n.translate('xpack.ml.jobsList.jobDetails.datafeedTimingStatsTitle', { + defaultMessage: 'Timing stats' + }), + position: 'right', + items: (job.datafeed_config && job.datafeed_config.timing_stats) ? + filterObjects(job.datafeed_config.timing_stats) + .filter(o => o[0] !== 'total_search_time_ms') // remove total_search_time_ms as average_search_time_per_bucket_ms is better + .map(formatValues) : [] + }; + return { general, customUrl, @@ -151,6 +170,7 @@ export function extractJobDetails(job) { dataDescription, datafeed, counts, - modelSizeStats + modelSizeStats, + datafeedTimingStats }; } diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js index 1711740d20bba..08072bbab66e5 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_details/job_details.js @@ -67,7 +67,8 @@ class JobDetailsUI extends Component { dataDescription, datafeed, counts, - modelSizeStats + modelSizeStats, + datafeedTimingStats } = extractJobDetails(job); const { intl } = this.props; @@ -93,7 +94,7 @@ class JobDetailsUI extends Component { id: 'xpack.ml.jobsList.jobDetails.tabs.datafeedLabel', defaultMessage: 'Datafeed' }), - content: , + content: , }, { id: 'counts', name: intl.formatMessage({ diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 69696e07ee8ff..aa4e8a756964e 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -127,7 +127,8 @@ export class JobsListView extends Component { } else { this.setRefreshInterval(value); } - this.refreshJobSummaryList(); + // force load the jobs list when the refresh interval changes + this.refreshJobSummaryList(true); } setRefreshInterval(interval) { diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts similarity index 99% rename from x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js rename to x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts index dc1bb83bf3f4d..913dc4a9510f3 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ - export { MLJobEditor, EDITOR_MODE } from './ml_job_editor'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.d.ts b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.d.ts new file mode 100644 index 0000000000000..a5af8a872f754 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/ml_job_editor/ml_job_editor.d.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function MLJobEditor(props: any): any; +export const EDITOR_MODE: any; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js index e39d599d0f3c4..0b347a35ce73a 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/directive.js @@ -17,12 +17,8 @@ import { checkGetJobsPrivilege } from 'plugins/ml/privilege/check_privilege'; import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; import { getJobManagementBreadcrumbs } from 'plugins/ml/jobs/breadcrumbs'; import { loadNewJobDefaults } from 'plugins/ml/jobs/new_job/utils/new_job_defaults'; -import { NavigationMenuContext } from '../../util/context_utils'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; const template = ``; @@ -49,9 +45,7 @@ module.directive('jobsPage', function () { link: (scope, element) => { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js index b6f9723ca82a1..4a4b9e95e3dfe 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/utils/search_service.js @@ -8,9 +8,9 @@ import _ from 'lodash'; -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; -import { escapeForElasticsearchQuery } from 'plugins/ml/util/string_utils'; -import { ml } from 'plugins/ml/services/ml_api_service'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; +import { escapeForElasticsearchQuery } from '../../../../../util/string_utils'; +import { ml } from '../../../../../services/ml_api_service'; // detector swimlane search function getScoresByRecord(jobId, earliestMs, latestMs, interval, firstSplitField) { @@ -163,5 +163,3 @@ export const mlSimpleJobSearchService = { getScoresByRecord, getCategoryFields }; - - diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js index 536b0b3b75045..447869ff8fdb4 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/simple/components/watcher/watch.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; +import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; export const watch = { trigger: { diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts new file mode 100644 index 0000000000000..4e37e01b79fd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_defaults.d.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// TODO - add correct types for these return settings +export function loadNewJobDefaults(): Promise; +export function newJobDefaults(): any; +export function newJobLimits(): any; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts new file mode 100644 index 0000000000000..35596917960c5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/utils/new_job_utils.d.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { IndexPatternTitle } from '../../../../common/types/kibana'; + +export interface SearchItems { + indexPattern: IndexPattern; + savedSearch: SavedSearch; + query: any; + combinedQuery: any; +} + +export function SearchItemsProvider($route: Record, config: any): () => SearchItems; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/preconfigured_job_redirect.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/preconfigured_job_redirect.js index 65e1e196b2d84..963cefa1abbab 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/preconfigured_job_redirect.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/preconfigured_job_redirect.js @@ -93,7 +93,7 @@ function getWizardUrlFromCloningJob(job) { } const indexPatternId = getIndexPatternIdFromName(job.datafeed_config.indices[0]); - return `jobs/new_job/simple/${page}?index=${indexPatternId}&_g=()`; + return `jobs/new_job/new_new_job/${page}?index=${indexPatternId}&_g=()`; } else { return null; } diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/index_or_search_controller.js b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/index_or_search_controller.js index 7f2295128312c..b5cef1584d93c 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/index_or_search_controller.js +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/index_or_search/index_or_search_controller.js @@ -66,7 +66,7 @@ module.controller('MlNewJobStepIndexOrSearch', timefilter.disableTimeRangeSelector(); // remove time picker from top of page timefilter.disableAutoRefreshSelector(); // remove time picker from top of page - $scope.indexPatterns = getIndexPatterns().filter(indexPattern => !indexPattern.get('type')); + $scope.indexPatterns = getIndexPatterns().filter(indexPattern => indexPattern.type !== 'rollup'); const path = $route.current.locals.nextStepPath; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html index 57c7a3bd2bfbe..dd173f98343b6 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job/wizard/steps/job_type/job_type.html @@ -213,6 +213,102 @@
+ +
+
diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts new file mode 100644 index 0000000000000..101841c3b7066 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/chart_loader.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { Field, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { ml } from '../../../../services/ml_api_service'; +import { mlResultsService } from '../../../../services/results_service'; +import { getCategoryFields } from './searches'; + +type DetectorIndex = number; +export interface LineChartPoint { + time: number | string; + value: number; +} +type SplitFieldValue = string | null; +export type LineChartData = Record; + +export class ChartLoader { + protected _indexPattern: IndexPattern; + protected _savedSearch: SavedSearch; + protected _indexPatternTitle: IndexPatternTitle = ''; + protected _timeFieldName: string = ''; + protected _query: object = {}; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + this._indexPattern = indexPattern; + this._savedSearch = savedSearch; + this._indexPatternTitle = indexPattern.title; + this._query = query; + + if (typeof indexPattern.timeFieldName === 'string') { + this._timeFieldName = indexPattern.timeFieldName; + } + } + + async loadLineCharts( + start: number, + end: number, + aggFieldPairs: AggFieldPair[], + splitField: SplitField, + splitFieldValue: SplitFieldValue, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const splitFieldName = splitField !== null ? splitField.name : null; + + const resp = await ml.jobs.newJobLineChart( + this._indexPatternTitle, + this._timeFieldName, + start, + end, + intervalMs, + this._query, + aggFieldPairs.map(getAggFieldPairNames), + splitFieldName, + splitFieldValue + ); + return resp.results; + } + return {}; + } + + async loadPopulationCharts( + start: number, + end: number, + aggFieldPairs: AggFieldPair[], + splitField: SplitField, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const splitFieldName = splitField !== null ? splitField.name : ''; + + const resp = await ml.jobs.newJobPopulationsChart( + this._indexPatternTitle, + this._timeFieldName, + start, + end, + intervalMs, + this._query, + aggFieldPairs.map(getAggFieldPairNames), + splitFieldName + ); + return resp.results; + } + return {}; + } + + async loadEventRateChart( + start: number, + end: number, + intervalMs: number + ): Promise { + if (this._timeFieldName !== '') { + const resp = await mlResultsService.getEventRateData( + this._indexPatternTitle, + this._query, + this._timeFieldName, + start, + end, + intervalMs * 3 + ); + return Object.entries(resp.results).map(([time, value]) => ({ + time: +time, + value: value as number, + })); + } + return []; + } + + async loadFieldExampleValues(field: Field): Promise { + const { results } = await getCategoryFields( + this._indexPatternTitle, + field.name, + 10, + this._query + ); + return results; + } +} + +export function getAggFieldPairNames(af: AggFieldPair) { + const by = + af.by !== undefined && af.by.field !== null && af.by.value !== null + ? { field: af.by.field.id, value: af.by.value } + : { field: null, value: null }; + + return { + agg: af.agg.dslName, + field: af.field.id, + by, + }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts new file mode 100644 index 0000000000000..73e8f88df479b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ChartLoader, LineChartData, LineChartPoint } from './chart_loader'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts new file mode 100644 index 0000000000000..f40b93fd8de89 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/chart_loader/searches.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +import { ml } from '../../../../services/ml_api_service'; + +interface CategoryResults { + success: boolean; + results: string[]; +} + +export function getCategoryFields( + indexPatternName: string, + fieldName: string, + size: number, + query: any +): Promise { + return new Promise((resolve, reject) => { + ml.esSearch({ + index: indexPatternName, + size: 0, + body: { + query, + aggs: { + catFields: { + terms: { + field: fieldName, + size, + }, + }, + }, + }, + }) + .then((resp: any) => { + const catFields = get(resp, ['aggregations', 'catFields', 'buckets'], []); + + resolve({ + success: true, + results: catFields.map((f: any) => f.key), + }); + }) + .catch((resp: any) => { + reject(resp); + }); + }); +} diff --git a/x-pack/legacy/plugins/infra/server/graphql/metadata/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts similarity index 70% rename from x-pack/legacy/plugins/infra/server/graphql/metadata/index.ts rename to x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts index cda731bdaa9b6..81cc00155a64c 100644 --- a/x-pack/legacy/plugins/infra/server/graphql/metadata/index.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index.ts @@ -4,5 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createMetadataResolvers } from './resolvers'; -export { metadataSchema } from './schema.gql'; +export * from './index_pattern_context'; +export * from './job_creator'; +export * from './job_runner'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts new file mode 100644 index 0000000000000..aa92536da8d1d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/index_pattern_context.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { StaticIndexPattern } from 'ui/index_patterns'; + +export type IndexPatternContextValue = StaticIndexPattern | null; +export const IndexPatternContext = React.createContext(null); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/combined_job.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/combined_job.ts new file mode 100644 index 0000000000000..435b7696af398 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/combined_job.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import { Datafeed } from './datafeed'; +import { Job } from './job'; + +// in older implementations of the job config, the datafeed was placed inside the job +// for convenience. +export interface CombinedJob extends Job { + datafeed_config: Datafeed; +} + +export function expandCombinedJobConfig(combinedJob: CombinedJob) { + const combinedJobClone = cloneDeep(combinedJob); + const job = combinedJobClone; + const datafeed = combinedJobClone.datafeed_config; + delete job.datafeed_config; + + return { job, datafeed }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.ts new file mode 100644 index 0000000000000..d0afbc757baf3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/datafeed.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { JobId } from './job'; +export type DatafeedId = string; + +export interface Datafeed { + datafeed_id: DatafeedId; + aggregations?: object; + chunking_config?: ChunkingConfig; + frequency?: string; + indices: IndexPatternTitle[]; + job_id?: JobId; + query: object; + query_delay?: string; + script_fields?: object; + scroll_size?: number; + delayed_data_check_config?: object; +} + +export interface ChunkingConfig { + mode: 'auto' | 'manual' | 'off'; + time_span?: string; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/index.ts new file mode 100644 index 0000000000000..c8b71ead4b6fb --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './job'; +export * from './datafeed'; +export * from './combined_job'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts new file mode 100644 index 0000000000000..cf9407e0c9511 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/configs/job.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type JobId = string; +export type BucketSpan = string; + +export interface Job { + job_id: JobId; + analysis_config: AnalysisConfig; + analysis_limits?: AnalysisLimits; + background_persist_interval?: string; + custom_settings?: any; + data_description: DataDescription; + description: string; + groups: string[]; + calendars?: string[]; + model_plot_config?: ModelPlotConfig; + model_snapshot_retention_days?: number; + renormalization_window_days?: number; + results_index_name?: string; + results_retention_days?: number; +} + +export interface AnalysisConfig { + bucket_span: BucketSpan; + categorization_field_name?: string; + categorization_filters?: string[]; + categorization_analyzer?: object | string; + detectors: Detector[]; + influencers: string[]; + latency?: number; + multivariate_by_fields?: boolean; + summary_count_field_name?: string; +} + +export interface Detector { + by_field_name?: string; + detector_description?: string; + detector_index?: number; + exclude_frequent?: string; + field_name?: string; + function: string; + over_field_name?: string; + partition_field_name?: string; + use_null?: string; + custom_rules?: CustomRule[]; +} +export interface AnalysisLimits { + categorization_examples_limit?: number; + model_memory_limit: string; +} + +export interface DataDescription { + format?: string; + time_field: string; + time_format?: string; +} + +export interface ModelPlotConfig { + enabled: boolean; + terms?: string; +} + +// TODO, finish this when it's needed +export interface CustomRule { + actions: any; + scope: object; + conditions: object; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts new file mode 100644 index 0000000000000..eca52c064ce67 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobCreator } from './job_creator'; +export { SingleMetricJobCreator } from './single_metric_job_creator'; +export { MultiMetricJobCreator } from './multi_metric_job_creator'; +export { PopulationJobCreator } from './population_job_creator'; +export { + isSingleMetricJobCreator, + isMultiMetricJobCreator, + isPopulationJobCreator, +} from './type_guards'; +export { jobCreatorFactory } from './job_creator_factory'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts new file mode 100644 index 0000000000000..7787b4d3fbb14 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator.ts @@ -0,0 +1,374 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { Job, Datafeed, Detector, JobId, DatafeedId, BucketSpan } from './configs'; +import { Aggregation, Field } from '../../../../../common/types/fields'; +import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; +import { mlJobService } from '../../../../services/job_service'; +import { JobRunner, ProgressSubscriber } from '../job_runner'; +import { JOB_TYPE } from './util/constants'; + +export class JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; + protected _indexPattern: IndexPattern; + protected _savedSearch: SavedSearch; + protected _indexPatternTitle: IndexPatternTitle = ''; + protected _job_config: Job; + protected _datafeed_config: Datafeed; + protected _detectors: Detector[]; + protected _influencers: string[]; + protected _useDedicatedIndex: boolean = false; + protected _start: number = 0; + protected _end: number = 0; + protected _subscribers: ProgressSubscriber[]; + protected _aggs: Aggregation[] = []; + protected _fields: Field[] = []; + private _stopAllRefreshPolls: { + stop: boolean; + }; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + this._indexPattern = indexPattern; + this._savedSearch = savedSearch; + this._indexPatternTitle = indexPattern.title; + + this._job_config = createEmptyJob(); + this._datafeed_config = createEmptyDatafeed(this._indexPatternTitle); + this._detectors = this._job_config.analysis_config.detectors; + this._influencers = this._job_config.analysis_config.influencers; + + if (typeof indexPattern.timeFieldName === 'string') { + this._job_config.data_description.time_field = indexPattern.timeFieldName; + } + + this._datafeed_config.query = query; + this._subscribers = []; + this._stopAllRefreshPolls = { stop: false }; + } + + public get type(): JOB_TYPE { + return this._type; + } + + protected _addDetector(detector: Detector, agg: Aggregation, field: Field) { + this._detectors.push(detector); + this._aggs.push(agg); + this._fields.push(field); + } + + protected _editDetector(detector: Detector, agg: Aggregation, field: Field, index: number) { + if (this._detectors[index] !== undefined) { + this._detectors[index] = detector; + this._aggs[index] = agg; + this._fields[index] = field; + } + } + + protected _removeDetector(index: number) { + this._detectors.splice(index, 1); + this._aggs.splice(index, 1); + this._fields.splice(index, 1); + } + + public removeAllDetectors() { + this._detectors.length = 0; + this._aggs.length = 0; + this._fields.length = 0; + } + + public get detectors(): Detector[] { + return this._detectors; + } + + public get aggregationsInDetectors(): Aggregation[] { + return this._aggs; + } + + public getAggregation(index: number): Aggregation | null { + const agg = this._aggs[index]; + return agg !== undefined ? agg : null; + } + + public getField(index: number): Field | null { + const field = this._fields[index]; + return field !== undefined ? field : null; + } + + public set bucketSpan(bucketSpan: BucketSpan) { + this._job_config.analysis_config.bucket_span = bucketSpan; + } + + public get bucketSpan(): BucketSpan { + return this._job_config.analysis_config.bucket_span; + } + + public addInfluencer(influencer: string) { + if (this._influencers.includes(influencer) === false) { + this._influencers.push(influencer); + } + } + + public removeInfluencer(influencer: string) { + const idx = this._influencers.indexOf(influencer); + if (idx !== -1) { + this._influencers.splice(idx, 1); + } + } + + public removeAllInfluencers() { + this._influencers.length = 0; + } + + public get influencers(): string[] { + return this._influencers; + } + + public set jobId(jobId: JobId) { + this._job_config.job_id = jobId; + this._datafeed_config.job_id = jobId; + this._datafeed_config.datafeed_id = `datafeed-${jobId}`; + + if (this._useDedicatedIndex) { + this._job_config.results_index_name = jobId; + } + } + + public get jobId(): JobId { + return this._job_config.job_id; + } + + public get datafeedId(): DatafeedId { + return this._datafeed_config.datafeed_id; + } + + public set description(description: string) { + this._job_config.description = description; + } + + public get description(): string { + return this._job_config.description; + } + + public get groups(): string[] { + return this._job_config.groups; + } + + public set groups(groups: string[]) { + this._job_config.groups = groups; + } + + public get calendars(): string[] { + return this._job_config.calendars || []; + } + + public set calendars(calendars: string[]) { + this._job_config.calendars = calendars; + } + + public set modelPlot(enable: boolean) { + if (enable) { + this._job_config.model_plot_config = { + enabled: true, + }; + } else { + delete this._job_config.model_plot_config; + } + } + + public get modelPlot() { + return ( + this._job_config.model_plot_config !== undefined && + this._job_config.model_plot_config.enabled === true + ); + } + + public set useDedicatedIndex(enable: boolean) { + this._useDedicatedIndex = enable; + if (enable) { + this._job_config.results_index_name = this._job_config.job_id; + } else { + delete this._job_config.results_index_name; + } + } + + public get useDedicatedIndex(): boolean { + return this._useDedicatedIndex; + } + + public set modelMemoryLimit(mml: string | null) { + if (mml !== null) { + this._job_config.analysis_limits = { + model_memory_limit: mml, + }; + } else { + delete this._job_config.analysis_limits; + } + } + + public get modelMemoryLimit(): string | null { + if ( + this._job_config.analysis_limits && + this._job_config.analysis_limits.model_memory_limit !== undefined + ) { + return this._job_config.analysis_limits.model_memory_limit; + } else { + return null; + } + } + + public setTimeRange(start: number, end: number) { + this._start = start; + this._end = end; + } + + public get start(): number { + return this._start; + } + + public get end(): number { + return this._end; + } + + public get query(): object { + return this._datafeed_config.query; + } + + public set query(query: object) { + this._datafeed_config.query = query; + } + + public get subscribers(): ProgressSubscriber[] { + return this._subscribers; + } + + public async createAndStartJob() { + try { + await this.createJob(); + await this.createDatafeed(); + await this.startDatafeed(); + } catch (error) { + throw error; + } + } + + public async createJob(): Promise { + try { + const { success, resp } = await mlJobService.saveNewJob(this._job_config); + if (success === true) { + return resp; + } else { + throw resp; + } + } catch (error) { + throw error; + } + } + + public async createDatafeed(): Promise { + try { + return await mlJobService.saveNewDatafeed(this._datafeed_config, this._job_config.job_id); + } catch (error) { + throw error; + } + } + + // create a jobRunner instance, start it and return it + public async startDatafeed(): Promise { + const jobRunner = new JobRunner(this); + await jobRunner.startDatafeed(); + return jobRunner; + } + + public subscribeToProgress(func: ProgressSubscriber) { + this._subscribers.push(func); + } + + public get jobConfig(): Job { + return this._job_config; + } + + public get datafeedConfig(): Datafeed { + return this._datafeed_config; + } + + public get stopAllRefreshPolls(): { stop: boolean } { + return this._stopAllRefreshPolls; + } + + public forceStopRefreshPolls() { + this._stopAllRefreshPolls.stop = true; + } + + private _setCustomSetting(setting: string, value: string | object | null) { + if (value === null) { + // if null is passed in, delete the custom setting + if ( + this._job_config.custom_settings !== undefined && + this._job_config.custom_settings[setting] !== undefined + ) { + delete this._job_config.custom_settings[setting]; + + if (Object.keys(this._job_config.custom_settings).length === 0) { + // clean up custom_settings if there's nothing else in there + delete this._job_config.custom_settings; + } + } + } else { + if (this._job_config.custom_settings === undefined) { + // if custom_settings doesn't exist, create it. + this._job_config.custom_settings = { + [setting]: value, + }; + } else { + this._job_config.custom_settings[setting] = value; + } + } + } + + private _getCustomSetting(setting: string): string | object | null { + if ( + this._job_config.custom_settings !== undefined && + this._job_config.custom_settings[setting] !== undefined + ) { + return this._job_config.custom_settings[setting]; + } + return null; + } + + public set createdBy(createdBy: string | null) { + this._setCustomSetting('created_by', createdBy); + } + + public get createdBy(): string | null { + return this._getCustomSetting('created_by') as string | null; + } + + public get formattedJobJson() { + return JSON.stringify(this._job_config, null, 2); + } + + public get formattedDatafeedJson() { + return JSON.stringify(this._datafeed_config, null, 2); + } + + protected _overrideConfigs(job: Job, datafeed: Datafeed) { + this._job_config = job; + this._datafeed_config = datafeed; + + this._detectors = this._job_config.analysis_config.detectors; + this._influencers = this._job_config.analysis_config.influencers; + if (this._job_config.groups === undefined) { + this._job_config.groups = []; + } + + if (this._job_config.analysis_config.influencers !== undefined) { + this._job_config.analysis_config.influencers.forEach(i => this.addInfluencer(i)); + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts new file mode 100644 index 0000000000000..d2194b57e2f1b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/job_creator_factory.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { SingleMetricJobCreator } from './single_metric_job_creator'; +import { MultiMetricJobCreator } from './multi_metric_job_creator'; +import { PopulationJobCreator } from './population_job_creator'; + +import { JOB_TYPE } from './util/constants'; + +export const jobCreatorFactory = (jobType: JOB_TYPE) => ( + indexPattern: IndexPattern, + savedSearch: SavedSearch, + query: object +) => { + let jc; + switch (jobType) { + case JOB_TYPE.SINGLE_METRIC: + jc = SingleMetricJobCreator; + break; + case JOB_TYPE.MULTI_METRIC: + jc = MultiMetricJobCreator; + break; + case JOB_TYPE.POPULATION: + jc = PopulationJobCreator; + break; + default: + jc = SingleMetricJobCreator; + break; + } + return new jc(indexPattern, savedSearch, query); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts new file mode 100644 index 0000000000000..c44cadca33adb --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/multi_metric_job_creator.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { Job, Datafeed, Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE, CREATED_BY_LABEL, DEFAULT_MODEL_MEMORY_LIMIT } from './util/constants'; +import { ml } from '../../../../services/ml_api_service'; +import { getRichDetectors } from './util/general'; + +export class MultiMetricJobCreator extends JobCreator { + // a multi metric job has one optional overall partition field + // which is the same for all detectors. + private _splitField: SplitField = null; + private _lastEstimatedModelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + protected _type: JOB_TYPE = JOB_TYPE.MULTI_METRIC; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.MULTI_METRIC; + } + + // set the split field, applying it to each detector + public setSplitField(field: SplitField) { + this._splitField = field; + + if (this._splitField === null) { + this.removeSplitField(); + } else { + for (let i = 0; i < this._detectors.length; i++) { + this._detectors[i].partition_field_name = this._splitField.id; + } + } + } + + public removeSplitField() { + this._detectors.forEach(d => { + delete d.partition_field_name; + }); + } + + public get splitField(): SplitField { + return this._splitField; + } + + public addDetector(agg: Aggregation, field: Field) { + const dtr: Detector = this._createDetector(agg, field); + this._addDetector(dtr, agg, field); + } + + public editDetector(agg: Aggregation, field: Field, index: number) { + const dtr: Detector = this._createDetector(agg, field); + this._editDetector(dtr, agg, field, index); + } + + // create a new detector object, applying the overall split field + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (this._splitField !== null) { + dtr.partition_field_name = this._splitField.id; + } + return dtr; + } + + public removeDetector(index: number) { + this._removeDetector(index); + } + + // called externally to set the model memory limit based current detector configuration + public async calculateModelMemoryLimit() { + if (this._splitField === null) { + // not split field, use the default + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } else { + const fieldNames = this._detectors.map(d => d.field_name).filter(fn => fn !== undefined); + const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ + indexPattern: this._indexPatternTitle, + splitFieldName: this._splitField.name, + query: this._datafeed_config.query, + fieldNames, + influencerNames: this._influencers, + timeFieldName: this._job_config.data_description.time_field, + earliestMs: this._start, + latestMs: this._end, + }); + + try { + if (this.modelMemoryLimit === null) { + this.modelMemoryLimit = modelMemoryLimit; + } else { + // To avoid overwriting a possible custom set model memory limit, + // it only gets set to the estimation if the current limit is either + // the default value or the value of the previous estimation. + // That's our best guess if the value hasn't been customized. + // It doesn't get it if the user intentionally for whatever reason (re)set + // the value to either the default or pervious estimate. + // Because the string based limit could contain e.g. MB/Mb/mb + // all strings get lower cased for comparison. + const currentModelMemoryLimit = this.modelMemoryLimit.toLowerCase(); + const defaultModelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT.toLowerCase(); + if ( + currentModelMemoryLimit === defaultModelMemoryLimit || + currentModelMemoryLimit === this._lastEstimatedModelMemoryLimit + ) { + this.modelMemoryLimit = modelMemoryLimit; + } + } + this._lastEstimatedModelMemoryLimit = modelMemoryLimit.toLowerCase(); + } catch (error) { + if (this.modelMemoryLimit === null) { + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } else { + // To avoid overwriting a possible custom set model memory limit, + // the limit is reset to the default only if the current limit matches + // the previous estimated limit. + const currentModelMemoryLimit = this.modelMemoryLimit.toLowerCase(); + if (currentModelMemoryLimit === this._lastEstimatedModelMemoryLimit) { + this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } + // eslint-disable-next-line no-console + console.error('Model memory limit could not be calculated', error); + } + } + } + } + + public get aggFieldPairs(): AggFieldPair[] { + return this.detectors.map((d, i) => ({ + field: this._fields[i], + agg: this._aggs[i], + })); + } + + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { + this._overrideConfigs(job, datafeed); + this.jobId = ''; + const detectors = getRichDetectors(job.analysis_config.detectors); + + this.removeAllDetectors(); + + detectors.forEach((d, i) => { + const dtr = detectors[i]; + if (dtr.agg !== null && dtr.field !== null) { + this.addDetector(dtr.agg, dtr.field); + } + }); + if (detectors.length) { + if (detectors[0].partitionField !== null) { + this.setSplitField(detectors[0].partitionField); + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts new file mode 100644 index 0000000000000..d567e108e02e8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/population_job_creator.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, SplitField, AggFieldPair } from '../../../../../common/types/fields'; +import { Job, Datafeed, Detector } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { JOB_TYPE, CREATED_BY_LABEL } from './util/constants'; +import { getRichDetectors } from './util/general'; + +export class PopulationJobCreator extends JobCreator { + // a population job has one overall over (split) field, which is the same for all detectors + // each detector has an optional by field + private _splitField: SplitField = null; + private _byFields: SplitField[] = []; + protected _type: JOB_TYPE = JOB_TYPE.POPULATION; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.POPULATION; + } + + // add a by field to a specific detector + public setByField(field: SplitField, index: number) { + if (field === null) { + this.removeByField(index); + } else { + if (this._detectors[index] !== undefined) { + this._byFields[index] = field; + this._detectors[index].by_field_name = field.id; + } + } + } + + // remove a by field from a specific detector + public removeByField(index: number) { + if (this._detectors[index] !== undefined) { + this._byFields[index] = null; + delete this._detectors[index].by_field_name; + } + } + + // get the by field for a specific detector + public getByField(index: number): SplitField { + if (this._byFields[index] === undefined) { + return null; + } + return this._byFields[index]; + } + + // add an over field to all detectors + public setSplitField(field: SplitField) { + this._splitField = field; + + if (this._splitField === null) { + this.removeSplitField(); + } else { + for (let i = 0; i < this._detectors.length; i++) { + this._detectors[i].over_field_name = this._splitField.id; + } + } + } + + // remove over field from all detectors + public removeSplitField() { + this._detectors.forEach(d => { + delete d.over_field_name; + }); + } + + public get splitField(): SplitField { + return this._splitField; + } + + public addDetector(agg: Aggregation, field: Field) { + const dtr: Detector = this._createDetector(agg, field); + + this._addDetector(dtr, agg, field); + this._byFields.push(null); + } + + // edit a specific detector, reapplying the by field + // already set on the the detector at that index + public editDetector(agg: Aggregation, field: Field, index: number) { + const dtr: Detector = this._createDetector(agg, field); + + const sp = this._byFields[index]; + if (sp !== undefined && sp !== null) { + dtr.by_field_name = sp.id; + } + + this._editDetector(dtr, agg, field, index); + } + + // create a detector object, adding the current over field + private _createDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (field !== null) { + dtr.field_name = field.id; + } + + if (this._splitField !== null) { + dtr.over_field_name = this._splitField.id; + } + return dtr; + } + + public removeDetector(index: number) { + this._removeDetector(index); + this._byFields.splice(index, 1); + } + + public get aggFieldPairs(): AggFieldPair[] { + return this.detectors.map((d, i) => ({ + field: this._fields[i], + agg: this._aggs[i], + by: { + field: this._byFields[i], + value: null, + }, + })); + } + + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { + this._overrideConfigs(job, datafeed); + this.jobId = ''; + const detectors = getRichDetectors(job.analysis_config.detectors); + + this.removeAllDetectors(); + + if (detectors.length) { + if (detectors[0].overField !== null) { + this.setSplitField(detectors[0].overField); + } + } + detectors.forEach((d, i) => { + const dtr = detectors[i]; + if (dtr.agg !== null && dtr.field !== null) { + this.addDetector(dtr.agg, dtr.field); + if (dtr.byField !== null) { + this.setByField(dtr.byField, i); + } + } + }); + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts new file mode 100644 index 0000000000000..a2ba0801e3324 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/single_metric_job_creator.ts @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedSearch } from 'src/legacy/core_plugins/kibana/public/discover/types'; +import { IndexPattern } from 'ui/index_patterns'; +import { parseInterval } from 'ui/utils/parse_interval'; +import { JobCreator } from './job_creator'; +import { Field, Aggregation, AggFieldPair } from '../../../../../common/types/fields'; +import { Job, Datafeed, Detector, BucketSpan } from './configs'; +import { createBasicDetector } from './util/default_configs'; +import { KIBANA_AGGREGATION } from '../../../../../common/constants/aggregation_types'; +import { JOB_TYPE, CREATED_BY_LABEL } from './util/constants'; +import { getRichDetectors } from './util/general'; + +export class SingleMetricJobCreator extends JobCreator { + protected _type: JOB_TYPE = JOB_TYPE.SINGLE_METRIC; + + constructor(indexPattern: IndexPattern, savedSearch: SavedSearch, query: object) { + super(indexPattern, savedSearch, query); + this.createdBy = CREATED_BY_LABEL.SINGLE_METRIC; + } + + // only a single detector exists for this job type + // therefore _addDetector and _editDetector merge into this + // single setDetector function + public setDetector(agg: Aggregation, field: Field) { + const dtr: Detector = createBasicDetector(agg, field); + + if (this._detectors.length === 0) { + this._addDetector(dtr, agg, field); + } else { + this._editDetector(dtr, agg, field, 0); + } + + this._createDatafeedAggregations(); + } + + public set bucketSpan(bucketSpan: BucketSpan) { + this._job_config.analysis_config.bucket_span = bucketSpan; + this._createDatafeedAggregations(); + } + + // overriding set means we need to override get too + // JS doesn't do inheritance very well + public get bucketSpan(): BucketSpan { + return this._job_config.analysis_config.bucket_span; + } + + // aggregations need to be recreated whenever the detector or bucket_span change + private _createDatafeedAggregations() { + if ( + this._detectors.length && + typeof this._job_config.analysis_config.bucket_span === 'string' && + this._aggs.length > 0 + ) { + delete this._job_config.analysis_config.summary_count_field_name; + delete this._datafeed_config.aggregations; + + const functionName = this._aggs[0].dslName; + const timeField = this._job_config.data_description.time_field; + + const duration = parseInterval(this._job_config.analysis_config.bucket_span); + if (duration === null) { + return; + } + + const bucketSpanSeconds = duration.asSeconds(); + const interval = bucketSpanSeconds * 1000; + + let field = null; + + switch (functionName) { + case KIBANA_AGGREGATION.COUNT: + this._job_config.analysis_config.summary_count_field_name = 'doc_count'; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval}ms`, + }, + aggregations: { + [timeField]: { + max: { + field: timeField, + }, + }, + }, + }, + }; + break; + case KIBANA_AGGREGATION.AVG: + case KIBANA_AGGREGATION.MEDIAN: + case KIBANA_AGGREGATION.SUM: + case KIBANA_AGGREGATION.MIN: + case KIBANA_AGGREGATION.MAX: + field = this._fields[0]; + if (field !== null) { + const fieldName = field.name; + this._job_config.analysis_config.summary_count_field_name = 'doc_count'; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval * 0.1}ms`, // use 10% of bucketSpan to allow for better sampling + }, + aggregations: { + [fieldName]: { + [functionName]: { + field: fieldName, + }, + }, + [timeField]: { + max: { + field: timeField, + }, + }, + }, + }, + }; + } + break; + case KIBANA_AGGREGATION.CARDINALITY: + field = this._fields[0]; + if (field !== null) { + const fieldName = field.name; + + this._job_config.analysis_config.summary_count_field_name = `dc_${fieldName}`; + + this._datafeed_config.aggregations = { + buckets: { + date_histogram: { + field: timeField, + interval: `${interval}ms`, + }, + aggregations: { + [timeField]: { + max: { + field: timeField, + }, + }, + [this._job_config.analysis_config.summary_count_field_name]: { + [functionName]: { + field: fieldName, + }, + }, + }, + }, + }; + + const dtr = this._detectors[0]; + // finally, modify the detector before saving + dtr.function = 'non_zero_count'; + // add a description using the original function name rather 'non_zero_count' + // as the user may not be aware it's been changed + dtr.detector_description = `${functionName} (${fieldName})`; + delete dtr.field_name; + } + break; + default: + break; + } + } + } + + public get aggFieldPair(): AggFieldPair | null { + if (this._aggs.length === 0) { + return null; + } else { + return { + agg: this._aggs[0], + field: this._fields[0], + }; + } + } + + public cloneFromExistingJob(job: Job, datafeed: Datafeed) { + this._overrideConfigs(job, datafeed); + this.jobId = ''; + const detectors = getRichDetectors(job.analysis_config.detectors); + + this.removeAllDetectors(); + + const dtr = detectors[0]; + if (detectors.length && dtr.agg !== null && dtr.field !== null) { + this.setDetector(dtr.agg, dtr.field); + } + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts new file mode 100644 index 0000000000000..9bba4981ea078 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/type_guards.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SingleMetricJobCreator } from './single_metric_job_creator'; +import { MultiMetricJobCreator } from './multi_metric_job_creator'; +import { PopulationJobCreator } from './population_job_creator'; +import { JOB_TYPE } from './util/constants'; + +export function isSingleMetricJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is SingleMetricJobCreator { + return jobCreator.type === JOB_TYPE.SINGLE_METRIC; +} + +export function isMultiMetricJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is MultiMetricJobCreator { + return jobCreator.type === JOB_TYPE.MULTI_METRIC; +} + +export function isPopulationJobCreator( + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator +): jobCreator is PopulationJobCreator { + return jobCreator.type === JOB_TYPE.POPULATION; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts new file mode 100644 index 0000000000000..faa04dc17c845 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/constants.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum JOB_TYPE { + SINGLE_METRIC = 'single_metric', + MULTI_METRIC = 'multi_metric', + POPULATION = 'population', +} + +export enum CREATED_BY_LABEL { + SINGLE_METRIC = 'single-metric-wizard', + MULTI_METRIC = 'multi-metric-wizard', + POPULATION = 'population-wizard', +} + +export const DEFAULT_MODEL_MEMORY_LIMIT = '10MB'; +export const DEFAULT_BUCKET_SPAN = '15m'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.ts new file mode 100644 index 0000000000000..2a09415c50bc4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/default_configs.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Job, Datafeed } from '../configs'; +import { IndexPatternTitle } from '../../../../../../common/types/kibana'; +import { Field, Aggregation, EVENT_RATE_FIELD_ID } from '../../../../../../common/types/fields'; +import { Detector } from '../configs'; + +export function createEmptyJob(): Job { + return { + job_id: '', + description: '', + groups: [], + analysis_config: { + bucket_span: '', + detectors: [], + influencers: [], + }, + data_description: { + time_field: '', + }, + }; +} + +export function createEmptyDatafeed(indexPatternTitle: IndexPatternTitle): Datafeed { + return { + datafeed_id: '', + indices: [indexPatternTitle], + query: {}, + }; +} + +export function createBasicDetector(agg: Aggregation, field: Field) { + const dtr: Detector = { + function: agg.id, + }; + + if (field.id !== EVENT_RATE_FIELD_ID) { + dtr.field_name = field.id; + } + return dtr; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/general.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/general.ts new file mode 100644 index 0000000000000..3f2375c452c3c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_creator/util/general.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Detector } from '../configs'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; + +// populate the detectors with Field and Agg objects loaded from the job capabilities service +export function getRichDetectors(detectors: Detector[]) { + return detectors.map(d => { + return { + agg: newJobCapsService.getAggById(d.function), + field: d.field_name !== undefined ? newJobCapsService.getFieldById(d.field_name) : null, + byField: + d.by_field_name !== undefined ? newJobCapsService.getFieldById(d.by_field_name) : null, + overField: + d.over_field_name !== undefined ? newJobCapsService.getFieldById(d.over_field_name) : null, + partitionField: + d.partition_field_name !== undefined + ? newJobCapsService.getFieldById(d.partition_field_name) + : null, + }; + }); +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/index.ts new file mode 100644 index 0000000000000..2ec2c3037ad7e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobRunner, ProgressSubscriber } from './job_runner'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts new file mode 100644 index 0000000000000..3f8bdfa371d01 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_runner/job_runner.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { ml } from '../../../../services/ml_api_service'; +import { mlJobService } from '../../../../services/job_service'; +import { JobCreator } from '../job_creator'; +import { DatafeedId, JobId } from '../job_creator/configs'; +import { DATAFEED_STATE } from '../../../../../common/constants/states'; + +const REFRESH_INTERVAL_MS = 100; +type Progress = number; +export type ProgressSubscriber = (progress: number) => void; + +export class JobRunner { + private _jobId: JobId; + private _datafeedId: DatafeedId; + private _start: number = 0; + private _end: number = 0; + private _datafeedState: DATAFEED_STATE = DATAFEED_STATE.STOPPED; + private _refreshInterval: number = REFRESH_INTERVAL_MS; + + private _progress$: BehaviorSubject; + private _percentageComplete: Progress = 0; + private _stopRefreshPoll: { + stop: boolean; + }; + + constructor(jobCreator: JobCreator) { + this._jobId = jobCreator.jobId; + this._datafeedId = jobCreator.datafeedId; + this._start = jobCreator.start; + this._end = jobCreator.end; + this._percentageComplete = 0; + this._stopRefreshPoll = jobCreator.stopAllRefreshPolls; + + this._progress$ = new BehaviorSubject(this._percentageComplete); + // link the _subscribers list from the JobCreator + // to the progress BehaviorSubject. + jobCreator.subscribers.forEach(s => this._progress$.subscribe(s)); + } + + public get datafeedState(): DATAFEED_STATE { + return this._datafeedState; + } + + public set refreshInterval(v: number) { + this._refreshInterval = v; + } + + public resetInterval() { + this._refreshInterval = REFRESH_INTERVAL_MS; + } + + private async openJob(): Promise { + try { + await mlJobService.openJob(this._jobId); + } catch (error) { + throw error; + } + } + + // start the datafeed and then start polling for progress + // the complete percentage is added to an observable + // so all pre-subscribed listeners can follow along. + public async startDatafeed(): Promise { + try { + await this.openJob(); + await mlJobService.startDatafeed(this._datafeedId, this._jobId, this._start, this._end); + this._datafeedState = DATAFEED_STATE.STARTED; + this._percentageComplete = 0; + + const check = async () => { + const { isRunning, progress } = await this.getProgress(); + + this._percentageComplete = progress; + this._progress$.next(this._percentageComplete); + + if (isRunning === true && this._stopRefreshPoll.stop === false) { + setTimeout(async () => { + await check(); + }, this._refreshInterval); + } + }; + // wait for the first check to run and then return success. + // all subsequent checks will update the observable + await check(); + } catch (error) { + throw error; + } + } + + public async getProgress(): Promise<{ progress: Progress; isRunning: boolean }> { + return await ml.jobs.getLookBackProgress(this._jobId, this._start, this._end); + } + + public subscribeToProgress(func: ProgressSubscriber) { + this._progress$.subscribe(func); + } + + public async isRunning(): Promise { + const { isRunning } = await this.getProgress(); + return isRunning; + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/index.ts new file mode 100644 index 0000000000000..de8e2b67bfb0f --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobValidator, Validation, BasicValidations, ValidationSummary } from './job_validator'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts new file mode 100644 index 0000000000000..2d5680f1a61a0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/job_validator.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { basicJobValidation } from '../../../../../common/util/job_utils'; +import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { JobCreator } from '../job_creator'; +import { populateValidationMessages, checkForExistingJobAndGroupIds } from './util'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +// delay start of validation to allow the user to make changes +// e.g. if they are typing in a new value, try not to validate +// after every keystroke +const VALIDATION_DELAY_MS = 500; + +export interface ValidationSummary { + basic: boolean; + advanced: boolean; +} + +export interface Validation { + valid: boolean; + message?: string; +} + +export interface BasicValidations { + jobId: Validation; + groupIds: Validation; + modelMemoryLimit: Validation; + bucketSpan: Validation; + duplicateDetectors: Validation; +} + +export class JobValidator { + private _jobCreator: JobCreator; + private _validationSummary: ValidationSummary; + private _lastJobConfig: string; + private _validateTimeout: NodeJS.Timeout; + private _existingJobsAndGroups: ExistingJobsAndGroups; + private _basicValidations: BasicValidations = { + jobId: { valid: true }, + groupIds: { valid: true }, + modelMemoryLimit: { valid: true }, + bucketSpan: { valid: true }, + duplicateDetectors: { valid: true }, + }; + + constructor(jobCreator: JobCreator, existingJobsAndGroups: ExistingJobsAndGroups) { + this._jobCreator = jobCreator; + this._lastJobConfig = this._jobCreator.formattedJobJson; + this._validationSummary = { + basic: false, + advanced: false, + }; + this._validateTimeout = setTimeout(() => {}, 0); + this._existingJobsAndGroups = existingJobsAndGroups; + } + + public validate() { + const formattedJobConfig = this._jobCreator.formattedJobJson; + return new Promise((resolve: () => void) => { + // only validate if the config has changed + if (formattedJobConfig !== this._lastJobConfig) { + clearTimeout(this._validateTimeout); + this._lastJobConfig = formattedJobConfig; + this._validateTimeout = setTimeout(() => { + this._runBasicValidation(); + resolve(); + }, VALIDATION_DELAY_MS); + } else { + resolve(); + } + }); + } + + private _resetBasicValidations() { + this._validationSummary.basic = true; + Object.values(this._basicValidations).forEach(v => { + v.valid = true; + delete v.message; + }); + } + + private _runBasicValidation() { + this._resetBasicValidations(); + + const jobConfig = this._jobCreator.jobConfig; + const limits = newJobLimits(); + + // run standard basic validation + const basicResults = basicJobValidation(jobConfig, undefined, limits); + populateValidationMessages(basicResults, this._basicValidations, jobConfig); + + // run addition job and group id validation + const idResults = checkForExistingJobAndGroupIds( + this._jobCreator.jobId, + this._jobCreator.groups, + this._existingJobsAndGroups + ); + populateValidationMessages(idResults, this._basicValidations, jobConfig); + + this._validationSummary.basic = this._isOverallBasicValid(); + } + + private _isOverallBasicValid() { + return Object.values(this._basicValidations).some(v => v.valid === false) === false; + } + + public get validationSummary(): ValidationSummary { + return this._validationSummary; + } + + public get bucketSpan(): Validation { + return this._basicValidations.bucketSpan; + } + + public get duplicateDetectors(): Validation { + return this._basicValidations.duplicateDetectors; + } + + public get jobId(): Validation { + return this._basicValidations.jobId; + } + + public get groupIds(): Validation { + return this._basicValidations.groupIds; + } + + public get modelMemoryLimit(): Validation { + return this._basicValidations.modelMemoryLimit; + } + + public set advancedValid(valid: boolean) { + this._validationSummary.advanced = valid; + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts new file mode 100644 index 0000000000000..784114364c94d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/job_validator/util.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { BasicValidations } from './job_validator'; +import { Job } from '../job_creator/configs'; +import { ALLOWED_DATA_UNITS } from '../../../../../common/constants/validation'; +import { newJobLimits } from '../../../new_job/utils/new_job_defaults'; +import { ValidationResults, ValidationMessage } from '../../../../../common/util/job_utils'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +export function populateValidationMessages( + validationResults: ValidationResults, + basicValidations: BasicValidations, + jobConfig: Job +) { + const limits = newJobLimits(); + + if (validationResults.contains('job_id_empty')) { + basicValidations.jobId.valid = false; + } else if (validationResults.contains('job_id_invalid')) { + basicValidations.jobId.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.jobNameAllowedCharactersDescription', + { + defaultMessage: + 'Job name can contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; ' + + 'must start and end with an alphanumeric character', + } + ); + basicValidations.jobId.message = msg; + } else if (validationResults.contains('job_id_already_exists')) { + basicValidations.jobId.valid = false; + const msg = i18n.translate('xpack.ml.newJob.wizard.validateJob.jobNameAlreadyExists', { + defaultMessage: + 'Job ID already exists. A job ID cannot be the same as an existing job or group.', + }); + basicValidations.jobId.message = msg; + } + + if (validationResults.contains('job_group_id_invalid')) { + basicValidations.groupIds.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.jobGroupAllowedCharactersDescription', + { + defaultMessage: + 'Job group names can contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; ' + + 'must start and end with an alphanumeric character', + } + ); + basicValidations.groupIds.message = msg; + } else if (validationResults.contains('job_group_id_already_exists')) { + basicValidations.groupIds.valid = false; + const msg = i18n.translate('xpack.ml.newJob.wizard.validateJob.groupNameAlreadyExists', { + defaultMessage: + 'Group ID already exists. A group ID cannot be the same as an existing job or group.', + }); + basicValidations.groupIds.message = msg; + } + + if (validationResults.contains('model_memory_limit_units_invalid')) { + basicValidations.modelMemoryLimit.valid = false; + const str = `${ALLOWED_DATA_UNITS.slice(0, ALLOWED_DATA_UNITS.length - 1).join(', ')} or ${[ + ...ALLOWED_DATA_UNITS, + ].pop()}`; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.modelMemoryLimitUnitsInvalidErrorMessage', + { + defaultMessage: 'Model memory limit data unit unrecognized. It must be {str}', + values: { str }, + } + ); + basicValidations.modelMemoryLimit.message = msg; + } + + if (validationResults.contains('model_memory_limit_invalid')) { + basicValidations.modelMemoryLimit.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.modelMemoryLimitRangeInvalidErrorMessage', + { + defaultMessage: + 'Model memory limit cannot be higher than the maximum value of {maxModelMemoryLimit}', + values: { maxModelMemoryLimit: limits.max_model_memory_limit.toUpperCase() }, + } + ); + basicValidations.modelMemoryLimit.message = msg; + } + + if (validationResults.contains('detectors_duplicates')) { + basicValidations.duplicateDetectors.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.duplicatedDetectorsErrorMessage', + { + defaultMessage: 'Duplicate detectors were found.', + } + ); + basicValidations.duplicateDetectors.message = msg; + } + + if (validationResults.contains('bucket_span_empty')) { + basicValidations.bucketSpan.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.bucketSpanMustBeSetErrorMessage', + { + defaultMessage: 'Bucket span must be set', + } + ); + + basicValidations.bucketSpan.message = msg; + } else if (validationResults.contains('bucket_span_invalid')) { + basicValidations.bucketSpan.valid = false; + const msg = i18n.translate( + 'xpack.ml.newJob.wizard.validateJob.bucketSpanInvalidTimeIntervalFormatErrorMessage', + { + defaultMessage: + '{bucketSpan} is not a valid time interval format e.g. {tenMinutes}, {oneHour}. It also needs to be higher than zero.', + values: { + bucketSpan: jobConfig.analysis_config.bucket_span, + tenMinutes: '10m', + oneHour: '1h', + }, + } + ); + + basicValidations.bucketSpan.message = msg; + } +} + +export function checkForExistingJobAndGroupIds( + jobId: string, + groupIds: string[], + existingJobsAndGroups: ExistingJobsAndGroups +): ValidationResults { + const messages: ValidationMessage[] = []; + + // check that job id does not already exist as a job or group or a newly created group + if ( + existingJobsAndGroups.jobIds.includes(jobId) || + existingJobsAndGroups.groupIds.includes(jobId) || + groupIds.includes(jobId) + ) { + messages.push({ id: 'job_id_already_exists' }); + } + + // check that groups that have been newly added in this job do not already exist as job ids + const newGroups = groupIds.filter(g => !existingJobsAndGroups.groupIds.includes(g)); + if (existingJobsAndGroups.jobIds.some(g => newGroups.includes(g))) { + messages.push({ id: 'job_group_id_already_exists' }); + } + + return { + messages, + valid: messages.length === 0, + contains: (id: string) => messages.some(m => id === m.id), + find: (id: string) => messages.find(m => id === m.id), + }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts new file mode 100644 index 0000000000000..ef0b05f73fa31 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ResultsLoader, Results, ModelItem, Anomaly } from './results_loader'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts new file mode 100644 index 0000000000000..ef237d3c46b39 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/results_loader.ts @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../job_creator'; +import { mlResultsService } from '../../../../services/results_service'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { getSeverityType } from '../../../../../common/util/anomaly_utils'; +import { ANOMALY_SEVERITY } from '../../../../../common/constants/anomalies'; +import { getScoresByRecord } from './searches'; +import { JOB_TYPE } from '../job_creator/util/constants'; +import { ChartLoader } from '../chart_loader'; + +export interface Results { + progress: number; + model: Record; + anomalies: Record; +} + +export interface ModelItem { + time: number; + actual: number; + modelUpper: number; + modelLower: number; +} + +export interface Anomaly { + time: number; + value: number; + severity: ANOMALY_SEVERITY; +} + +const emptyModelItem = { + time: 0, + actual: 0, + modelUpper: 0, + modelLower: 0, +}; + +interface SplitFieldWithValue { + name: string; + value: string; +} + +const LAST_UPDATE_DELAY_MS = 500; + +export type ResultsSubscriber = (results: Results) => void; + +type AnyJobCreator = SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + +export class ResultsLoader { + private _results$: BehaviorSubject; + private _resultsSearchRunning = false; + private _jobCreator: AnyJobCreator; + private _chartInterval: MlTimeBuckets; + private _lastModelTimeStamp: number = 0; + private _lastResultsTimeout: any = null; + private _chartLoader: ChartLoader; + + private _results: Results = { + progress: 0, + model: [], + anomalies: [], + }; + + private _detectorSplitFieldFilters: SplitFieldWithValue | null = null; + private _splitFieldFiltersLoaded: boolean = false; + + constructor(jobCreator: AnyJobCreator, chartInterval: MlTimeBuckets, chartLoader: ChartLoader) { + this._jobCreator = jobCreator; + this._chartInterval = chartInterval; + this._results$ = new BehaviorSubject(this._results); + this._chartLoader = chartLoader; + + jobCreator.subscribeToProgress(this.progressSubscriber); + } + + progressSubscriber = async (progress: number) => { + if ( + this._resultsSearchRunning === false && + (progress - this._results.progress > 5 || progress === 100) + ) { + if (this._splitFieldFiltersLoaded === false) { + this._splitFieldFiltersLoaded = true; + // load detector field filters if this is the first run. + await this._populateDetectorSplitFieldFilters(); + } + + this._updateData(progress, false); + + if (progress === 100) { + // after the job has finished, do one final update + // a while after the last 100% has been received. + // note, there may be multiple 100% progresses sent as they will only stop once the + // datafeed has stopped. + clearTimeout(this._lastResultsTimeout); + this._lastResultsTimeout = setTimeout(() => { + this._updateData(progress, true); + }, LAST_UPDATE_DELAY_MS); + } + } + }; + + private async _updateData(progress: number, fullRefresh: boolean) { + this._resultsSearchRunning = true; + + if (fullRefresh === true) { + this._clearResults(); + } + this._results.progress = progress; + + const getAnomalyData = + this._jobCreator.type === JOB_TYPE.SINGLE_METRIC + ? () => this._loadJobAnomalyData(0) + : () => this._loadDetectorsAnomalyData(); + + // TODO - load more that one model + const [model, anomalies] = await Promise.all([this._loadModelData(0), getAnomalyData()]); + this._results.model = model; + this._results.anomalies = anomalies; + + this._resultsSearchRunning = false; + this._results$.next(this._results); + } + + public subscribeToResults(func: ResultsSubscriber) { + return this._results$.subscribe(func); + } + + public get progress() { + return this._results.progress; + } + + private _clearResults() { + this._results.model = {}; + this._results.anomalies = {}; + this._results.progress = 0; + this._lastModelTimeStamp = 0; + } + + private async _loadModelData(dtrIndex: number): Promise> { + if (this._jobCreator.modelPlot === false) { + return []; + } + + const agg = this._jobCreator.getAggregation(dtrIndex); + if (agg === null) { + return { [dtrIndex]: [emptyModelItem] }; + } + const resp = await mlResultsService.getModelPlotOutput( + this._jobCreator.jobId, + dtrIndex, + [], + this._lastModelTimeStamp, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + agg.mlModelPlotAgg + ); + + return this._createModel(resp, dtrIndex); + } + + private _createModel(resp: any, dtrIndex: number): Record { + if (this._results.model[dtrIndex] === undefined) { + this._results.model[dtrIndex] = []; + } + + // create ModelItem list from search results + const model = Object.entries(resp.results).map( + ([time, modelItems]) => + ({ + time: +time, + ...modelItems, + } as ModelItem) + ); + + if (model.length > 10) { + // discard the last 5 buckets in the previously loaded model to avoid partial results + // set the _lastModelTimeStamp to be 5 buckets behind so we load the correct + // section of results next time. + this._lastModelTimeStamp = model[model.length - 5].time; + for (let i = 0; i < 5; i++) { + this._results.model[dtrIndex].pop(); + } + } + + // return a new array from the old and new model + return { [dtrIndex]: this._results.model[dtrIndex].concat(model) }; + } + + private async _loadJobAnomalyData(dtrIndex: number): Promise> { + const resp = await mlResultsService.getScoresByBucket( + [this._jobCreator.jobId], + this._jobCreator.start, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + 1 + ); + + const results = resp.results[this._jobCreator.jobId]; + if (results === undefined) { + return []; + } + + const anomalies: Record = {}; + anomalies[0] = Object.entries(results).map( + ([time, value]) => + ({ time: +time, value, severity: getSeverityType(value as number) } as Anomaly) + ); + return anomalies; + } + + private async _loadDetectorsAnomalyData(): Promise> { + const resp = await getScoresByRecord( + this._jobCreator.jobId, + this._jobCreator.start, + this._jobCreator.end, + `${this._chartInterval.getInterval().asMilliseconds()}ms`, + this._detectorSplitFieldFilters + ); + + const anomalies: Record = {}; + Object.entries(resp.results).forEach(([dtrIdx, results]) => { + anomalies[+dtrIdx] = results.map( + r => ({ ...r, severity: getSeverityType(r.value as number) } as Anomaly) + ); + }); + return anomalies; + } + + private async _populateDetectorSplitFieldFilters() { + if (isMultiMetricJobCreator(this._jobCreator) || isPopulationJobCreator(this._jobCreator)) { + if (this._jobCreator.splitField !== null) { + const fieldValues = await this._chartLoader.loadFieldExampleValues( + this._jobCreator.splitField + ); + if (fieldValues.length > 0) { + this._detectorSplitFieldFilters = { + name: this._jobCreator.splitField.name, + value: fieldValues[0], + }; + } + return; + } + } + this._detectorSplitFieldFilters = null; + } +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts new file mode 100644 index 0000000000000..bb47af0c4b27a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/common/results_loader/searches.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; + +import { ML_RESULTS_INDEX_PATTERN } from './../../../../../common/constants/index_patterns'; +import { escapeForElasticsearchQuery } from '../../../../util/string_utils'; +import { ml } from '../../../../services/ml_api_service'; + +interface SplitFieldWithValue { + name: string; + value: string; +} + +type TimeStamp = number; + +interface Result { + time: TimeStamp; + value: Value; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +// detector swimlane search +export function getScoresByRecord( + jobId: string, + earliestMs: number, + latestMs: number, + interval: string, + firstSplitField: SplitFieldWithValue | null +): Promise { + return new Promise((resolve, reject) => { + const obj: ProcessedResults = { + success: true, + results: {}, + totalResults: 0, + }; + + let jobIdFilterStr = 'job_id: ' + jobId; + if (firstSplitField && firstSplitField.value !== undefined) { + // Escape any reserved characters for the query_string query, + // wrapping the value in quotes to do a phrase match. + // Backslash is a special character in JSON strings, so doubly escape + // any backslash characters which exist in the field value. + jobIdFilterStr += ` AND ${escapeForElasticsearchQuery(firstSplitField.name)}:`; + jobIdFilterStr += `"${String(firstSplitField.value).replace(/\\/g, '\\\\')}"`; + } + + ml.esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + }, + }, + { + bool: { + must: [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + query_string: { + query: jobIdFilterStr, + }, + }, + ], + }, + }, + ], + }, + }, + aggs: { + detector_index: { + terms: { + field: 'detector_index', + order: { + recordScore: 'desc', + }, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }, + }, + }) + .then((resp: any) => { + const detectorsByIndex = get(resp, ['aggregations', 'detector_index', 'buckets'], []); + detectorsByIndex.forEach((dtr: any) => { + const dtrResults: Result[] = []; + const dtrIndex = +dtr.key; + + const buckets = get(dtr, ['byTime', 'buckets'], []); + for (let j = 0; j < buckets.length; j++) { + const bkt: any = buckets[j]; + const time = bkt.key; + dtrResults.push({ + time, + value: get(bkt, ['recordScore', 'value']), + }); + } + obj.results[dtrIndex] = dtrResults; + }); + + resolve(obj); + }) + .catch((resp: any) => { + reject(resp); + }); + }); +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts new file mode 100644 index 0000000000000..d3feaf087524c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './pages/new_job/route'; +import './pages/new_job/directive'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx new file mode 100644 index 0000000000000..1fef1d804e6f2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomalies.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC } from 'react'; +import { AnnotationDomainTypes, getAnnotationId, LineAnnotation } from '@elastic/charts'; +import { Anomaly } from '../../../../common/results_loader'; +import { getSeverityColor } from '../../../../../../../common/util/anomaly_utils'; +import { ANOMALY_THRESHOLD } from '../../../../../../../common/constants/anomalies'; + +interface Props { + anomalyData?: Anomaly[]; +} + +interface Severities { + critical: any[]; + major: any[]; + minor: any[]; + warning: any[]; + unknown: any[]; + low: any[]; +} + +function getAnomalyStyle(threshold: number) { + return { + line: { + stroke: getSeverityColor(threshold), + strokeWidth: 3, + opacity: 1, + }, + }; +} + +function splitAnomalySeverities(anomalies: Anomaly[]) { + const severities: Severities = { + critical: [], + major: [], + minor: [], + warning: [], + unknown: [], + low: [], + }; + anomalies.forEach(a => { + if (a.value !== 0) { + severities[a.severity].push({ dataValue: a.time }); + } + }); + return severities; +} + +export const Anomalies: FC = ({ anomalyData }) => { + const anomalies = anomalyData === undefined ? [] : anomalyData; + const severities: Severities = splitAnomalySeverities(anomalies); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx new file mode 100644 index 0000000000000..ae0cb502d56fd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/anomaly_chart.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { Chart, Settings, TooltipType } from '@elastic/charts'; +import { ModelItem, Anomaly } from '../../../../common/results_loader'; +import { Anomalies } from './anomalies'; +import { ModelBounds } from './model_bounds'; +import { Line } from './line'; +import { Scatter } from './scatter'; +import { Axes } from '../common/axes'; +import { getXRange } from '../common/utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; + +export enum CHART_TYPE { + LINE, + SCATTER, +} + +interface Props { + chartType: CHART_TYPE; + chartData: LineChartPoint[]; + modelData: ModelItem[]; + anomalyData: Anomaly[]; + height: string; + width: string; +} + +export const AnomalyChart: FC = ({ + chartType, + chartData, + modelData, + anomalyData, + height, + width, +}) => { + const data = chartType === CHART_TYPE.SCATTER ? flattenData(chartData) : chartData; + if (data.length === 0) { + return null; + } + + const xDomain = getXRange(data); + return ( +
+ + + + + + {chartType === CHART_TYPE.LINE && } + {chartType === CHART_TYPE.SCATTER && } + +
+ ); +}; + +function flattenData(data: any): LineChartPoint[] { + const chartData = data.reduce((p: any[], c: any) => { + p.push(...c.values.map((v: any) => ({ time: c.time, value: v.value }))); + return p; + }, []); + return chartData; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts new file mode 100644 index 0000000000000..46880334f3e9a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { AnomalyChart, CHART_TYPE } from './anomaly_chart'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx new file mode 100644 index 0000000000000..3da84da900a2d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/line.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { LineSeries, getSpecId, ScaleType, CurveType } from '@elastic/charts'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, LINE_COLOR } from '../common/settings'; + +interface Props { + chartData: any[]; +} + +const SPEC_ID = 'line'; + +const lineSeriesStyle = { + ...seriesStyle, +}; + +export const Line: FC = ({ chartData }) => { + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx new file mode 100644 index 0000000000000..0d76b50b80b97 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/model_bounds.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { getSpecId, ScaleType, AreaSeries, CurveType } from '@elastic/charts'; +import { ModelItem } from '../../../../common/results_loader'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, MODEL_COLOR } from '../common/settings'; + +interface Props { + modelData?: ModelItem[]; +} + +const SPEC_ID = 'model'; + +const areaSeriesStyle = { + ...seriesStyle, + area: { + ...seriesStyle.area, + visible: true, + }, + line: { + ...seriesStyle.line, + strokeWidth: 1, + opacity: 0.4, + }, +}; + +export const ModelBounds: FC = ({ modelData }) => { + const model = modelData === undefined ? [] : modelData; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx new file mode 100644 index 0000000000000..3a8fb9dbfb4d7 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/anomaly_chart/scatter.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { LineSeries, getSpecId, ScaleType, CurveType } from '@elastic/charts'; +import { getCustomColor } from '../common/utils'; +import { seriesStyle, LINE_COLOR } from '../common/settings'; + +interface Props { + chartData: any[]; +} + +const SPEC_ID = 'scatter'; + +const scatterSeriesStyle = { + ...seriesStyle, + line: { + ...seriesStyle.line, + visible: false, + }, + point: { + ...seriesStyle.point, + visible: true, + }, +}; + +export const Scatter: FC = ({ chartData }) => { + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx new file mode 100644 index 0000000000000..d068609760da8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/axes.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { Axis, getAxisId, Position, timeFormatter, niceTimeFormatByDay } from '@elastic/charts'; +import { getYRange } from './utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; + +const dateFormatter = timeFormatter(niceTimeFormatByDay(3)); + +interface Props { + chartData?: LineChartPoint[]; +} + +// round to 2dp +function tickFormatter(d: number): string { + return (Math.round(d * 100) / 100).toString(); +} + +export const Axes: FC = ({ chartData }) => { + const yDomain = chartData !== undefined ? getYRange(chartData) : undefined; + + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts new file mode 100644 index 0000000000000..6543466bfd254 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/settings.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import chrome from 'ui/chrome'; +import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; +import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; + +const IS_DARK_THEME = chrome.getUiSettingsClient().get('theme:darkMode'); +const themeName = IS_DARK_THEME ? darkTheme : lightTheme; + +export const LINE_COLOR = themeName.euiColorPrimary; +export const MODEL_COLOR = themeName.euiColorPrimary; +export const EVENT_RATE_COLOR = themeName.euiColorPrimary; + +export interface ChartSettings { + width: string; + height: string; + cols: 1 | 2 | 3; + intervalMs: number; +} + +export const defaultChartSettings: ChartSettings = { + width: '100%', + height: '300px', + cols: 1, + intervalMs: 0, +}; + +export const seriesStyle = { + line: { + stroke: '', + strokeWidth: 2, + visible: true, + opacity: 1, + }, + border: { + visible: false, + strokeWidth: 0, + stroke: '', + }, + point: { + visible: false, + radius: 2, + stroke: '', + strokeWidth: 4, + opacity: 0.5, + }, + area: { + fill: '', + opacity: 0.25, + visible: false, + }, +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts new file mode 100644 index 0000000000000..74d01b00f9254 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/common/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getSpecId, CustomSeriesColorsMap, DataSeriesColorsValues } from '@elastic/charts'; + +export function getCustomColor(specId: string, color: string): CustomSeriesColorsMap { + const lineDataSeriesColorValues: DataSeriesColorsValues = { + colorValues: [], + specId: getSpecId(specId), + }; + return new Map([[lineDataSeriesColorValues, color]]); +} + +export function getYRange(chartData: any) { + let max: number = Number.MIN_VALUE; + let min: number = Number.MAX_VALUE; + chartData.forEach((r: any) => { + max = Math.max(r.value, max); + min = Math.min(r.value, min); + }); + + const padding = (max - min) * 0.1; + max += padding; + min -= padding; + + return { + min, + max, + }; +} + +export function getXRange(lineChartData: any) { + return { + min: lineChartData[0].time, + max: lineChartData[lineChartData.length - 1].time, + }; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx new file mode 100644 index 0000000000000..011df2bc550a7 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/event_rate_chart.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { BarSeries, Chart, getSpecId, ScaleType, Settings, TooltipType } from '@elastic/charts'; +import { Axes } from '../common/axes'; +import { getCustomColor } from '../common/utils'; +import { LineChartPoint } from '../../../../common/chart_loader'; +import { EVENT_RATE_COLOR } from '../common/settings'; + +interface Props { + eventRateChartData: LineChartPoint[]; + height: string; + width: string; + showAxis?: boolean; +} + +const SPEC_ID = 'event_rate'; + +export const EventRateChart: FC = ({ eventRateChartData, height, width, showAxis }) => { + return ( +
+ + {showAxis === true && } + + + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts new file mode 100644 index 0000000000000..589e735f23519 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/charts/event_rate_chart/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EventRateChart } from './event_rate_chart'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts new file mode 100644 index 0000000000000..8e242adc6fee7 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_creator_context.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createContext } from 'react'; +import { Field, Aggregation } from '../../../../../common/types/fields'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + PopulationJobCreator, +} from '../../common/job_creator'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { JobValidator } from '../../common/job_validator'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +export interface JobCreatorContextValue { + jobCreatorUpdated: number; + jobCreatorUpdate: () => void; + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + jobValidator: JobValidator; + jobValidatorUpdated: number; + fields: Field[]; + aggs: Aggregation[]; + existingJobsAndGroups: ExistingJobsAndGroups; +} + +export const JobCreatorContext = createContext({ + jobCreatorUpdated: 0, + jobCreatorUpdate: () => {}, + jobCreator: {} as SingleMetricJobCreator, + chartLoader: {} as ChartLoader, + resultsLoader: {} as ResultsLoader, + chartInterval: {} as MlTimeBuckets, + jobValidator: {} as JobValidator, + jobValidatorUpdated: 0, + fields: [], + aggs: [], + existingJobsAndGroups: {} as ExistingJobsAndGroups, +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.tsx new file mode 100644 index 0000000000000..0aa87925eb61b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/additional_section.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { CalendarsSelection } from './components/calendars'; + +const ButtonContent = Additional settings; + +interface Props { + additionalExpanded: boolean; + setAdditionalExpanded: (a: boolean) => void; +} + +export const AdditionalSection: FC = ({ additionalExpanded, setAdditionalExpanded }) => { + return null; // disable this section until custom URLs component is ready + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx new file mode 100644 index 0000000000000..f749d78508a2e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/calendars_selection.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; +import { ml } from '../../../../../../../../../services/ml_api_service'; + +export const CalendarsSelection: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [selectedCalendars, setSelectedCalendars] = useState(jobCreator.calendars); + const [selectedOptions, setSelectedOptions] = useState([]); + const [options, setOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + async function loadCalendars() { + setIsLoading(true); + const calendars = await ml.calendars(); + setOptions(calendars.map(c => ({ label: c.calendar_id }))); + setSelectedOptions(selectedCalendars.map(c => ({ label: c }))); + setIsLoading(false); + } + + useEffect(() => { + loadCalendars(); + }, []); + + function onChange(optionsIn: EuiComboBoxOptionProps[]) { + setSelectedOptions(optionsIn); + setSelectedCalendars(optionsIn.map(o => o.label)); + } + + useEffect(() => { + jobCreator.calendars = selectedCalendars; + jobCreatorUpdate(); + }, [selectedCalendars.join()]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.tsx new file mode 100644 index 0000000000000..339a4c14530e8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/description.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Calendars'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/index.ts new file mode 100644 index 0000000000000..54fd45c6f40e4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/components/calendars/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CalendarsSelection } from './calendars_selection'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/index.ts new file mode 100644 index 0000000000000..ba62f0cdc6983 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/additional_section/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { AdditionalSection } from './additional_section'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.tsx new file mode 100644 index 0000000000000..2bc7a612e1f20 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/advanced_section.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { ModelPlotSwitch } from './components/model_plot'; +import { DedicatedIndexSwitch } from './components/dedicated_index'; +import { ModelMemoryLimitInput } from './components/model_memory_limit'; + +const ButtonContent = Advanced; + +interface Props { + advancedExpanded: boolean; + setAdvancedExpanded: (a: boolean) => void; +} + +export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpanded }) => { + return ( + + + + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx new file mode 100644 index 0000000000000..7be4c5563355d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/dedicated_index_switch.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const DedicatedIndexSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [useDedicatedIndex, setUseDedicatedIndex] = useState(jobCreator.useDedicatedIndex); + + useEffect(() => { + jobCreator.useDedicatedIndex = useDedicatedIndex; + jobCreatorUpdate(); + }, [useDedicatedIndex]); + + function toggleModelPlot() { + setUseDedicatedIndex(!useDedicatedIndex); + } + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx new file mode 100644 index 0000000000000..8807bfbb810d5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/description.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Use dedicated index'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/index.ts new file mode 100644 index 0000000000000..97135b6e67c42 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/dedicated_index/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DedicatedIndexSwitch } from './dedicated_index_switch'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx new file mode 100644 index 0000000000000..244f37cd33f83 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/description.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Model memory limit'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/index.ts new file mode 100644 index 0000000000000..97f708a0bf124 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ModelMemoryLimitInput } from './model_memory_limit_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx new file mode 100644 index 0000000000000..cb690cfcfbc53 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_memory_limit/model_memory_limit_input.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const ModelMemoryLimitInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [validation, setValidation] = useState(jobValidator.modelMemoryLimit); + const [modelMemoryLimit, setModelMemoryLimit] = useState( + jobCreator.modelMemoryLimit === null ? '' : jobCreator.modelMemoryLimit + ); + + useEffect(() => { + jobCreator.modelMemoryLimit = modelMemoryLimit === '' ? null : modelMemoryLimit; + jobCreatorUpdate(); + }, [modelMemoryLimit]); + + useEffect(() => { + setValidation(jobValidator.modelMemoryLimit); + }, [jobValidatorUpdated]); + + return ( + + setModelMemoryLimit(e.target.value)} + isInvalid={validation.valid === false} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.tsx new file mode 100644 index 0000000000000..17a0a0d7f1a62 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/description.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Enable model plot'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/index.ts new file mode 100644 index 0000000000000..f7526ff93df9a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ModelPlotSwitch } from './model_plot_switch'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx new file mode 100644 index 0000000000000..0ae8016b7dce9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const ModelPlotSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [modelPlotEnabled, setModelPlotEnabled] = useState(jobCreator.modelPlot); + + useEffect(() => { + jobCreator.modelPlot = modelPlotEnabled; + jobCreatorUpdate(); + }, [modelPlotEnabled]); + + function toggleModelPlot() { + setModelPlotEnabled(!modelPlotEnabled); + } + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/index.ts new file mode 100644 index 0000000000000..abec13ebdf70c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/advanced_section/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { AdvancedSection } from './advanced_section'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx new file mode 100644 index 0000000000000..027bbe25a8cf8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/description.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Groups'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx new file mode 100644 index 0000000000000..d1eb1a45cf09b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/groups_input.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { tabColor } from '../../../../../../../../common/util/group_color_utils'; +import { Description } from './description'; + +export const GroupsInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const { existingJobsAndGroups } = useContext(JobCreatorContext); + const [selectedGroups, setSelectedGroups] = useState(jobCreator.groups); + const [validation, setValidation] = useState(jobValidator.groupIds); + + useEffect(() => { + jobCreator.groups = selectedGroups; + jobCreatorUpdate(); + }, [selectedGroups.join()]); + + const options: EuiComboBoxOptionProps[] = existingJobsAndGroups.groupIds.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + const selectedOptions: EuiComboBoxOptionProps[] = selectedGroups.map((g: string) => ({ + label: g, + color: tabColor(g), + })); + + function onChange(optionsIn: EuiComboBoxOptionProps[]) { + setSelectedGroups(optionsIn.map(g => g.label)); + } + + function onCreateGroup(input: string, flattenedOptions: EuiComboBoxOptionProps[]) { + const normalizedSearchValue = input.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newGroup: EuiComboBoxOptionProps = { + label: input, + color: tabColor(input), + }; + + if ( + flattenedOptions.findIndex( + option => option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1 + ) { + options.push(newGroup); + } + + setSelectedGroups([...selectedOptions, newGroup].map(g => g.label)); + } + + useEffect(() => { + setValidation(jobValidator.groupIds); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/index.ts new file mode 100644 index 0000000000000..bdf4215883632 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/groups/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GroupsInput } from './groups_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.tsx new file mode 100644 index 0000000000000..768be30dcc35d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/description.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Job description'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/index.ts new file mode 100644 index 0000000000000..c8a2ed92fc63a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobDescriptionInput } from './job_description_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx new file mode 100644 index 0000000000000..f1e894f359082 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_description/job_description_input.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiTextArea } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Description } from './description'; + +export const JobDescriptionInput: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [jobDescription, setJobDescription] = useState(jobCreator.description); + + useEffect(() => { + jobCreator.description = jobDescription; + jobCreatorUpdate(); + }, [jobDescription]); + + return ( + + setJobDescription(e.target.value)} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx new file mode 100644 index 0000000000000..358f473074bae --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/description.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Job ID'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/index.ts new file mode 100644 index 0000000000000..fd4c1581ca340 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobIdInput } from './job_id_input'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.tsx new file mode 100644 index 0000000000000..14a8b29c0cca4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/components/job_id/job_id_input.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { EuiFieldText } from '@elastic/eui'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Description } from './description'; + +export const JobIdInput: FC = () => { + const { jobCreator, jobCreatorUpdate, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [jobId, setJobId] = useState(jobCreator.jobId); + const [validation, setValidation] = useState(jobValidator.jobId); + + useEffect(() => { + jobCreator.jobId = jobId; + jobCreatorUpdate(); + }, [jobId]); + + useEffect(() => { + const isEmptyId = jobId === ''; + setValidation({ + valid: isEmptyId === true || jobValidator.jobId.valid, + message: isEmptyId === false ? jobValidator.jobId.message : '', + }); + }, [jobValidatorUpdated]); + + return ( + + setJobId(e.target.value)} + isInvalid={validation.valid === false} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts new file mode 100644 index 0000000000000..766ccdc7cc0d1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { JobDetailsStep } from './job_details'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx new file mode 100644 index 0000000000000..244608f400f55 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/job_details_step/job_details.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { WizardNav } from '../wizard_nav'; +import { JobIdInput } from './components/job_id'; +import { JobDescriptionInput } from './components/job_description'; +import { GroupsInput } from './components/groups'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { AdvancedSection } from './components/advanced_section'; +import { AdditionalSection } from './components/additional_section'; + +interface Props extends StepProps { + advancedExpanded: boolean; + setAdvancedExpanded: (a: boolean) => void; + additionalExpanded: boolean; + setAdditionalExpanded: (a: boolean) => void; +} + +export const JobDetailsStep: FC = ({ + setCurrentStep, + isCurrentStep, + advancedExpanded, + setAdvancedExpanded, + additionalExpanded, + setAdditionalExpanded, +}) => { + const { jobValidator, jobValidatorUpdated } = useContext(JobCreatorContext); + const [nextActive, setNextActive] = useState(false); + + useEffect(() => { + const active = + jobValidator.jobId.valid && + jobValidator.modelMemoryLimit.valid && + jobValidator.groupIds.valid; + setNextActive(active); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + + + + + + + + + + + + + + + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} + next={() => setCurrentStep(WIZARD_STEPS.VALIDATION)} + nextActive={nextActive} + /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx new file mode 100644 index 0000000000000..938489feef7ee --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/agg_select.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useState, useEffect } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps, EuiFormRow } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { Field, Aggregation, AggFieldPair } from '../../../../../../../../common/types/fields'; + +// The display label used for an aggregation e.g. sum(bytes). +export type Label = string; + +// Label object structured for EUI's ComboBox. +export interface DropDownLabel { + label: Label; + agg: Aggregation; + field: Field; +} + +// Label object structure for EUI's ComboBox with support for nesting. +export interface DropDownOption { + label: Label; + options: DropDownLabel[]; +} + +export type DropDownProps = DropDownLabel[] | EuiComboBoxOptionProps[]; + +interface Props { + fields: Field[]; + changeHandler(d: EuiComboBoxOptionProps[]): void; + selectedOptions: EuiComboBoxOptionProps[]; + removeOptions: AggFieldPair[]; +} + +export const AggSelect: FC = ({ fields, changeHandler, selectedOptions, removeOptions }) => { + const { jobValidator, jobValidatorUpdated } = useContext(JobCreatorContext); + const [validation, setValidation] = useState(jobValidator.duplicateDetectors); + // create list of labels based on already selected detectors + // so they can be removed from the dropdown list + const removeLabels = removeOptions.map(createLabel); + + const options: EuiComboBoxOptionProps[] = fields.map(f => { + const aggOption: DropDownOption = { label: f.name, options: [] }; + if (typeof f.aggs !== 'undefined') { + aggOption.options = f.aggs + .map( + a => + ({ + label: `${a.title}(${f.name})`, + agg: a, + field: f, + } as DropDownLabel) + ) + .filter(o => removeLabels.includes(o.label) === false); + } + return aggOption; + }); + + useEffect(() => { + setValidation(jobValidator.duplicateDetectors); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; + +export function createLabel(pair: AggFieldPair | null): string { + return pair === null ? '' : `${pair.agg.title}(${pair.field.name})`; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts new file mode 100644 index 0000000000000..174bcc799a923 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/agg_select/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AggSelect, DropDownLabel, DropDownOption, DropDownProps, createLabel } from './agg_select'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx new file mode 100644 index 0000000000000..eace905b2c6de --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; + +import { BucketSpanInput } from './bucket_span_input'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Description } from './description'; + +export const BucketSpan: FC = () => { + const { + jobCreator, + jobCreatorUpdate, + jobCreatorUpdated, + jobValidator, + jobValidatorUpdated, + } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + const [validation, setValidation] = useState(jobValidator.bucketSpan); + + useEffect(() => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + }, [bucketSpan]); + + useEffect(() => { + setBucketSpan(jobCreator.bucketSpan); + }, [jobCreatorUpdated]); + + useEffect(() => { + setValidation(jobValidator.bucketSpan); + }, [jobValidatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx new file mode 100644 index 0000000000000..c277b3b84e54a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/bucket_span_input.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFieldText } from '@elastic/eui'; + +interface Props { + bucketSpan: string; + setBucketSpan: (bs: string) => void; + isInvalid: boolean; +} + +export const BucketSpanInput: FC = ({ bucketSpan, setBucketSpan, isInvalid }) => { + return ( + setBucketSpan(e.target.value)} + isInvalid={isInvalid} + /> + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx new file mode 100644 index 0000000000000..ce5ac51814255 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/description.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; +import { Validation } from '../../../../../common/job_validator'; + +interface Props { + children: JSX.Element; + validation: Validation; +} + +export const Description: FC = memo(({ children, validation }) => { + const title = 'Bucket span'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts new file mode 100644 index 0000000000000..14baca0da4599 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/bucket_span/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { BucketSpan } from './bucket_span'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx new file mode 100644 index 0000000000000..aca883b552aab --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/detector_title.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon } from '@elastic/eui'; + +import { Field, Aggregation, SplitField } from '../../../../../../../../common/types/fields'; + +interface DetectorTitleProps { + index: number; + agg: Aggregation; + field: Field; + splitField: SplitField; + deleteDetector?: (dtrIds: number) => void; +} + +export const DetectorTitle: FC = ({ + index, + agg, + field, + splitField, + deleteDetector, +}) => { + return ( + + + {getTitle(agg, field, splitField)} + + + + {deleteDetector !== undefined && ( + deleteDetector(index)} + iconType="cross" + size="s" + aria-label="Next" + /> + )} + + + ); +}; + +function getTitle(agg: Aggregation, field: Field, splitField: SplitField): string { + // let title = ${agg.title}(${field.name})`; + // if (splitField !== null) { + // title += ` split by ${splitField.name}`; + // } + // return title; + return `${agg.title}(${field.name})`; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts new file mode 100644 index 0000000000000..1eb9cac39ed93 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/detector_title/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DetectorTitle } from './detector_title'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.tsx new file mode 100644 index 0000000000000..a98237de38a06 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/description.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +interface Props { + children: JSX.Element; +} + +export const Description: FC = memo(({ children }) => { + const title = 'Influencers'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt + ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts new file mode 100644 index 0000000000000..10dd3978d79d3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { Influencers } from './influencers'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx new file mode 100644 index 0000000000000..01fc9888d31ed --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; + +import { InfluencersSelect } from './influencers_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; +import { Description } from './description'; + +export const Influencers: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return null; + } + + const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; + const { fields } = newJobCapsService; + const [influencers, setInfluencers] = useState([...jobCreator.influencers]); + const [splitField, setSplitField] = useState(jobCreator.splitField); + + useEffect(() => { + jobCreator.removeAllInfluencers(); + influencers.forEach(i => jobCreator.addInfluencer(i)); + jobCreatorUpdate(); + }, [influencers.join()]); + + useEffect(() => { + // if the split field has changed auto add it to the influencers + if (splitField !== null && influencers.includes(splitField.name) === false) { + setInfluencers([...influencers, splitField.name]); + } + }, [splitField]); + + useEffect(() => { + setSplitField(jobCreator.splitField); + setInfluencers([...jobCreator.influencers]); + }, [jobCreatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx new file mode 100644 index 0000000000000..c7495054bdb27 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/influencers/influencers_select.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; + +import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../../common/types/fields'; + +interface Props { + fields: Field[]; + changeHandler(i: string[]): void; + selectedInfluencers: string[]; +} + +export const InfluencersSelect: FC = ({ fields, changeHandler, selectedInfluencers }) => { + const options: EuiComboBoxOptionProps[] = fields + .filter(f => f.id !== EVENT_RATE_FIELD_ID) + .map(f => ({ + label: f.name, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selection: EuiComboBoxOptionProps[] = selectedInfluencers.map(i => ({ label: i })); + + function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + changeHandler(selectedOptions.map(o => o.label)); + } + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx new file mode 100644 index 0000000000000..691527513b804 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/chart_grid.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC } from 'react'; +import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; + +import { AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { ChartSettings } from '../../../charts/common/settings'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; +import { SplitCards, useAnimateSplit } from '../split_cards'; +import { DetectorTitle } from '../detector_title'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + fieldValues: string[]; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; + animate?: boolean; +} + +export const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + fieldValues, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, +}) => { + const animateSplit = useAnimateSplit(); + + return ( + + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + )} + + ))} + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts new file mode 100644 index 0000000000000..42af66e54af0d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MultiMetricView } from './multi_metric_view'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx new file mode 100644 index 0000000000000..da477193e35ab --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { MultiMetricJobCreator, isMultiMetricJobCreator } from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { DropDownLabel, DropDownProps } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { AggFieldPair } from '../../../../../../../../common/types/fields'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { ChartGrid } from './chart_grid'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +export const MultiMetricDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isMultiMetricJobCreator(jc) === false) { + return null; + } + const jobCreator = jc as MultiMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState( + jobCreator.aggFieldPairs + ); + const [lineChartsData, setLineChartsData] = useState({}); + const [modelData, setModelData] = useState>([]); + const [anomalyData, setAnomalyData] = useState>([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + + const [chartSettings, setChartSettings] = useState(defaultChartSettings); + const [splitField, setSplitField] = useState(jobCreator.splitField); + const [fieldValues, setFieldValues] = useState([]); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + addDetector(selectedOptionsIn); + } + + function addDetector(selectedOptionsIn: DropDownLabel[]) { + if (selectedOptionsIn !== null && selectedOptionsIn.length) { + const option = selectedOptionsIn[0] as DropDownLabel; + if (typeof option !== 'undefined') { + const newPair = { agg: option.agg, field: option.field }; + setAggFieldPairList([...aggFieldPairList, newPair]); + setSelectedOptions([{ label: '' }]); + } else { + setAggFieldPairList([]); + } + } + } + + function deleteDetector(index: number) { + aggFieldPairList.splice(index, 1); + setAggFieldPairList([...aggFieldPairList]); + } + + function setResultsWrapper(results: Results) { + setModelData(results.model); + setAnomalyData(results.anomalies); + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + // watch for changes in detector list length + useEffect(() => { + jobCreator.removeAllDetectors(); + aggFieldPairList.forEach(pair => { + jobCreator.addDetector(pair.agg, pair.field); + }); + jobCreator.calculateModelMemoryLimit(); + jobCreatorUpdate(); + loadCharts(); + setIsValid(aggFieldPairList.length > 0); + }, [aggFieldPairList.length]); + + // watch for change in jobCreator + useEffect(() => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadCharts(); + } + setSplitField(jobCreator.splitField); + }, [jobCreatorUpdated]); + + // watch for changes in split field. + // load example field values + // changes to fieldValues here will trigger the card effect + useEffect(() => { + if (splitField !== null) { + chartLoader + .loadFieldExampleValues(splitField) + .then(setFieldValues) + .catch(() => {}); + } else { + setFieldValues([]); + } + jobCreator.calculateModelMemoryLimit(); + }, [splitField]); + + // watch for changes in the split field values + // reload the charts + useEffect(() => { + loadCharts(); + }, [fieldValues]); + + function getChartSettings(): ChartSettings { + const cs = { + ...defaultChartSettings, + intervalMs: chartInterval.getInterval().asMilliseconds(), + }; + if (aggFieldPairList.length > 2) { + cs.cols = 3; + cs.height = '150px'; + cs.intervalMs = cs.intervalMs * 3; + } else if (aggFieldPairList.length > 1) { + cs.cols = 2; + cs.height = '200px'; + cs.intervalMs = cs.intervalMs * 2; + } + return cs; + } + + async function loadCharts() { + const cs = getChartSettings(); + setChartSettings(cs); + + if (aggFieldPairList.length > 0) { + const resp: LineChartData = await chartLoader.loadLineCharts( + jobCreator.start, + jobCreator.end, + aggFieldPairList, + jobCreator.splitField, + fieldValues.length > 0 ? fieldValues[0] : null, + cs.intervalMs + ); + + setLineChartsData(resp); + } + } + + return ( + + {lineChartsData && ( + + )} + {isActive && ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx new file mode 100644 index 0000000000000..e82f7aa9a9b30 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/metric_selector.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Field, AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AggSelect, DropDownLabel, DropDownProps } from '../agg_select'; + +interface Props { + fields: Field[]; + detectorChangeHandler: (options: DropDownLabel[]) => void; + selectedOptions: DropDownProps; + maxWidth?: number; + removeOptions: AggFieldPair[]; +} + +const MAX_WIDTH = 560; + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx new file mode 100644 index 0000000000000..3dc775f9d00d4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/multi_metric_view.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { MultiMetricDetectors } from './metric_selection'; +import { MultiMetricSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed?: (proceed: boolean) => void; +} + +export const MultiMetricView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx new file mode 100644 index 0000000000000..03cd9b2dee232 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/multi_metric_view/settings.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; +import { SplitFieldSelector } from '../split_field'; +import { Influencers } from '../influencers'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const MultiMetricSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect(() => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, [bucketSpan]); + + useEffect(() => { + setBucketSpan(jobCreator.bucketSpan); + }, [jobCreatorUpdated]); + + return ( + + {isActive && ( + + + + + + + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx new file mode 100644 index 0000000000000..fb801d72fef7e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/chart_grid.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC } from 'react'; +import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { AggFieldPair, SplitField } from '../../../../../../../../common/types/fields'; +import { ChartSettings } from '../../../charts/common/settings'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; +import { SplitCards, useAnimateSplit } from '../split_cards'; +import { DetectorTitle } from '../detector_title'; +import { ByFieldSelector } from '../split_field'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; + +type DetectorFieldValues = Record; + +interface ChartGridProps { + aggFieldPairList: AggFieldPair[]; + chartSettings: ChartSettings; + splitField: SplitField; + lineChartsData: LineChartData; + modelData: Record; + anomalyData: Record; + deleteDetector?: (index: number) => void; + jobType: JOB_TYPE; + fieldValuesPerDetector: DetectorFieldValues; +} + +export const ChartGrid: FC = ({ + aggFieldPairList, + chartSettings, + splitField, + lineChartsData, + modelData, + anomalyData, + deleteDetector, + jobType, + fieldValuesPerDetector, +}) => { + const animateSplit = useAnimateSplit(); + + return ( + + {aggFieldPairList.map((af, i) => ( + + {lineChartsData[i] !== undefined && ( + + + + + + + {deleteDetector !== undefined && } + + + + + + + )} + + ))} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts new file mode 100644 index 0000000000000..2397767fe4650 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PopulationView } from './population_view'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx new file mode 100644 index 0000000000000..d64b72eb251e4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -0,0 +1,264 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState, useReducer } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { PopulationJobCreator, isPopulationJobCreator } from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { DropDownLabel, DropDownProps } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { Field, AggFieldPair } from '../../../../../../../../common/types/fields'; +import { defaultChartSettings, ChartSettings } from '../../../charts/common/settings'; +import { MetricSelector } from './metric_selector'; +import { SplitFieldSelector } from '../split_field'; +import { MlTimeBuckets } from '../../../../../../../util/ml_time_buckets'; +import { ChartGrid } from './chart_grid'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +type DetectorFieldValues = Record; + +export const PopulationDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isPopulationJobCreator(jc) === false) { + return null; + } + const jobCreator = jc as PopulationJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([{ label: '' }]); + const [aggFieldPairList, setAggFieldPairList] = useState( + jobCreator.aggFieldPairs + ); + const [lineChartsData, setLineChartsData] = useState({}); + const [modelData, setModelData] = useState>([]); + const [anomalyData, setAnomalyData] = useState>([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + const [chartSettings, setChartSettings] = useState(defaultChartSettings); + const [splitField, setSplitField] = useState(jobCreator.splitField); + const [fieldValuesPerDetector, setFieldValuesPerDetector] = useState({}); + const [byFieldsUpdated, setByFieldsUpdated] = useReducer<(s: number) => number>(s => s + 1, 0); + const updateByFields = () => setByFieldsUpdated(0); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + addDetector(selectedOptionsIn); + } + + function addDetector(selectedOptionsIn: DropDownLabel[]) { + if (selectedOptionsIn !== null && selectedOptionsIn.length) { + const option = selectedOptionsIn[0] as DropDownLabel; + if (typeof option !== 'undefined') { + const newPair = { agg: option.agg, field: option.field, by: { field: null, value: null } }; + setAggFieldPairList([...aggFieldPairList, newPair]); + setSelectedOptions([{ label: '' }]); + } else { + setAggFieldPairList([]); + } + } + } + + function deleteDetector(index: number) { + aggFieldPairList.splice(index, 1); + setAggFieldPairList([...aggFieldPairList]); + updateByFields(); + } + + function setResultsWrapper(results: Results) { + setModelData(results.model); + setAnomalyData(results.anomalies); + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + // watch for changes in detector list length + useEffect(() => { + jobCreator.removeAllDetectors(); + aggFieldPairList.forEach((pair, i) => { + jobCreator.addDetector(pair.agg, pair.field); + if (pair.by !== undefined) { + // re-add by fields + jobCreator.setByField(pair.by.field, i); + } + }); + jobCreatorUpdate(); + loadCharts(); + setIsValid(aggFieldPairList.length > 0); + }, [aggFieldPairList.length]); + + // watch for changes in by field values + // redraw the charts if they change. + // triggered when example fields have been loaded + // if the split field or by fields have changed + useEffect(() => { + loadCharts(); + }, [JSON.stringify(fieldValuesPerDetector)]); + + // watch for change in jobCreator + useEffect(() => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadCharts(); + } + setSplitField(jobCreator.splitField); + + // update by fields and their by fields + let update = false; + const newList = [...aggFieldPairList]; + newList.forEach((pair, i) => { + const bf = jobCreator.getByField(i); + if (pair.by !== undefined && pair.by.field !== bf) { + pair.by.field = bf; + update = true; + } + }); + if (update) { + setAggFieldPairList(newList); + updateByFields(); + } + }, [jobCreatorUpdated]); + + // watch for changes in split field or by fields. + // load example field values + // changes to fieldValues here will trigger the card effect via setFieldValuesPerDetector + useEffect(() => { + loadFieldExamples(); + }, [splitField, byFieldsUpdated]); + + function getChartSettings(): ChartSettings { + const interval = new MlTimeBuckets(); + interval.setInterval('auto'); + interval.setBounds(chartInterval.getBounds()); + + const cs = { + ...defaultChartSettings, + intervalMs: interval.getInterval().asMilliseconds(), + }; + if (aggFieldPairList.length > 2) { + cs.cols = 3; + cs.height = '150px'; + cs.intervalMs = cs.intervalMs * 3; + } else if (aggFieldPairList.length > 1) { + cs.cols = 2; + cs.height = '200px'; + cs.intervalMs = cs.intervalMs * 2; + } + return cs; + } + + async function loadCharts() { + const cs = getChartSettings(); + setChartSettings(cs); + + if (aggFieldPairList.length > 0) { + const resp: LineChartData = await chartLoader.loadPopulationCharts( + jobCreator.start, + jobCreator.end, + aggFieldPairList, + jobCreator.splitField, + cs.intervalMs + ); + + setLineChartsData(resp); + } + } + + async function loadFieldExamples() { + const promises: any[] = []; + aggFieldPairList.forEach((af, i) => { + if (af.by !== undefined && af.by.field !== null) { + promises.push( + (async (index: number, field: Field) => { + return { + index, + fields: await chartLoader.loadFieldExampleValues(field), + }; + })(i, af.by.field) + ); + } + }); + const results = await Promise.all(promises); + const fieldValues = results.reduce((p, c) => { + p[c.index] = c.fields; + return p; + }, {}) as DetectorFieldValues; + + const newPairs = aggFieldPairList.map((pair, i) => ({ + ...pair, + ...(pair.by === undefined || pair.by.field === null + ? {} + : { + by: { + ...pair.by, + value: fieldValues[i][0], + }, + }), + })); + setAggFieldPairList([...newPairs]); + setFieldValuesPerDetector(fieldValues); + } + + return ( + + {isActive === true && ( + + + {splitField !== null && } + + )} + + {isActive === false && splitField === null && ( + + Population label TODO + {splitField !== null && } + + )} + + {lineChartsData && splitField !== null && ( + + )} + {isActive === true && splitField !== null && ( + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx new file mode 100644 index 0000000000000..e82f7aa9a9b30 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/metric_selector.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Field, AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AggSelect, DropDownLabel, DropDownProps } from '../agg_select'; + +interface Props { + fields: Field[]; + detectorChangeHandler: (options: DropDownLabel[]) => void; + selectedOptions: DropDownProps; + maxWidth?: number; + removeOptions: AggFieldPair[]; +} + +const MAX_WIDTH = 560; + +export const MetricSelector: FC = ({ + fields, + detectorChangeHandler, + selectedOptions, + maxWidth, + removeOptions, +}) => { + return ( + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx new file mode 100644 index 0000000000000..ab226f12fdf5d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/population_view.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { PopulationDetectors } from './metric_selection'; +import { PopulationSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed?: (proceed: boolean) => void; +} + +export const PopulationView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx new file mode 100644 index 0000000000000..ee010f89c94a2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/population_view/settings.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; +import { Influencers } from '../influencers'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const PopulationSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect(() => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, [bucketSpan]); + + useEffect(() => { + setBucketSpan(jobCreator.bucketSpan); + }, [jobCreatorUpdated]); + + return ( + + {isActive && ( + + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts new file mode 100644 index 0000000000000..3d45f053cd6e9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SingleMetricView } from './single_metric_view'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx new file mode 100644 index 0000000000000..4bd7602798bd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { + SingleMetricJobCreator, + isSingleMetricJobCreator, +} from '../../../../../common/job_creator'; +import { Results, ModelItem, Anomaly } from '../../../../../common/results_loader'; +import { LineChartData } from '../../../../../common/chart_loader'; +import { AggSelect, DropDownLabel, DropDownProps, createLabel } from '../agg_select'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { AggFieldPair } from '../../../../../../../../common/types/fields'; +import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; + +interface Props { + isActive: boolean; + setIsValid: (na: boolean) => void; +} + +const DTR_IDX = 0; + +export const SingleMetricDetectors: FC = ({ isActive, setIsValid }) => { + const { + jobCreator: jc, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + resultsLoader, + } = useContext(JobCreatorContext); + + if (isSingleMetricJobCreator(jc) === false) { + return null; + } + const jobCreator = jc as SingleMetricJobCreator; + + const { fields } = newJobCapsService; + const [selectedOptions, setSelectedOptions] = useState([ + { label: createLabel(jobCreator.aggFieldPair) }, + ]); + const [aggFieldPair, setAggFieldPair] = useState(jobCreator.aggFieldPair); + const [lineChartsData, setLineChartData] = useState([]); + const [modelData, setModelData] = useState([]); + const [anomalyData, setAnomalyData] = useState([]); + const [start, setStart] = useState(jobCreator.start); + const [end, setEnd] = useState(jobCreator.end); + + function detectorChangeHandler(selectedOptionsIn: DropDownLabel[]) { + setSelectedOptions(selectedOptionsIn); + if (selectedOptionsIn.length) { + const option = selectedOptionsIn[0]; + if (typeof option !== 'undefined') { + setAggFieldPair({ agg: option.agg, field: option.field }); + } else { + setAggFieldPair(null); + } + } + } + + function setResultsWrapper(results: Results) { + const model = results.model[DTR_IDX]; + if (model !== undefined) { + setModelData(model); + } + const anomalies = results.anomalies[DTR_IDX]; + if (anomalies !== undefined) { + setAnomalyData(anomalies); + } + } + + useEffect(() => { + // subscribe to progress and results + const subscription = resultsLoader.subscribeToResults(setResultsWrapper); + return () => { + subscription.unsubscribe(); + }; + }, []); + + useEffect(() => { + if (aggFieldPair !== null) { + jobCreator.setDetector(aggFieldPair.agg, aggFieldPair.field); + jobCreatorUpdate(); + loadChart(); + setIsValid(aggFieldPair !== null); + } + }, [aggFieldPair]); + + useEffect(() => { + if (jobCreator.start !== start || jobCreator.end !== end) { + setStart(jobCreator.start); + setEnd(jobCreator.end); + loadChart(); + } + }, [jobCreatorUpdated]); + + async function loadChart() { + if (aggFieldPair !== null) { + const resp: LineChartData = await chartLoader.loadLineCharts( + jobCreator.start, + jobCreator.end, + [aggFieldPair], + null, + null, + chartInterval.getInterval().asMilliseconds() + ); + if (resp[DTR_IDX] !== undefined) { + setLineChartData(resp); + } + } + } + + return ( + + {isActive && ( + + )} + {lineChartsData[DTR_IDX] !== undefined && ( + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx new file mode 100644 index 0000000000000..145d492018503 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/settings.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { BucketSpan } from '../bucket_span'; + +interface Props { + isActive: boolean; + setIsValid: (proceed: boolean) => void; +} + +export const SingleMetricSettings: FC = ({ isActive, setIsValid }) => { + const { jobCreator, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + const [bucketSpan, setBucketSpan] = useState(jobCreator.bucketSpan); + + useEffect(() => { + jobCreator.bucketSpan = bucketSpan; + jobCreatorUpdate(); + setIsValid(bucketSpan !== ''); + }, [bucketSpan]); + + useEffect(() => { + setBucketSpan(jobCreator.bucketSpan); + }, [jobCreatorUpdated]); + + return {isActive && }; +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx new file mode 100644 index 0000000000000..35274fe120a0b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/single_metric_view/single_metric_view.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useEffect, useState } from 'react'; +import { EuiHorizontalRule } from '@elastic/eui'; + +import { SingleMetricDetectors } from './metric_selection'; +import { SingleMetricSettings } from './settings'; + +interface Props { + isActive: boolean; + setCanProceed?: (proceed: boolean) => void; +} + +export const SingleMetricView: FC = ({ isActive, setCanProceed }) => { + const [metricsValid, setMetricValid] = useState(false); + const [settingsValid, setSettingsValid] = useState(false); + + useEffect(() => { + if (typeof setCanProceed === 'function') { + setCanProceed(metricsValid && settingsValid); + } + }, [metricsValid, settingsValid]); + + return ( + + + {metricsValid && isActive && ( + + + + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts new file mode 100644 index 0000000000000..cef01cdabce43 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/animate_split_hook.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; + +export const ANIMATION_SWITCH_DELAY_MS = 1000; + +// custom hook to enable the card split animation of the cards 1 second after the component has been rendered +// then switching to a step which contains the cards, the animation shouldn't play, instead +// the cards should be initially rendered in the split state. +// all subsequent changes to the split should be animated. + +export function useAnimateSplit() { + const [animateSplit, setAnimateSplit] = useState(false); + useEffect(() => { + setTimeout(() => { + setAnimateSplit(true); + }, ANIMATION_SWITCH_DELAY_MS); + }, []); + + return animateSplit; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts new file mode 100644 index 0000000000000..f243577d9ae96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SplitCards } from './split_cards'; +export { useAnimateSplit } from './animate_split_hook'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx new file mode 100644 index 0000000000000..62441c37fd9aa --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_cards/split_cards.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, memo, Fragment } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; + +import { SplitField } from '../../../../../../../../common/types/fields'; +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + fieldValues: string[]; + splitField: SplitField; + numberOfDetectors: number; + children: JSX.Element; + jobType: JOB_TYPE; + animate?: boolean; +} + +interface Panel { + panel: HTMLDivElement; + marginBottom: number; +} + +export const SplitCards: FC = memo( + ({ fieldValues, splitField, children, numberOfDetectors, jobType, animate = false }) => { + const panels: Panel[] = []; + + function storePanels(panel: HTMLDivElement | null, marginBottom: number) { + if (panel !== null) { + if (animate === false) { + panel.style.marginBottom = `${marginBottom}px`; + } + panels.push({ panel, marginBottom }); + } + } + + function getBackPanels() { + panels.length = 0; + + const fieldValuesCopy = [...fieldValues]; + fieldValuesCopy.shift(); + + let margin = 5; + const sideMargins = fieldValuesCopy.map((f, i) => (margin += 10 - i)).reverse(); + + if (animate === true) { + setTimeout(() => { + panels.forEach(p => (p.panel.style.marginBottom = `${p.marginBottom}px`)); + }, 100); + } + + const SPACING = 100; + const SPLIT_HEIGHT_MULTIPLIER = 1.6; + return fieldValuesCopy.map((fieldName, i) => { + const diff = (i + 1) * (SPLIT_HEIGHT_MULTIPLIER * (10 / fieldValuesCopy.length)); + const marginBottom = -SPACING + diff; + + const sideMargin = sideMargins[i]; + + const style = { + height: `${SPACING}px`, + marginBottom: `-${SPACING}px`, + marginLeft: `${sideMargin}px`, + marginRight: `${sideMargin}px`, + ...(animate ? { transition: 'margin 0.2s' } : {}), + }; + return ( +
storePanels(ref, marginBottom)} style={style}> + +
{fieldName}
+
+
+ ); + }); + } + + return ( + + + {(fieldValues.length === 0 || numberOfDetectors === 0) && {children}} + {fieldValues.length > 0 && numberOfDetectors > 0 && splitField !== null && ( + + {jobType === JOB_TYPE.MULTI_METRIC && ( + +
Data split by {splitField.name}
+ +
+ )} + + {getBackPanels()} + +
{fieldValues[0]}
+ + {children} +
+
+ )} +
+
+ ); + } +); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx new file mode 100644 index 0000000000000..6e16b946d8ac3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; + +import { SplitFieldSelect } from './split_field_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { Field } from '../../../../../../../../common/types/fields'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; + +interface Props { + detectorIndex: number; +} + +export const ByFieldSelector: FC = ({ detectorIndex }) => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return null; + } + const jobCreator = jc as PopulationJobCreator; + + const { categoryFields: allCategoryFields } = newJobCapsService; + + const [byField, setByField] = useState(jobCreator.getByField(detectorIndex)); + const categoryFields = useFilteredCategoryFields( + allCategoryFields, + jobCreator, + jobCreatorUpdated + ); + + useEffect(() => { + jobCreator.setByField(byField, detectorIndex); + jobCreatorUpdate(); + }, [byField]); + + useEffect(() => { + const bf = jobCreator.getByField(detectorIndex); + setByField(bf); + }, [jobCreatorUpdated]); + + return ( + + ); +}; + +// remove the split (over) field from the by field options +function useFilteredCategoryFields( + allCategoryFields: Field[], + jobCreator: MultiMetricJobCreator | PopulationJobCreator, + jobCreatorUpdated: number +) { + const [fields, setFields] = useState(allCategoryFields); + + useEffect(() => { + const sf = jobCreator.splitField; + if (sf !== null) { + setFields(allCategoryFields.filter(f => f.name !== sf.name)); + } else { + setFields(allCategoryFields); + } + }, [jobCreatorUpdated]); + + return fields; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx new file mode 100644 index 0000000000000..1227f15dd5a5a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/description.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, memo, FC } from 'react'; +import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +import { JOB_TYPE } from '../../../../../common/job_creator/util/constants'; + +interface Props { + children: JSX.Element; + jobType: JOB_TYPE; +} + +export const Description: FC = memo(({ children, jobType }) => { + if (jobType === JOB_TYPE.MULTI_METRIC) { + const title = 'Split field'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); + } else if (jobType === JOB_TYPE.POPULATION) { + const title = 'Population field'; + return ( + {title}} + description={ + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam. + + } + > + + {children} + + + ); + } else { + return null; + } +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts new file mode 100644 index 0000000000000..e5a1f2aebdaa0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { ByFieldSelector } from './by_field'; +export { SplitFieldSelector } from './split_field'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx new file mode 100644 index 0000000000000..de0546996ef96 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext, useEffect, useState } from 'react'; + +import { SplitFieldSelect } from './split_field_select'; +import { JobCreatorContext } from '../../../job_creator_context'; +import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { Description } from './description'; +import { + MultiMetricJobCreator, + isMultiMetricJobCreator, + PopulationJobCreator, + isPopulationJobCreator, +} from '../../../../../common/job_creator'; + +export const SplitFieldSelector: FC = () => { + const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); + if (isMultiMetricJobCreator(jc) === false && isPopulationJobCreator(jc) === false) { + return null; + } + const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; + const canClearSelection = isMultiMetricJobCreator(jc); + + const { categoryFields } = newJobCapsService; + const [splitField, setSplitField] = useState(jobCreator.splitField); + + useEffect(() => { + jobCreator.setSplitField(splitField); + jobCreatorUpdate(); + }, [splitField]); + + useEffect(() => { + setSplitField(jobCreator.splitField); + }, [jobCreatorUpdated]); + + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx new file mode 100644 index 0000000000000..a30212aeb37bf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/components/split_field/split_field_select.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; + +import { Field, SplitField } from '../../../../../../../../common/types/fields'; + +interface DropDownLabel { + label: string; + field: Field; +} + +interface Props { + fields: Field[]; + changeHandler(f: SplitField): void; + selectedField: SplitField; + isClearable: boolean; +} + +export const SplitFieldSelect: FC = ({ + fields, + changeHandler, + selectedField, + isClearable, +}) => { + const options: EuiComboBoxOptionProps[] = fields.map( + f => + ({ + label: f.name, + field: f, + } as DropDownLabel) + ); + + const selection: EuiComboBoxOptionProps[] = []; + if (selectedField !== null) { + selection.push({ label: selectedField.name, field: selectedField } as DropDownLabel); + } + + function onChange(selectedOptions: EuiComboBoxOptionProps[]) { + const option = selectedOptions[0] as DropDownLabel; + if (typeof option !== 'undefined') { + changeHandler(option.field); + } else { + changeHandler(null); + } + } + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts new file mode 100644 index 0000000000000..3cd2e15c10bd7 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PickFieldsStep } from './pick_fields'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx new file mode 100644 index 0000000000000..712d49159b542 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/pick_fields_step/pick_fields.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; + +import { JobCreatorContext } from '../job_creator_context'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JOB_TYPE } from '../../../common/job_creator/util/constants'; +import { SingleMetricView } from './components/single_metric_view'; +import { MultiMetricView } from './components/multi_metric_view'; +import { PopulationView } from './components/population_view'; + +export const PickFieldsStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobCreatorUpdated, jobValidator, jobValidatorUpdated } = useContext( + JobCreatorContext + ); + const [nextActive, setNextActive] = useState(false); + const [jobType, setJobType] = useState(jobCreator.type); + + useEffect(() => { + // this shouldn't really change, but just in case we need to... + setJobType(jobCreator.type); + }, [jobCreatorUpdated]); + + useEffect(() => { + const active = + jobCreator.detectors.length > 0 && + jobValidator.bucketSpan.valid && + jobValidator.duplicateDetectors.valid; + setNextActive(active); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + {jobType === JOB_TYPE.SINGLE_METRIC && ( + + )} + {jobType === JOB_TYPE.MULTI_METRIC && ( + + )} + {jobType === JOB_TYPE.POPULATION && ( + + )} + setCurrentStep(WIZARD_STEPS.TIME_RANGE)} + next={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} + nextActive={nextActive} + /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts new file mode 100644 index 0000000000000..9497f985efc3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/step_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum WIZARD_STEPS { + TIME_RANGE, + PICK_FIELDS, + JOB_DETAILS, + VALIDATION, + SUMMARY, +} + +export interface StepProps { + isCurrentStep: boolean; + setCurrentStep: React.Dispatch>; +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/index.ts new file mode 100644 index 0000000000000..648e1da5ba1f1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { JobProgress } from './job_progress'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/job_progress.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/job_progress.tsx new file mode 100644 index 0000000000000..d270f37ab48f1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/components/job_progress/job_progress.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiProgress } from '@elastic/eui'; + +interface Props { + progress: number; +} + +export const JobProgress: FC = ({ progress }) => { + if (progress > 0 && progress < 100) { + return ; + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx new file mode 100644 index 0000000000000..93517b1fc6645 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/detector_chart.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext } from 'react'; +import { JobCreatorContext } from '../job_creator_context'; +import { JOB_TYPE } from '../../../common/job_creator/util/constants'; +import { SingleMetricView } from '../pick_fields_step/components/single_metric_view'; +import { MultiMetricView } from '../pick_fields_step/components/multi_metric_view'; +import { PopulationView } from '../pick_fields_step/components/population_view'; + +export const DetectorChart: FC = () => { + const { jobCreator } = useContext(JobCreatorContext); + + return ( + + {jobCreator.type === JOB_TYPE.SINGLE_METRIC && } + {jobCreator.type === JOB_TYPE.MULTI_METRIC && } + {jobCreator.type === JOB_TYPE.POPULATION && } + + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts similarity index 85% rename from x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/index.ts rename to x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts index 8095e8424873a..f0f441d48afcb 100644 --- a/x-pack/legacy/plugins/infra/server/lib/domains/metadata_domain/index.ts +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './metadata_domain'; +export { SummaryStep } from './summary'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx new file mode 100644 index 0000000000000..149a5ffc4f161 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/job_details.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useContext } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui'; +import { JobCreatorContext } from '../job_creator_context'; +import { isMultiMetricJobCreator, isPopulationJobCreator } from '../../../common/job_creator'; + +export const JobDetails: FC = () => { + const { jobCreator } = useContext(JobCreatorContext); + + interface ListItems { + title: string; + description: string | JSX.Element; + } + + const jobDetails: ListItems[] = [ + { + title: 'Job ID', + description: jobCreator.jobId, + }, + { + title: 'Job description', + description: + jobCreator.description.length > 0 ? ( + jobCreator.description + ) : ( + No description provided + ), + }, + { + title: 'Groups', + description: + jobCreator.groups.length > 0 ? ( + jobCreator.groups.join(', ') + ) : ( + No groups selected + ), + }, + ]; + + const detectorDetails: ListItems[] = [ + { + title: 'Bucket span', + description: jobCreator.bucketSpan, + }, + ]; + + if (isMultiMetricJobCreator(jobCreator)) { + detectorDetails.push({ + title: 'Split field', + description: + isMultiMetricJobCreator(jobCreator) && jobCreator.splitField !== null ? ( + jobCreator.splitField.name + ) : ( + No split field selected + ), + }); + } + + if (isPopulationJobCreator(jobCreator)) { + detectorDetails.push({ + title: 'Population field', + description: + isPopulationJobCreator(jobCreator) && jobCreator.splitField !== null ? ( + jobCreator.splitField.name + ) : ( + + No population field selected + + ), + }); + } + + detectorDetails.push({ + title: 'Influencers', + description: + jobCreator.influencers.length > 0 ? ( + jobCreator.influencers.join(', ') + ) : ( + No split field selected + ), + }); + + const advancedDetails: ListItems[] = [ + { + title: 'Enable model plot', + description: jobCreator.modelPlot ? 'True' : 'False', + }, + { + title: 'Use dedicated index', + description: jobCreator.useDedicatedIndex ? 'True' : 'False', + }, + { + title: 'Model memory limit', + description: jobCreator.modelMemoryLimit !== null ? jobCreator.modelMemoryLimit : '', + }, + ]; + + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx new file mode 100644 index 0000000000000..e2bb85bfdb318 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/json_flyout.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { + EuiFlyout, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiTitle, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; +import { JobCreator } from '../../../common/job_creator'; +import { MLJobEditor } from '../../../../jobs_list/components/ml_job_editor'; + +interface Props { + jobCreator: JobCreator; + closeFlyout: () => void; +} +export const JsonFlyout: FC = ({ jobCreator, closeFlyout }) => { + return ( + + + + + + + + + + + + Close + + + + + + ); +}; + +const Contents: FC<{ title: string; value: string }> = ({ title, value }) => { + const EDITOR_HEIGHT = '800px'; + return ( + + +
{title}
+
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx new file mode 100644 index 0000000000000..a6f4361ee5475 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/summary_step/summary.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useState, useEffect } from 'react'; +import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { mlJobService } from '../../../../../services/job_service'; +import { JsonFlyout } from './json_flyout'; +import { isSingleMetricJobCreator } from '../../../common/job_creator'; +import { JobDetails } from './job_details'; +import { DetectorChart } from './detector_chart'; +import { JobProgress } from './components/job_progress'; + +export const SummaryStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobValidator, jobValidatorUpdated, resultsLoader } = useContext( + JobCreatorContext + ); + const [progress, setProgress] = useState(resultsLoader.progress); + const [showJsonFlyout, setShowJsonFlyout] = useState(false); + const [isValid, setIsValid] = useState(jobValidator.validationSummary.basic); + + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + async function start() { + setShowJsonFlyout(false); + try { + await jobCreator.createAndStartJob(); + } catch (error) { + // catch and display all job creation errors + toastNotifications.addDanger({ + title: i18n.translate('xpack.ml.newJob.wizard.createJobError', { + defaultMessage: `Job creation error`, + }), + text: error.message, + }); + } + } + + function viewResults() { + const url = mlJobService.createResultsUrl( + [jobCreator.jobId], + jobCreator.start, + jobCreator.end, + isSingleMetricJobCreator(jobCreator) === true ? 'timeseriesexplorer' : 'explorer' + ); + window.open(url, '_blank'); + } + + function toggleJsonFlyout() { + setShowJsonFlyout(!showJsonFlyout); + } + + useEffect(() => { + setIsValid(jobValidator.validationSummary.basic); + }, [jobValidatorUpdated]); + + return ( + + {isCurrentStep && ( + + + + + + + + {progress === 0 && setCurrentStep(WIZARD_STEPS.VALIDATION)} />} + + {progress < 100 && ( + + 0} disabled={isValid === false}> + Create job + +   + 0}> + Preview job JSON + + {showJsonFlyout && ( + setShowJsonFlyout(false)} jobCreator={jobCreator} /> + )} +   + + )} + {progress > 0 && ( + + View results + + )} + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts new file mode 100644 index 0000000000000..05ce3a68a1b6c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimeRangeStep } from './time_range'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx new file mode 100644 index 0000000000000..fb21f0c1ae109 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +import { timefilter } from 'ui/timefilter'; +import moment from 'moment'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { useKibanaContext } from '../../../../../contexts/kibana'; +import { FullTimeRangeSelector } from '../../../../../components/full_time_range_selector'; +import { EventRateChart } from '../charts/event_rate_chart'; +import { LineChartPoint } from '../../../common/chart_loader'; +import { TimeRangePicker } from './time_range_picker'; +import { GetTimeFieldRangeResponse } from '../../../../../services/ml_api_service'; +import { mlJobService } from '../../../../../services/job_service'; +import { ml } from '../../../../../services/ml_api_service'; + +export interface TimeRange { + start: number; + end: number; +} +export const TimeRangeStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const kibanaContext = useKibanaContext(); + + const { + jobCreator, + jobCreatorUpdate, + jobCreatorUpdated, + chartLoader, + chartInterval, + } = useContext(JobCreatorContext); + + const [timeRange, setTimeRange] = useState({ + start: jobCreator.start, + end: jobCreator.end, + }); + const [eventRateChartData, setEventRateChartData] = useState([]); + + async function loadChart() { + const resp = await chartLoader.loadEventRateChart( + jobCreator.start, + jobCreator.end, + chartInterval.getInterval().asMilliseconds() + ); + setEventRateChartData(resp); + } + + useEffect(() => { + const { start, end } = timeRange; + jobCreator.setTimeRange(start, end); + chartInterval.setBounds({ + min: moment(start), + max: moment(end), + }); + // update the timefilter, to keep the URL in sync + timefilter.setTime({ + from: moment(start).toISOString(), + to: moment(end).toISOString(), + }); + + jobCreatorUpdate(); + loadChart(); + }, [JSON.stringify(timeRange)]); + + useEffect(() => { + setTimeRange({ + start: jobCreator.start, + end: jobCreator.end, + }); + }, [jobCreatorUpdated]); + + function fullTimeRangeCallback(range: GetTimeFieldRangeResponse) { + setTimeRange({ + start: range.start.epoch, + end: range.end.epoch, + }); + } + + useEffect(() => { + if (mlJobService.currentJob !== undefined) { + (async (index: string, timeFieldName: string | undefined, query: object) => { + const resp = await ml.getTimeFieldRange({ + index, + timeFieldName, + query, + }); + setTimeRange({ + start: resp.start.epoch, + end: resp.end.epoch, + }); + // wipe the cloning job + mlJobService.currentJob = undefined; + })( + kibanaContext.currentIndexPattern.title, + kibanaContext.currentIndexPattern.timeFieldName, + kibanaContext.combinedQuery + ); + } + }, []); + + return ( + + {isCurrentStep && ( + + + + + + + + + + + + + + setCurrentStep(WIZARD_STEPS.PICK_FIELDS)} nextActive={true} /> + + )} + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx new file mode 100644 index 0000000000000..3cac139b27b88 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/time_range_step/time_range_picker.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import React, { Fragment, FC, useState, useEffect } from 'react'; +import { EuiDatePickerRange, EuiDatePicker } from '@elastic/eui'; + +import { useKibanaContext } from '../../../../../contexts/kibana'; +import { TimeRange } from './time_range'; + +const WIDTH = '512px'; + +interface Props { + setTimeRange: (d: TimeRange) => void; + timeRange: TimeRange; +} + +type Moment = moment.Moment; + +export const TimeRangePicker: FC = ({ setTimeRange, timeRange }) => { + const kibanaContext = useKibanaContext(); + const dateFormat: string = kibanaContext.kibanaConfig.get('dateFormat'); + + const [startMoment, setStartMoment] = useState(moment(timeRange.start)); + const [endMoment, setEndMoment] = useState(moment(timeRange.end)); + + function handleChangeStart(date: Moment | null) { + setStartMoment(date || undefined); + } + + function handleChangeEnd(date: Moment | null) { + setEndMoment(date || undefined); + } + + // update the parent start and end if the timepicker changes + useEffect(() => { + if (startMoment !== undefined && endMoment !== undefined) { + setTimeRange({ + start: startMoment.valueOf(), + end: endMoment.valueOf(), + }); + } + }, [startMoment, endMoment]); + + // update our local start and end moment objects if + // the parent start and end updates. + // this happens if the use full data button is pressed. + useEffect(() => { + setStartMoment(moment(timeRange.start)); + setEndMoment(moment(timeRange.end)); + }, [JSON.stringify(timeRange)]); + + return ( + +
+ + } + endDateControl={ + + } + /> +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/index.ts new file mode 100644 index 0000000000000..c789e9a2fdc5a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ValidationStep } from './validation'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.tsx new file mode 100644 index 0000000000000..994f909140f3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/validation_step/validation.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useContext, useState } from 'react'; +import { WizardNav } from '../wizard_nav'; +import { WIZARD_STEPS, StepProps } from '../step_types'; +import { JobCreatorContext } from '../job_creator_context'; +import { mlJobService } from '../../../../../services/job_service'; +import { ValidateJob } from '../../../../../components/validate_job/validate_job_view'; + +const idFilterList = [ + 'job_id_valid', + 'job_group_id_valid', + 'detectors_function_not_empty', + 'success_bucket_span', +]; + +export const ValidationStep: FC = ({ setCurrentStep, isCurrentStep }) => { + const { jobCreator, jobValidator } = useContext(JobCreatorContext); + const [nextActive, setNextActive] = useState(false); + + function getJobConfig() { + return { + ...jobCreator.jobConfig, + datafeed_config: jobCreator.datafeedConfig, + }; + } + + function getDuration() { + return { + start: jobCreator.start, + end: jobCreator.end, + }; + } + + // keep a record of the advanced validation in the jobValidator + // and disable the next button if any advanced checks have failed. + // note, it is not currently possible to get to a state where any of the + // advanced validation checks return an error because they are all + // caught in previous basic checks + function setIsValid(valid: boolean) { + jobValidator.advancedValid = valid; + setNextActive(valid); + } + + return ( + + {isCurrentStep && ( + + + setCurrentStep(WIZARD_STEPS.JOB_DETAILS)} + next={() => setCurrentStep(WIZARD_STEPS.SUMMARY)} + nextActive={nextActive} + /> + + )} + {isCurrentStep === false && } + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/index.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/index.ts new file mode 100644 index 0000000000000..5d9db25730fce --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { WizardNav } from './wizard_nav'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx new file mode 100644 index 0000000000000..d1629e03f36d9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/components/wizard_nav/wizard_nav.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface StepsNavProps { + previousActive?: boolean; + nextActive?: boolean; + previous?(): void; + next?(): void; +} + +export const WizardNav: FC = ({ + previous, + previousActive = true, + next, + nextActive = true, +}) => ( + + + {previous && ( + + + {i18n.translate('xpack.ml.newJob.wizard.previousStepButton', { + defaultMessage: 'Previous', + })} + + + )} + {next && ( + + + {i18n.translate('xpack.ml.newJob.wizard.nextStepButton', { + defaultMessage: 'Next', + })} + + + )} + +); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx new file mode 100644 index 0000000000000..6513d9c69da1b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/directive.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +// @ts-ignore +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml', ['react']); +import { timefilter } from 'ui/timefilter'; +import { IndexPatterns } from 'ui/index_patterns'; + +import { I18nContext } from 'ui/i18n'; +import { IPrivate } from 'ui/private'; +import { InjectorService } from '../../../../../common/types/angular'; + +import { SearchItemsProvider } from '../../../new_job/utils/new_job_utils'; +import { Page, PageProps } from './page'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; + +import { KibanaContext, KibanaConfigTypeFix } from '../../../../contexts/kibana'; + +module.directive('mlNewJobPage', ($injector: InjectorService) => { + return { + scope: {}, + restrict: 'E', + link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => { + timefilter.disableTimeRangeSelector(); + timefilter.disableAutoRefreshSelector(); + + const indexPatterns = $injector.get('indexPatterns'); + const kbnBaseUrl = $injector.get('kbnBaseUrl'); + const kibanaConfig = $injector.get('config'); + const Private = $injector.get('Private'); + const $route = $injector.get('$route'); + const existingJobsAndGroups = $route.current.locals.existingJobsAndGroups; + + if ($route.current.locals.jobType === undefined) { + return; + } + const jobType: JOB_TYPE = $route.current.locals.jobType; + + const createSearchItems = Private(SearchItemsProvider); + const { indexPattern, savedSearch, combinedQuery } = createSearchItems(); + + const kibanaContext = { + combinedQuery, + currentIndexPattern: indexPattern, + currentSavedSearch: savedSearch, + indexPatterns, + kbnBaseUrl, + kibanaConfig, + }; + + const props: PageProps = { + existingJobsAndGroups, + jobType, + }; + + ReactDOM.render( + + + {React.createElement(Page, props)} + + , + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx new file mode 100644 index 0000000000000..7397cba6953bd --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/page.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, Fragment } from 'react'; + +import { EuiPage, EuiPageBody, EuiPageContentBody } from '@elastic/eui'; +import { Wizard } from './wizard'; +import { + jobCreatorFactory, + isSingleMetricJobCreator, + isPopulationJobCreator, +} from '../../common/job_creator'; +import { + JOB_TYPE, + DEFAULT_MODEL_MEMORY_LIMIT, + DEFAULT_BUCKET_SPAN, +} from '../../common/job_creator/util/constants'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { JobValidator } from '../../common/job_validator'; +import { useKibanaContext } from '../../../../contexts/kibana'; +import { getTimeFilterRange } from '../../../../components/full_time_range_selector'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; +import { newJobDefaults } from '../../../new_job/utils/new_job_defaults'; +import { ExistingJobsAndGroups, mlJobService } from '../../../../services/job_service'; +import { expandCombinedJobConfig } from '../../common/job_creator/configs'; + +const PAGE_WIDTH = 1200; // document.querySelector('.single-metric-job-container').width(); +const BAR_TARGET = PAGE_WIDTH > 2000 ? 1000 : PAGE_WIDTH / 2; +const MAX_BARS = BAR_TARGET + (BAR_TARGET / 100) * 100; // 100% larger than bar target + +export interface PageProps { + existingJobsAndGroups: ExistingJobsAndGroups; + jobType: JOB_TYPE; +} + +export const Page: FC = ({ existingJobsAndGroups, jobType }) => { + const kibanaContext = useKibanaContext(); + + const jobDefaults = newJobDefaults(); + + const jobCreator = jobCreatorFactory(jobType)( + kibanaContext.currentIndexPattern, + kibanaContext.currentSavedSearch, + kibanaContext.combinedQuery + ); + + const { from, to } = getTimeFilterRange(); + jobCreator.setTimeRange(from, to); + + if (mlJobService.currentJob !== undefined) { + const clonedJob = mlJobService.cloneJob(mlJobService.currentJob); + const { job, datafeed } = expandCombinedJobConfig(clonedJob); + jobCreator.cloneFromExistingJob(job, datafeed); + } else { + jobCreator.bucketSpan = DEFAULT_BUCKET_SPAN; + + if (isPopulationJobCreator(jobCreator) === true) { + // for population jobs use the default mml (1GB) + jobCreator.modelMemoryLimit = jobDefaults.anomaly_detectors.model_memory_limit; + } else { + // for all other jobs, use 10MB + jobCreator.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; + } + + if (isSingleMetricJobCreator(jobCreator) === true) { + jobCreator.modelPlot = true; + } + } + + const chartInterval = new MlTimeBuckets(); + chartInterval.setBarTarget(BAR_TARGET); + chartInterval.setMaxBars(MAX_BARS); + chartInterval.setInterval('auto'); + + const chartLoader = new ChartLoader( + kibanaContext.currentIndexPattern, + kibanaContext.currentSavedSearch, + kibanaContext.combinedQuery + ); + + const jobValidator = new JobValidator(jobCreator, existingJobsAndGroups); + + const resultsLoader = new ResultsLoader(jobCreator, chartInterval, chartLoader); + + useEffect(() => { + return () => { + jobCreator.forceStopRefreshPolls(); + mlJobService.currentJob = undefined; + }; + }); + + return ( + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts new file mode 100644 index 0000000000000..e494e14f65d84 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/route.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uiRoutes from 'ui/routes'; + +// @ts-ignore +import { checkFullLicense } from '../../../../license/check_license'; +import { checkGetJobsPrivilege } from '../../../../privilege/check_privilege'; +// @ts-ignore +import { loadCurrentIndexPattern, loadCurrentSavedSearch } from '../../../../util/index_utils'; + +import { + getCreateSingleMetricJobBreadcrumbs, + getCreateMultiMetricJobBreadcrumbs, + getCreatePopulationJobBreadcrumbs, + // @ts-ignore +} from '../../../breadcrumbs'; + +import { Route } from '../../../../../common/types/kibana'; + +import { loadNewJobCapabilities } from '../../../../services/new_job_capabilities_service'; + +import { loadNewJobDefaults } from '../../../new_job/utils/new_job_defaults'; + +import { mlJobService } from '../../../../services/job_service'; +import { JOB_TYPE } from '../../common/job_creator/util/constants'; + +const template = ``; + +const routes: Route[] = [ + { + id: JOB_TYPE.SINGLE_METRIC, + k7Breadcrumbs: getCreateSingleMetricJobBreadcrumbs, + }, + { + id: JOB_TYPE.MULTI_METRIC, + k7Breadcrumbs: getCreateMultiMetricJobBreadcrumbs, + }, + { + id: JOB_TYPE.POPULATION, + k7Breadcrumbs: getCreatePopulationJobBreadcrumbs, + }, +]; + +routes.forEach((route: Route) => { + uiRoutes.when(`/jobs/new_job/new_new_job/${route.id}`, { + template, + k7Breadcrumbs: route.k7Breadcrumbs, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + indexPattern: loadCurrentIndexPattern, + savedSearch: loadCurrentSavedSearch, + loadNewJobCapabilities, + loadNewJobDefaults, + existingJobsAndGroups: mlJobService.getJobAndGroupIds, + jobType: () => route.id, + }, + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx new file mode 100644 index 0000000000000..ccb0d27b5dbcf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/jobs/new_job_new/pages/new_job/wizard.tsx @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useReducer, useState, useEffect } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiStepsHorizontal, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { WIZARD_STEPS } from '../components/step_types'; + +import { TimeRangeStep } from '../components/time_range_step'; + +import { PickFieldsStep } from '../components/pick_fields_step'; +import { JobDetailsStep } from '../components/job_details_step'; +import { ValidationStep } from '../components/validation_step'; +import { SummaryStep } from '../components/summary_step'; +import { MlTimeBuckets } from '../../../../util/ml_time_buckets'; + +import { JobCreatorContext, JobCreatorContextValue } from '../components/job_creator_context'; +import { ExistingJobsAndGroups } from '../../../../services/job_service'; + +import { + SingleMetricJobCreator, + MultiMetricJobCreator, + PopulationJobCreator, +} from '../../common/job_creator'; +import { ChartLoader } from '../../common/chart_loader'; +import { ResultsLoader } from '../../common/results_loader'; +import { JobValidator } from '../../common/job_validator'; +import { newJobCapsService } from '../../../../services/new_job_capabilities_service'; + +interface Props { + jobCreator: SingleMetricJobCreator | MultiMetricJobCreator | PopulationJobCreator; + chartLoader: ChartLoader; + resultsLoader: ResultsLoader; + chartInterval: MlTimeBuckets; + jobValidator: JobValidator; + existingJobsAndGroups: ExistingJobsAndGroups; +} + +export const Wizard: FC = ({ + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + jobValidator, + existingJobsAndGroups, +}) => { + const [jobCreatorUpdated, setJobCreatorUpdate] = useReducer<(s: number) => number>(s => s + 1, 0); + const jobCreatorUpdate = () => setJobCreatorUpdate(jobCreatorUpdated); + + const [jobValidatorUpdated, setJobValidatorUpdate] = useReducer<(s: number) => number>( + s => s + 1, + 0 + ); + + const jobCreatorContext: JobCreatorContextValue = { + jobCreatorUpdated, + jobCreatorUpdate, + jobCreator, + chartLoader, + resultsLoader, + chartInterval, + jobValidator, + jobValidatorUpdated, + fields: newJobCapsService.fields, + aggs: newJobCapsService.aggs, + existingJobsAndGroups, + }; + + // store whether the advanced and additional sections have been expanded. + // has to be stored at this level to ensure it's remembered on wizard step change + const [advancedExpanded, setAdvancedExpanded] = useState(false); + const [additionalExpanded, setAdditionalExpanded] = useState(false); + + const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.TIME_RANGE); + const [highestStep, setHighestStep] = useState(WIZARD_STEPS.TIME_RANGE); + const [disableSteps, setDisableSteps] = useState(false); + const [progress, setProgress] = useState(resultsLoader.progress); + const [stringifiedConfigs, setStringifiedConfigs] = useState( + stringifyConfigs(jobCreator.jobConfig, jobCreator.datafeedConfig) + ); + + useEffect(() => { + // IIFE to run the validation. the useEffect callback can't be async + (async () => { + await jobValidator.validate(); + setJobValidatorUpdate(jobValidatorUpdated); + })(); + + // if the job config has changed, reset the highestStep + // compare a stringified config to ensure the configs have actually changed + const tempConfigs = stringifyConfigs(jobCreator.jobConfig, jobCreator.datafeedConfig); + if (tempConfigs !== stringifiedConfigs) { + setHighestStep(currentStep); + setStringifiedConfigs(tempConfigs); + } + }, [jobCreatorUpdated]); + + useEffect(() => { + jobCreator.subscribeToProgress(setProgress); + }, []); + + // disable the step links if the job is running + useEffect(() => { + setDisableSteps(progress > 0); + }, [progress]); + + // keep a record of the highest step reached in the wizard + useEffect(() => { + if (currentStep >= highestStep) { + setHighestStep(currentStep); + } + }, [currentStep]); + + function jumpToStep(step: WIZARD_STEPS) { + if (step <= highestStep) { + setCurrentStep(step); + } + } + + const stepsConfig = [ + { + title: i18n.translate('xpack.ml.newJob.wizard.step.timeRangeTitle', { + defaultMessage: 'Time range', + }), + onClick: () => jumpToStep(WIZARD_STEPS.TIME_RANGE), + isSelected: currentStep === WIZARD_STEPS.TIME_RANGE, + isComplete: currentStep > WIZARD_STEPS.TIME_RANGE, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.pickFieldsTitle', { + defaultMessage: 'Pick fields', + }), + onClick: () => jumpToStep(WIZARD_STEPS.PICK_FIELDS), + isSelected: currentStep === WIZARD_STEPS.PICK_FIELDS, + isComplete: currentStep > WIZARD_STEPS.PICK_FIELDS, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.jobDetailsTitle', { + defaultMessage: 'Job details', + }), + onClick: () => jumpToStep(WIZARD_STEPS.JOB_DETAILS), + isSelected: currentStep === WIZARD_STEPS.JOB_DETAILS, + isComplete: currentStep > WIZARD_STEPS.JOB_DETAILS, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.validationTitle', { + defaultMessage: 'Validation', + }), + onClick: () => jumpToStep(WIZARD_STEPS.VALIDATION), + isSelected: currentStep === WIZARD_STEPS.VALIDATION, + isComplete: currentStep > WIZARD_STEPS.VALIDATION, + disabled: disableSteps, + }, + { + title: i18n.translate('xpack.ml.newJob.wizard.step.summaryTitle', { + defaultMessage: 'Summary', + }), + onClick: () => jumpToStep(WIZARD_STEPS.SUMMARY), + isSelected: currentStep === WIZARD_STEPS.SUMMARY, + isComplete: currentStep > WIZARD_STEPS.SUMMARY, + disabled: disableSteps, + }, + ]; + + return ( + + + + {currentStep === WIZARD_STEPS.TIME_RANGE && ( + + Time range + + + )} + {currentStep === WIZARD_STEPS.PICK_FIELDS && ( + + Pick fields + + + )} + {currentStep === WIZARD_STEPS.JOB_DETAILS && ( + + Job details + + + )} + {currentStep === WIZARD_STEPS.VALIDATION && ( + + Validation + + + )} + {currentStep === WIZARD_STEPS.SUMMARY && ( + + Summary + + + )} + + ); +}; + +const Title: FC = ({ children }) => { + return ( + + +

{children}

+
+ +
+ ); +}; + +function stringifyConfigs(jobConfig: object, datafeedConfig: object) { + return JSON.stringify(jobConfig) + JSON.stringify(datafeedConfig); +} diff --git a/x-pack/legacy/plugins/ml/public/privilege/check_privilege.ts b/x-pack/legacy/plugins/ml/public/privilege/check_privilege.ts index 73cc10ab551d1..cda6c82885d74 100644 --- a/x-pack/legacy/plugins/ml/public/privilege/check_privilege.ts +++ b/x-pack/legacy/plugins/ml/public/privilege/check_privilege.ts @@ -16,11 +16,14 @@ let privileges: Privileges = getDefaultPrivileges(); export function checkGetJobsPrivilege(kbnUrl: any): Promise { return new Promise((resolve, reject) => { - getPrivileges().then(priv => { - privileges = priv; + getPrivileges().then(({ capabilities, isPlatinumOrTrialLicense }) => { + privileges = capabilities; // the minimum privilege for using ML with a platinum or trial license is being able to get the transforms list. - // all other functionality is controlled by the return privileges object - if (privileges.canGetJobs) { + // all other functionality is controlled by the return privileges object. + // if the license is basic (isPlatinumOrTrialLicense === false) then do not redirect, + // allow the promise to resolve as the separate license check will redirect then user to + // a basic feature + if (privileges.canGetJobs || isPlatinumOrTrialLicense === false) { return resolve(privileges); } else { kbnUrl.redirect('/access-denied'); @@ -32,12 +35,15 @@ export function checkGetJobsPrivilege(kbnUrl: any): Promise { export function checkCreateJobsPrivilege(kbnUrl: any): Promise { return new Promise((resolve, reject) => { - getPrivileges().then(priv => { - privileges = priv; - if (privileges.canCreateJob) { + getPrivileges().then(({ capabilities, isPlatinumOrTrialLicense }) => { + privileges = capabilities; + // if the license is basic (isPlatinumOrTrialLicense === false) then do not redirect, + // allow the promise to resolve as the separate license check will redirect then user to + // a basic feature + if (privileges.canCreateJob || isPlatinumOrTrialLicense === false) { return resolve(privileges); } else { - // if the user has no permission to create a transform, + // if the user has no permission to create a job, // redirect them back to the Transforms Management page kbnUrl.redirect('/jobs'); return reject(); @@ -48,8 +54,8 @@ export function checkCreateJobsPrivilege(kbnUrl: any): Promise { export function checkFindFileStructurePrivilege(kbnUrl: any): Promise { return new Promise((resolve, reject) => { - getPrivileges().then(priv => { - privileges = priv; + getPrivileges().then(({ capabilities }) => { + privileges = capabilities; // the minimum privilege for using ML with a basic license is being able to use the datavisualizer. // all other functionality is controlled by the return privileges object if (privileges.canFindFileStructure) { @@ -64,8 +70,8 @@ export function checkFindFileStructurePrivilege(kbnUrl: any): Promise { return new Promise((resolve, reject) => { - getPrivileges().then(priv => { - privileges = priv; + getPrivileges().then(({ capabilities }) => { + privileges = capabilities; // the minimum privilege for using ML with a basic license is being able to use the data frames. // all other functionality is controlled by the return privileges object if (privileges.canGetDataFrame) { @@ -80,8 +86,8 @@ export function checkGetDataFrameTransformsPrivilege(kbnUrl: any): Promise { return new Promise((resolve, reject) => { - getPrivileges().then(priv => { - privileges = priv; + getPrivileges().then(({ capabilities }) => { + privileges = capabilities; if ( privileges.canCreateDataFrame && privileges.canPreviewDataFrame && diff --git a/x-pack/legacy/plugins/ml/public/privilege/get_privileges.ts b/x-pack/legacy/plugins/ml/public/privilege/get_privileges.ts index 840ba7c05dbd2..5822babe6bb09 100644 --- a/x-pack/legacy/plugins/ml/public/privilege/get_privileges.ts +++ b/x-pack/legacy/plugins/ml/public/privilege/get_privileges.ts @@ -7,19 +7,19 @@ import { ml } from '../services/ml_api_service'; import { setUpgradeInProgress } from '../services/upgrade_service'; -import { Privileges, getDefaultPrivileges } from '../../common/types/privileges'; +import { PrivilegesResponse } from '../../common/types/privileges'; -export function getPrivileges(): Promise { +export function getPrivileges(): Promise { return new Promise((resolve, reject) => { ml.checkMlPrivileges() - .then(({ capabilities, upgradeInProgress }) => { - if (upgradeInProgress === true) { + .then((resp: PrivilegesResponse) => { + if (resp.upgradeInProgress === true) { setUpgradeInProgress(true); } - resolve(capabilities); + resolve(resp); }) .catch(() => { - reject(getDefaultPrivileges()); + reject(); }); }); } diff --git a/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json b/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json new file mode 100644 index 0000000000000..b05462348a300 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/__mocks__/farequote_job_caps_response.json @@ -0,0 +1,206 @@ +{ + "farequote-*": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "median", + "title": "Median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_median", + "title": "High median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_median", + "title": "Low median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "distinct_count", + "title": "Distinct count", + "kibanaName": "cardinality", + "dslName": "cardinality", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "airline", + "responsetime" + ] + } + ], + "fields": [ + { + "id": "responsetime", + "name": "responsetime", + "type": "float", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "median", + "high_median", + "low_median", + "min", + "max", + "distinct_count" + ] + }, + { + "id": "airline", + "name": "airline", + "type": "keyword", + "aggregatable": true, + "aggIds": [ + "distinct_count" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/public/services/forecast_service.js b/x-pack/legacy/plugins/ml/public/services/forecast_service.js index 17b29e65cfde7..4ec8a9dd1fec4 100644 --- a/x-pack/legacy/plugins/ml/public/services/forecast_service.js +++ b/x-pack/legacy/plugins/ml/public/services/forecast_service.js @@ -10,10 +10,8 @@ // data on forecasts that have been performed. import _ from 'lodash'; -import { ML_RESULTS_INDEX_PATTERN } from 'plugins/ml/../common/constants/index_patterns'; -import { ml } from 'plugins/ml/services/ml_api_service'; - - +import { ML_RESULTS_INDEX_PATTERN } from '../../common/constants/index_patterns'; +import { ml } from './ml_api_service'; // Gets a basic summary of the most recently run forecasts for the specified // job, with results at or later than the supplied timestamp. @@ -382,4 +380,3 @@ export const mlForecastService = { runForecast, getForecastRequestStats }; - diff --git a/x-pack/legacy/plugins/ml/public/services/job_service.d.ts b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts new file mode 100644 index 0000000000000..e4d323d6542f4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ExistingJobsAndGroups { + jobIds: string[]; + groupIds: string[]; +} + +declare interface JobService { + currentJob: any; + saveNewJob(job: any): Promise; + cloneJob(job: any): any; + openJob(jobId: string): Promise; + saveNewDatafeed(datafeedConfig: any, jobId: string): Promise; + startDatafeed(datafeedId: string, jobId: string, start: number, end: number): Promise; + createResultsUrl(jobId: string[], start: number, end: number, location: string): string; + getJobAndGroupIds(): ExistingJobsAndGroups; +} + +export const mlJobService: JobService; diff --git a/x-pack/legacy/plugins/ml/public/services/job_service.js b/x-pack/legacy/plugins/ml/public/services/job_service.js index 6a9bdc74d2f40..25cb1d0ffdacb 100644 --- a/x-pack/legacy/plugins/ml/public/services/job_service.js +++ b/x-pack/legacy/plugins/ml/public/services/job_service.js @@ -351,6 +351,7 @@ class JobService { delete tempJob.datafeed_config.job_id; delete tempJob.datafeed_config.state; delete tempJob.datafeed_config.node; + delete tempJob.datafeed_config.timing_stats; // remove query_delay if it's between 60s and 120s // the back-end produces a random value between 60 and 120 and so @@ -739,6 +740,16 @@ class JobService { } + async getJobAndGroupIds() { + try { + return await ml.jobs.getAllJobAndGroupIds(); + } catch (error) { + return { + jobIds: [], + groupIds: [], + }; + } + } } // private function used to check the job saving response diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts index 70dedf50343af..133794725d856 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts @@ -5,7 +5,9 @@ */ import { Annotation } from '../../../common/types/annotations'; -import { Privileges } from '../../../common/types/privileges'; +import { DslName, AggFieldNamePair } from '../../../common/types/fields'; +import { ExistingJobsAndGroups } from '../job_service'; +import { PrivilegesResponse } from '../../../common/types/privileges'; // TODO This is not a complete representation of all methods of `ml.*`. // It just satisfies needs for other parts of the code area which use @@ -15,6 +17,12 @@ interface EsIndex { name: string; } +export interface GetTimeFieldRangeResponse { + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; +} + declare interface Ml { annotations: { deleteAnnotation(id: string | undefined): Promise; @@ -37,11 +45,64 @@ declare interface Ml { }; hasPrivileges(obj: object): Promise; - checkMlPrivileges(): Promise<{ capabilities: Privileges; upgradeInProgress: boolean }>; - esSearch: any; + + checkMlPrivileges(): Promise; + getJobStats(obj: object): Promise; + getDatafeedStats(obj: object): Promise; + esSearch(obj: object): any; getIndices(): Promise; - getTimeFieldRange(obj: object): Promise; + getTimeFieldRange(obj: object): Promise; + calculateModelMemoryLimit(obj: object): Promise<{ modelMemoryLimit: string }>; + calendars(): Promise< + Array<{ + calendar_id: string; + description: string; + events: any[]; + job_ids: string[]; + }> + >; + + jobs: { + jobsSummary(jobIds: string[]): Promise; + jobs(jobIds: string[]): Promise; + groups(): Promise; + updateGroups(updatedJobs: string[]): Promise; + forceStartDatafeeds(datafeedIds: string[], start: string, end: string): Promise; + stopDatafeeds(datafeedIds: string[]): Promise; + deleteJobs(jobIds: string[]): Promise; + closeJobs(jobIds: string[]): Promise; + jobAuditMessages(jobId: string, from: string): Promise; + deletingJobTasks(): Promise; + newJobCaps(indexPatternTitle: string, isRollup: boolean): Promise; + newJobLineChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null + ): Promise; + newJobPopulationsChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string + ): Promise; + getAllJobAndGroupIds(): Promise; + getLookBackProgress( + jobId: string, + start: number, + end: number + ): Promise<{ progress: number; isRunning: boolean }>; + }; } declare const ml: Ml; diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js b/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js index 25962620b9af2..39b646998b426 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/jobs.js @@ -127,4 +127,84 @@ export const jobs = { }); }, + newJobCaps(indexPatternTitle, isRollup = false) { + const isRollupString = (isRollup === true) ? `?rollup=true` : ''; + return http({ + url: `${basePath}/jobs/new_job_caps/${indexPatternTitle}${isRollupString}`, + method: 'GET', + }); + }, + + newJobLineChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + ) { + return http({ + url: `${basePath}/jobs/new_job_line_chart`, + method: 'POST', + data: { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + } + }); + }, + + newJobPopulationsChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + ) { + return http({ + url: `${basePath}/jobs/new_job_population_chart`, + method: 'POST', + data: { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + } + }); + }, + + getAllJobAndGroupIds() { + return http({ + url: `${basePath}/jobs/all_jobs_and_group_ids`, + method: 'GET', + }); + }, + + getLookBackProgress(jobId, start, end) { + return http({ + url: `${basePath}/jobs/look_back_progress`, + method: 'POST', + data: { + jobId, + start, + end, + } + }); + }, }; diff --git a/x-pack/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts new file mode 100644 index 0000000000000..ca66c79588a73 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities._service.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { newJobCapsService } from './new_job_capabilities_service'; +import { IndexPattern } from 'ui/index_patterns'; + +// there is magic happening here. starting the include name with `mock..` +// ensures it can be lazily loaded by the jest.mock function below. +import mockFarequoteResponse from './__mocks__/farequote_job_caps_response.json'; + +jest.mock('./ml_api_service', () => ({ + ml: { + jobs: { + newJobCaps: jest.fn(() => Promise.resolve(mockFarequoteResponse)), + }, + }, +})); + +const indexPattern = ({ + id: 'farequote-*', + title: 'farequote-*', +} as unknown) as IndexPattern; + +describe('new_job_capabilities_service', () => { + describe('farequote newJobCaps()', () => { + it('can construct job caps objects from endpoint json', async done => { + await newJobCapsService.initializeFromIndexPattern(indexPattern); + const { fields, aggs } = await newJobCapsService.newJobCaps; + + const responseTimeField = fields.find(f => f.id === 'responsetime') || { aggs: [] }; + const airlineField = fields.find(f => f.id === 'airline') || { aggs: [] }; + const meanAgg = aggs.find(a => a.id === 'mean') || { fields: [] }; + const distinctCountAgg = aggs.find(a => a.id === 'distinct_count') || { fields: [] }; + + expect(fields).toHaveLength(3); + expect(aggs).toHaveLength(15); + + expect(responseTimeField.aggs).toHaveLength(12); + expect(airlineField.aggs).toHaveLength(1); + + expect(meanAgg.fields).toHaveLength(1); + expect(distinctCountAgg.fields).toHaveLength(2); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.ts b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.ts new file mode 100644 index 0000000000000..99f4d14b3ffc8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/new_job_capabilities_service.ts @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPattern } from 'ui/index_patterns'; +import { + Field, + Aggregation, + AggId, + FieldId, + NewJobCaps, + EVENT_RATE_FIELD_ID, +} from '../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../common/constants/field_types'; +import { ML_JOB_AGGREGATION } from '../../common/constants/aggregation_types'; +import { ml } from './ml_api_service'; + +// called in the angular routing resolve block to initialize the +// newJobCapsService with the currently selected index pattern +export function loadNewJobCapabilities(indexPatterns: any, $route: Record) { + return new Promise(resolve => { + indexPatterns + .get($route.current.params.index) + .then(async (indexPattern: IndexPattern) => { + await newJobCapsService.initializeFromIndexPattern(indexPattern); + resolve(newJobCapsService.newJobCaps); + }) + .catch((error: any) => { + resolve(error); + }); + }); +} + +const categoryFieldTypes = [ES_FIELD_TYPES.TEXT, ES_FIELD_TYPES.KEYWORD, ES_FIELD_TYPES.IP]; + +class NewJobCapsService { + private _fields: Field[]; + private _aggs: Aggregation[]; + private _includeCountAgg: boolean; + + constructor(includeCountAgg = true) { + this._fields = []; + this._aggs = []; + this._includeCountAgg = includeCountAgg; + } + + public get fields(): Field[] { + return this._fields; + } + + public get aggs(): Aggregation[] { + return this._aggs; + } + + public get newJobCaps(): NewJobCaps { + return { + fields: this._fields, + aggs: this._aggs, + }; + } + + public get categoryFields(): Field[] { + return this._fields.filter(f => categoryFieldTypes.includes(f.type)); + } + + public async initializeFromIndexPattern(indexPattern: IndexPattern) { + try { + const resp = await ml.jobs.newJobCaps(indexPattern.title, indexPattern.type === 'rollup'); + const { fields, aggs } = createObjects(resp, indexPattern.title); + + if (this._includeCountAgg === true) { + const { countField, countAggs } = createCountFieldAndAggs(); + + fields.push(countField); + aggs.push(...countAggs); + } + + this._fields = fields; + this._aggs = aggs; + } catch (error) { + console.error('Unable to load new job capabilities', error); // eslint-disable-line no-console + } + } + + public getFieldById(id: string): Field | null { + const field = this._fields.find(f => f.id === id); + return field === undefined ? null : field; + } + + public getAggById(id: string): Aggregation | null { + const agg = this._aggs.find(f => f.id === id); + return agg === undefined ? null : agg; + } +} + +// using the response from the endpoint, create the field and aggs objects +// when transported over the endpoint, the fields and aggs contain lists of ids of the +// fields and aggs they are related to. +// this function creates lists of real Fields and Aggregations and cross references them. +// the list if ids are then deleted. +function createObjects(resp: any, indexPatternTitle: string) { + const results = resp[indexPatternTitle]; + + const fields: Field[] = []; + const aggs: Aggregation[] = []; + // for speed, a map of aggregations, keyed on their id + + // create a AggMap type to allow an enum (AggId) to be used as a Record key and then initialized with {} + type AggMap = Record; + const aggMap: AggMap = {} as AggMap; + // for speed, a map of aggregation id lists from a field, keyed on the field id + const aggIdMap: Record = {}; + + if (results !== undefined) { + results.aggs.forEach((a: Aggregation) => { + // copy the agg and add a Fields list + const agg: Aggregation = { + ...a, + fields: [], + }; + aggMap[agg.id] = agg; + aggs.push(agg); + }); + + results.fields.forEach((f: Field) => { + // copy the field and add an Aggregations list + const field: Field = { + ...f, + aggs: [], + }; + if (field.aggIds !== undefined) { + aggIdMap[field.id] = field.aggIds; + } + fields.push(field); + }); + + // loop through the fields and populate their aggs lists. + // for each agg added to a field, also add that field to the agg's field list + fields.forEach((field: Field) => { + aggIdMap[field.id].forEach((aggId: AggId) => { + mix(field, aggMap[aggId]); + }); + }); + } + + // the aggIds and fieldIds lists are no longer needed as we've created + // lists of real fields and aggs + fields.forEach(f => delete f.aggIds); + aggs.forEach(a => delete a.fieldIds); + + return { + fields, + aggs, + }; +} + +function mix(field: Field, agg: Aggregation) { + if (agg.fields === undefined) { + agg.fields = []; + } + if (field.aggs === undefined) { + field.aggs = []; + } + agg.fields.push(field); + field.aggs.push(agg); +} + +function createCountFieldAndAggs() { + const countField: Field = { + id: EVENT_RATE_FIELD_ID, + name: 'Event rate', + type: ES_FIELD_TYPES.INTEGER, + aggregatable: true, + aggs: [], + }; + + const countAggs: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: 'count', + dslName: 'count', + type: 'metrics', + mlModelPlotAgg: { + min: 'min', + max: 'max', + }, + fields: [countField], + }, + ]; + + if (countField.aggs !== undefined) { + countField.aggs.push(...countAggs); + } + + return { + countField, + countAggs, + }; +} + +export const newJobCapsService = new NewJobCapsService(); diff --git a/x-pack/legacy/plugins/ml/public/services/results_service.d.ts b/x-pack/legacy/plugins/ml/public/services/results_service.d.ts new file mode 100644 index 0000000000000..b30a13ad175cf --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/services/results_service.d.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +declare interface MlResultsService { + getScoresByBucket: ( + jobIds: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + maxResults: number + ) => Promise; + getScheduledEventsByBucket: () => Promise; + getTopInfluencers: () => Promise; + getTopInfluencerValues: () => Promise; + getOverallBucketScores: () => Promise; + getInfluencerValueMaxScoreByTime: () => Promise; + getRecordInfluencers: () => Promise; + getRecordsForInfluencer: () => Promise; + getRecordsForDetector: () => Promise; + getRecords: () => Promise; + getRecordsForCriteria: () => Promise; + getMetricData: () => Promise; + getEventRateData: ( + index: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | number + ) => Promise; + getEventDistributionData: () => Promise; + getModelPlotOutput: ( + jobId: string, + detectorIndex: number, + criteriaFields: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + aggType: { + min: string; + max: string; + } + ) => Promise; + getRecordMaxScoreByTime: () => Promise; +} + +export const mlResultsService: MlResultsService; diff --git a/x-pack/legacy/plugins/ml/public/settings/calendars/edit/directive.js b/x-pack/legacy/plugins/ml/public/settings/calendars/edit/directive.js index 2d5427f091def..728176a9b7eb1 100644 --- a/x-pack/legacy/plugins/ml/public/settings/calendars/edit/directive.js +++ b/x-pack/legacy/plugins/ml/public/settings/calendars/edit/directive.js @@ -17,14 +17,9 @@ import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check import { checkMlNodesAvailable } from '../../../ml_nodes_check/check_ml_nodes'; import { getCreateCalendarBreadcrumbs, getEditCalendarBreadcrumbs } from '../../breadcrumbs'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { I18nContext } from 'ui/i18n'; -import { NavigationMenuContext } from '../../../util/context_utils'; - const template = `
@@ -66,9 +61,7 @@ module.directive('mlNewCalendar', function ($route) { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap b/x-pack/legacy/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap index 179e453281cd6..ce0f62b585f20 100644 --- a/x-pack/legacy/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/settings/calendars/edit/import_modal/__snapshots__/import_modal.test.js.snap @@ -45,6 +45,7 @@ exports[`ImportModal Renders import modal 1`] = ` diff --git a/x-pack/legacy/plugins/ml/public/settings/calendars/list/directive.js b/x-pack/legacy/plugins/ml/public/settings/calendars/list/directive.js index 4f16433b987d5..32085fa0e9939 100644 --- a/x-pack/legacy/plugins/ml/public/settings/calendars/list/directive.js +++ b/x-pack/legacy/plugins/ml/public/settings/calendars/list/directive.js @@ -16,14 +16,9 @@ import { checkGetJobsPrivilege, checkPermission } from '../../../privilege/check import { getMlNodeCount } from '../../../ml_nodes_check/check_ml_nodes'; import { getCalendarManagementBreadcrumbs } from '../../breadcrumbs'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { I18nContext } from 'ui/i18n'; -import { NavigationMenuContext } from '../../../util/context_utils'; - const template = `
@@ -54,9 +49,7 @@ module.directive('mlCalendarsList', function () { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/settings/filter_lists/edit/directive.js b/x-pack/legacy/plugins/ml/public/settings/filter_lists/edit/directive.js index af3ba07d1b655..1d6c72c13f9f2 100644 --- a/x-pack/legacy/plugins/ml/public/settings/filter_lists/edit/directive.js +++ b/x-pack/legacy/plugins/ml/public/settings/filter_lists/edit/directive.js @@ -18,14 +18,9 @@ import { checkGetJobsPrivilege, checkPermission } from 'plugins/ml/privilege/che import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; import { EditFilterList } from './edit_filter_list'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { I18nContext } from 'ui/i18n'; -import { NavigationMenuContext } from '../../../util/context_utils'; - const template = `
@@ -65,9 +60,7 @@ module.directive('mlEditFilterList', function ($route) { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/settings/filter_lists/list/directive.js b/x-pack/legacy/plugins/ml/public/settings/filter_lists/list/directive.js index 95cdebf3518f7..311bf1bcb358a 100644 --- a/x-pack/legacy/plugins/ml/public/settings/filter_lists/list/directive.js +++ b/x-pack/legacy/plugins/ml/public/settings/filter_lists/list/directive.js @@ -18,14 +18,9 @@ import { checkGetJobsPrivilege, checkPermission } from 'plugins/ml/privilege/che import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; import { FilterLists } from './filter_lists'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; import { I18nContext } from 'ui/i18n'; -import { NavigationMenuContext } from '../../../util/context_utils'; - const template = `
@@ -55,9 +50,7 @@ module.directive('mlFilterLists', function () { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/settings/settings.js b/x-pack/legacy/plugins/ml/public/settings/settings.js index 3e1b7d724f188..e8d2533af7f4b 100644 --- a/x-pack/legacy/plugins/ml/public/settings/settings.js +++ b/x-pack/legacy/plugins/ml/public/settings/settings.js @@ -22,14 +22,14 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; -import { useNavigationMenuContext } from '../util/context_utils'; +import { useUiChromeContext } from '../contexts/ui/use_ui_chrome_context'; import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; export function Settings({ canGetFilters, canGetCalendars }) { - const basePath = useNavigationMenuContext().chrome.getBasePath(); + const basePath = useUiChromeContext().getBasePath(); return ( diff --git a/x-pack/legacy/plugins/ml/public/settings/settings.test.js b/x-pack/legacy/plugins/ml/public/settings/settings.test.js index 3e7129bbc0718..0fe0a4ab50be4 100644 --- a/x-pack/legacy/plugins/ml/public/settings/settings.test.js +++ b/x-pack/legacy/plugins/ml/public/settings/settings.test.js @@ -5,27 +5,18 @@ */ import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import PropTypes from 'prop-types'; import React from 'react'; -import * as ContextUtils from '../util/context_utils'; import { Settings } from './settings'; -const navigationMenuMock = ContextUtils.navigationMenuMock; -const mountOptions = { - context: { NavigationMenuContext: navigationMenuMock }, - childContextTypes: { NavigationMenuContext: PropTypes.object } -}; - +jest.mock('../contexts/ui/use_ui_chrome_context'); jest.mock('../components/navigation_menu/navigation_menu', () => ({ - NavigationMenu: () =>
+ NavigationMenu: () =>
, })); -jest.spyOn(ContextUtils, 'useNavigationMenuContext').mockImplementation(() => navigationMenuMock); - describe('Settings', () => { test('Renders settings page with all buttons enabled.', () => { - const wrapper = mountWithIntl(, mountOptions); + const wrapper = mountWithIntl(); const filterButton = wrapper .find('[data-test-subj="ml_filter_lists_button"]') @@ -39,7 +30,7 @@ describe('Settings', () => { }); test('Filter Lists button disabled if canGetFilters is false', () => { - const wrapper = mountWithIntl(, mountOptions); + const wrapper = mountWithIntl(); const filterButton = wrapper .find('[data-test-subj="ml_filter_lists_button"]') @@ -53,7 +44,7 @@ describe('Settings', () => { }); test('Calendar management button disabled if canGetCalendars is false', () => { - const wrapper = mountWithIntl(, mountOptions); + const wrapper = mountWithIntl(); const filterButton = wrapper .find('[data-test-subj="ml_filter_lists_button"]') diff --git a/x-pack/legacy/plugins/ml/public/settings/settings_directive.js b/x-pack/legacy/plugins/ml/public/settings/settings_directive.js index ab03c548f53cd..8692d903b1bd0 100644 --- a/x-pack/legacy/plugins/ml/public/settings/settings_directive.js +++ b/x-pack/legacy/plugins/ml/public/settings/settings_directive.js @@ -15,14 +15,11 @@ const module = uiModules.get('apps/ml', ['react']); import { checkFullLicense } from '../license/check_license'; import { checkGetJobsPrivilege, checkPermission } from '../privilege/check_privilege'; import { getMlNodeCount } from '../ml_nodes_check/check_ml_nodes'; -import { NavigationMenuContext } from '../util/context_utils'; import { getSettingsBreadcrumbs } from './breadcrumbs'; import { I18nContext } from 'ui/i18n'; -import chrome from 'ui/chrome'; import uiRoutes from 'ui/routes'; import { timefilter } from 'ui/timefilter'; -import { timeHistory } from 'ui/timefilter/time_history'; const template = `
@@ -58,9 +55,7 @@ module.directive('mlSettings', function () { ReactDOM.render( - - - + , element[0] ); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js deleted file mode 100644 index 9ee71c676c6c6..0000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseries_chart_directive.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { TimeseriesChart } from '../components/timeseries_chart/timeseries_chart'; - -describe('ML - ', () => { - let $scope; - let $compile; - let $element; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Plain initialization doesn\'t throw an error', () => { - // this creates a dummy DOM element with class 'ml-timeseries-chart' as a direct child of - // the tag so the directive can find it in the DOM to create the resizeChecker. - const mockClassedElement = document.createElement('div'); - mockClassedElement.classList.add('ml-timeseries-chart'); - document.getElementsByTagName('body')[0].append(mockClassedElement); - - // spy the TimeseriesChart component's unmount method to be able to test if it was called - const componentWillUnmountSpy = sinon.spy(TimeseriesChart.prototype, 'componentWillUnmount'); - - $element = $compile('')($scope); - const scope = $element.isolateScope(); - - // sanity test to check if directive picked up the attribute for its scope - expect(scope.showForecast).to.equal(true); - - // componentWillUnmount() should not have been called so far - expect(componentWillUnmountSpy.callCount).to.equal(0); - - // remove $element to trigger $destroy() callback - $element.remove(); - - // componentWillUnmount() should now have been called once - expect(componentWillUnmountSpy.callCount).to.equal(1); - - componentWillUnmountSpy.restore(); - - // clean up the dummy DOM element - mockClassedElement.parentNode.removeChild(mockClassedElement); - }); - -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js deleted file mode 100644 index 42510c5a30fd1..0000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_controller.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -import ngMock from 'ng_mock'; -import expect from '@kbn/expect'; - -describe('ML - Time Series Explorer Controller', () => { - beforeEach(() => { - ngMock.module('kibana'); - }); - - it('Initialize Time Series Explorer Controller', (done) => { - ngMock.inject(function ($rootScope, $controller) { - const scope = $rootScope.$new(); - - expect(() => { - $controller('MlTimeSeriesExplorerController', { $scope: scope }); - }).to.not.throwError(); - - expect(scope.timeFieldName).to.eql('timestamp'); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js new file mode 100644 index 0000000000000..b86abd15dce7e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import ngMock from 'ng_mock'; +import expect from '@kbn/expect'; + +describe('ML - Time Series Explorer Directive', () => { + let $scope; + let $compile; + let $element; + + beforeEach(ngMock.module('kibana')); + beforeEach(() => { + ngMock.inject(function ($injector) { + $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + $scope = $rootScope.$new(); + }); + }); + + afterEach(() => { + $scope.$destroy(); + }); + + it('Initialize Time Series Explorer Directive', (done) => { + ngMock.inject(function () { + expect(() => { + $element = $compile('')($scope); + }).to.not.throwError(); + + // directive has scope: false + const scope = $element.isolateScope(); + expect(scope).to.eql(undefined); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss index eec3db7fac889..f4366ec13fda3 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/_timeseriesexplorer.scss @@ -60,22 +60,6 @@ } } - .show-model-controls { - float: right; - position: relative; - top: 18px; - - div { - display: inline; - padding-left: $euiSize; - } - - .kuiCheckBoxLabel { - display: inline-block; - font-size: $euiFontSizeXS; - } - } - .forecast-controls { float: right; } diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js index 95dfd9325f5a6..e6ed8674ca0c5 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/context_chart_mask/index.js @@ -5,4 +5,4 @@ */ -export * from './context_chart_mask'; +export { ContextChartMask } from './context_chart_mask'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js new file mode 100644 index 0000000000000..0e82002904d51 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/entity_control.js @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import { injectI18n } from '@kbn/i18n/react'; + +import { + EuiComboBox, + EuiFlexItem, + EuiFormRow, +} from '@elastic/eui'; + +function getEntityControlOptions(entity) { + if (!Array.isArray(entity.fieldValues)) { + return []; + } + + return entity.fieldValues.map((value) => { + return { label: value }; + }); +} + +export const EntityControl = injectI18n( + class EntityControl extends React.Component { + static propTypes = { + entity: PropTypes.object.isRequired, + entityFieldValueChanged: PropTypes.func.isRequired, + }; + + state = { + selectedOptions: undefined + } + + constructor(props) { + super(props); + } + + componentDidUpdate() { + const { entity } = this.props; + const { selectedOptions } = this.state; + + const fieldValue = entity.fieldValue; + + if ( + (selectedOptions === undefined && fieldValue.length > 0) || + (Array.isArray(selectedOptions) && fieldValue.length > 0 && selectedOptions[0].label !== fieldValue) + ) { + this.setState({ + selectedOptions: [{ label: fieldValue }] + }); + } else if (Array.isArray(selectedOptions) && fieldValue.length === 0) { + this.setState({ + selectedOptions: undefined + }); + } + } + + onChange = (selectedOptions) => { + const options = (selectedOptions.length > 0) ? selectedOptions : undefined; + this.setState({ + selectedOptions: options, + }); + + const fieldValue = (Array.isArray(options) && options[0].label.length > 0) ? options[0].label : ''; + this.props.entityFieldValueChanged(this.props.entity, fieldValue); + }; + + render() { + const { entity, intl } = this.props; + const { selectedOptions } = this.state; + const options = getEntityControlOptions(entity); + + return ( + + + + + + ); + } + } +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js new file mode 100644 index 0000000000000..63ceb2b4490b5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/entity_control/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { EntityControl } from './entity_control'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js index 1b807638e57d3..00812d56ade4a 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/forecasting_modal/forecasting_modal.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ - - /* * React modal dialog which allows the user to run and view time series forecasts. */ @@ -22,6 +20,8 @@ import { EuiToolTip } from '@elastic/eui'; +import { timefilter } from 'ui/timefilter'; + // don't use something like plugins/ml/../common // because it won't work with the jest tests import { FORECAST_REQUEST_STATE, JOB_STATE } from '../../../../common/constants/states'; @@ -44,7 +44,6 @@ const WARN_NUM_PARTITIONS = 100; // Warn about running a forecast with this n const FORECAST_STATS_POLL_FREQUENCY = 250; // Frequency in ms at which to poll for forecast request stats. const WARN_NO_PROGRESS_MS = 120000; // If no progress in forecast request, abort check and warn. - function getDefaultState() { return { isModalVisible: false, @@ -60,7 +59,6 @@ function getDefaultState() { }; } - export const ForecastingModal = injectI18n(class ForecastingModal extends Component { static propTypes = { isDisabled: PropTypes.bool, @@ -68,7 +66,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon detectorIndex: PropTypes.number, entities: PropTypes.array, loadForForecastId: PropTypes.func, - timefilter: PropTypes.object, }; constructor(props) { @@ -348,7 +345,7 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon if (typeof job === 'object') { // Get the list of all the finished forecasts for this job with results at or later than the dashboard 'from' time. - const bounds = this.props.timefilter.getActiveBounds(); + const bounds = timefilter.getActiveBounds(); const statusFinishedQuery = { term: { forecast_status: FORECAST_REQUEST_STATE.FINISHED @@ -458,7 +455,6 @@ export const ForecastingModal = injectI18n(class ForecastingModal extends Compon const forecastButton = ( 0) { + if (mlAnnotationsEnabled && focusAnnotationData && focusAnnotationData.length > 0) { const levels = getAnnotationLevels(focusAnnotationData); const maxLevel = d3.max(Object.keys(levels).map(key => levels[key])); // TODO needs revisiting to be a more robust normalization @@ -643,7 +635,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .classed('hidden', !showModelBounds); } - if (annotationsEnabled) { + if (mlAnnotationsEnabled) { renderAnnotations( focusChart, focusAnnotationData, @@ -853,12 +845,9 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo const data = contextChartData; - const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this); - const drawContextBrush = this.drawContextBrush.bind(this); - const drawSwimlane = this.drawSwimlane.bind(this); this.contextXScale = d3.time.scale().range([0, cxtWidth]) - .domain(calculateContextXAxisDomain()); + .domain(this.calculateContextXAxisDomain()); const combinedData = contextForecastData === undefined ? data : data.concat(contextForecastData); const valuesRange = { min: Number.MAX_VALUE, max: Number.MIN_VALUE }; @@ -969,7 +958,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('class', 'swimlane') .attr('transform', 'translate(0,' + cxtChartHeight + ')'); - drawSwimlane(swimlane, cxtWidth, swlHeight); + this.drawSwimlane(swimlane, cxtWidth, swlHeight); // Draw a mask over the sections of the context chart and swimlane // which fall outside of the zoom brush selection area. @@ -988,17 +977,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo filterAxisLabels(cxtGroup.selectAll('.x.context-chart-axis'), cxtWidth); - drawContextBrush(cxtGroup); + this.drawContextBrush(cxtGroup); } - drawContextBrush(contextGroup) { + drawContextBrush = (contextGroup) => { const { contextChartSelected } = this.props; const brush = this.brush; const contextXScale = this.contextXScale; - const setBrushVisibility = this.setBrushVisibility.bind(this); const mask = this.mask; // Create the brush for zooming in to the focus area of interest. @@ -1023,6 +1011,8 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('x', 0) .attr('width', 10); + const handleBrushExtent = brush.extent(); + const topBorder = contextGroup.append('rect') .attr('class', 'top-border') .attr('y', -2) @@ -1034,16 +1024,16 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo .attr('width', 10) .attr('height', 90) .attr('class', 'brush-handle') + .attr('x', contextXScale(handleBrushExtent[0]) - 10) .html('
'); const rightHandle = contextGroup.append('foreignObject') .attr('width', 10) .attr('height', 90) .attr('class', 'brush-handle') + .attr('x', contextXScale(handleBrushExtent[1]) + 0) .html('
'); - setBrushVisibility(!brush.empty()); - - function showBrush(show) { + const showBrush = (show) => { if (show === true) { const brushExtent = brush.extent(); mask.reveal(brushExtent); @@ -1054,8 +1044,10 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo topBorder.attr('width', contextXScale(brushExtent[1]) - contextXScale(brushExtent[0]) - 2); } - setBrushVisibility(show); - } + this.setBrushVisibility(show); + }; + + showBrush(!brush.empty()); function brushing() { const isEmpty = brush.empty(); @@ -1087,7 +1079,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } } - setBrushVisibility(show) { + setBrushVisibility = (show) => { const mask = this.mask; if (mask !== undefined) { @@ -1107,14 +1099,12 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } } - drawSwimlane(swlGroup, swlWidth, swlHeight) { + drawSwimlane = (swlGroup, swlWidth, swlHeight) => { const { contextAggregationInterval, swimlaneData } = this.props; - const calculateContextXAxisDomain = this.calculateContextXAxisDomain.bind(this); - const data = swimlaneData; if (typeof data === 'undefined') { @@ -1126,7 +1116,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo // x-axis min to the start of the aggregation interval. // Need to use the min(earliest) and max(earliest) of the context chart // aggregation to align the axes of the chart and swimlane elements. - const xAxisDomain = calculateContextXAxisDomain(); + const xAxisDomain = this.calculateContextXAxisDomain(); const x = d3.time.scale().range([0, swlWidth]) .domain(xAxisDomain); @@ -1182,7 +1172,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo } - calculateContextXAxisDomain() { + calculateContextXAxisDomain = () => { const { contextAggregationInterval, swimlaneData, @@ -1211,9 +1201,19 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo // Sets the extent of the brush on the context chart to the // supplied from and to Date objects. - setContextBrushExtent(from, to, fireEvent) { + setContextBrushExtent = (from, to, fireEvent) => { const brush = this.brush; - brush.extent([from, to]); + const brushExtent = brush.extent(); + + const newExtent = [from, to]; + if ( + newExtent[0].getTime() === brushExtent[0].getTime() && + newExtent[1].getTime() === brushExtent[1].getTime() + ) { + fireEvent = false; + } + + brush.extent(newExtent); brush(d3.select('.brush')); if (fireEvent) { brush.event(d3.select('.brush')); @@ -1226,8 +1226,6 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo zoomTo } = this.props; - const setContextBrushExtent = this.setContextBrushExtent.bind(this); - const bounds = timefilter.getActiveBounds(); const minBoundsMs = bounds.min.valueOf(); const maxBoundsMs = bounds.max.valueOf(); @@ -1242,12 +1240,11 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo to = Math.min(minBoundsMs + millis, maxBoundsMs); } - setContextBrushExtent(new Date(from), new Date(to), true); + this.setContextBrushExtent(new Date(from), new Date(to), true); } showFocusChartTooltip(marker, circle) { const { - annotationsEnabled, modelPlotEnabled, intl } = this.props; @@ -1388,7 +1385,7 @@ const TimeseriesChartIntl = injectI18n(class TimeseriesChart extends React.Compo }); } - if (annotationsEnabled && _.has(marker, 'annotation')) { + if (mlAnnotationsEnabled && _.has(marker, 'annotation')) { contents = mlEscape(marker.annotation); contents += `
${moment(marker.timestamp).format('MMMM Do YYYY, HH:mm')}`; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index 9dd71a0ba65f9..6374e752951b2 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -16,6 +16,7 @@ import { TimeseriesChart } from './timeseries_chart'; // mocking the following files because they import some core kibana // code which the jest setup isn't happy with. jest.mock('ui/chrome', () => ({ + addBasePath: path => path, getBasePath: path => path, // returns false for mlAnnotationsEnabled getInjected: () => false, diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js deleted file mode 100644 index 9ce4506a91e73..0000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseries_chart/timeseries_chart_directive.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * Chart plotting data from a single time series, with or without model plot enabled, - * annotated with anomalies. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; - -import { TimeseriesChart } from './timeseries_chart'; - -import angular from 'angular'; -import { timefilter } from 'ui/timefilter'; - -import { ResizeChecker } from 'ui/resize_checker'; - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { I18nContext } from 'ui/i18n'; - -module.directive('mlTimeseriesChart', function ($timeout) { - - function link(scope, element) { - // Key dimensions for the viz and constituent charts. - let svgWidth = angular.element('.results-container').width(); - - function contextChartSelected(selection) { - scope.$root.$broadcast('contextChartSelected', selection); - } - - function renderReactComponent(renderFocusChartOnly = false) { - // Set the size of the components according to the width of the parent container at render time. - svgWidth = Math.max(angular.element('.results-container').width(), 0); - - const props = { - annotationsEnabled: scope.annotationsEnabled, - autoZoomDuration: scope.autoZoomDuration, - contextAggregationInterval: scope.contextAggregationInterval, - contextChartData: scope.contextChartData, - contextForecastData: scope.contextForecastData, - contextChartSelected: contextChartSelected, - detectorIndex: scope.detectorIndex, - focusAnnotationData: scope.focusAnnotationData, - focusChartData: scope.focusChartData, - focusForecastData: scope.focusForecastData, - focusAggregationInterval: scope.focusAggregationInterval, - modelPlotEnabled: scope.modelPlotEnabled, - refresh: scope.refresh, - renderFocusChartOnly, - selectedJob: scope.selectedJob, - showAnnotations: scope.showAnnotations, - showForecast: scope.showForecast, - showModelBounds: scope.showModelBounds, - svgWidth, - swimlaneData: scope.swimlaneData, - timefilter, - zoomFrom: scope.zoomFrom, - zoomTo: scope.zoomTo - }; - - ReactDOM.render( - - - , - element[0] - ); - } - - renderReactComponent(); - - scope.$on('render', () => { - $timeout(() => { - renderReactComponent(); - }); - }); - - function renderFocusChart() { - renderReactComponent(true); - } - - scope.$watchCollection('focusForecastData', renderFocusChart); - scope.$watchCollection('focusChartData', renderFocusChart); - scope.$watchGroup(['showModelBounds', 'showForecast'], renderFocusChart); - scope.$watch('annotationsEnabled', renderReactComponent); - if (scope.annotationsEnabled) { - scope.$watchCollection('focusAnnotationData', renderFocusChart); - scope.$watch('showAnnotations', renderFocusChart); - } - - // Redraw the charts when the container is resize. - const resizeChecker = new ResizeChecker(angular.element('.ml-timeseries-chart')); - resizeChecker.on('resize', () => { - scope.$evalAsync(() => { - renderReactComponent(); - - // Add a re-render of the focus chart to set renderFocusChartOnly back to true. - // Not efficient, but ensures adding annotations doesn't cause the whole chart - // to be re-rendered. - renderReactComponent(true); - }); - }); - - element.on('$destroy', () => { - resizeChecker.destroy(); - // unmountComponentAtNode() needs to be called so mlTableService listeners within - // the TimeseriesChart component get unwatched properly. - ReactDOM.unmountComponentAtNode(element[0]); - scope.$destroy(); - }); - - } - - return { - scope: { - annotationsEnabled: '=', - selectedJob: '=', - detectorIndex: '=', - modelPlotEnabled: '=', - contextChartData: '=', - contextForecastData: '=', - contextChartAnomalyData: '=', - focusChartData: '=', - swimlaneData: '=', - focusAnnotationData: '=', - focusForecastData: '=', - contextAggregationInterval: '=', - focusAggregationInterval: '=', - zoomFrom: '=', - zoomTo: '=', - autoZoomDuration: '=', - refresh: '=', - showAnnotations: '=', - showModelBounds: '=', - showForecast: '=' - }, - link: link - }; -}); diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/index.ts b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js similarity index 74% rename from x-pack/legacy/plugins/snapshot_restore/public/app/services/state/index.ts rename to x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js index e0c4ee23cb18a..73c577e5134cc 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/state/index.ts +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/index.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { initialState, reducer, AppStateProvider, useAppState } from './app_state'; +export { TimeseriesexplorerNoChartData } from './timeseriesexplorer_no_chart_data'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js new file mode 100644 index 0000000000000..726ef2cffe8e8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_chart_data/timeseriesexplorer_no_chart_data.js @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering EuiEmptyPrompt when no results were found. + */ + +import React from 'react'; + +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +export const TimeseriesexplorerNoChartData = ({ dataNotChartable, entities }) => ( + + {i18n.translate('xpack.ml.timeSeriesExplorer.noResultsFoundLabel', { + defaultMessage: 'No results found' + })} + + } + body={dataNotChartable + ? ( +

+ {i18n.translate('xpack.ml.timeSeriesExplorer.dataNotChartableDescription', { + defaultMessage: `Model plot is not collected for the selected {entityCount, plural, one {entity} other {entities}} +and the source data cannot be plotted for this detector.`, + values: { + entityCount: entities.length + } + })} +

+ ) + : ( +

+ {i18n.translate('xpack.ml.timeSeriesExplorer.tryWideningTheTimeSelectionDescription', { + defaultMessage: 'Try widening the time selection or moving further back in time.' + })} +

+ ) + } + /> +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js new file mode 100644 index 0000000000000..843e2490ac4b6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/index.js @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimeseriesexplorerNoJobsFound } from './timeseriesexplorer_no_jobs_found'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js new file mode 100644 index 0000000000000..52d0326d7ca64 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/components/timeseriesexplorer_no_jobs_found/timeseriesexplorer_no_jobs_found.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering EuiEmptyPrompt when no jobs were found. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; + +export const TimeseriesexplorerNoJobsFound = () => ( + + + + } + actions={ + + + + } + /> +); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js index 157a60713cd7d..946312d08e9ce 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/index.js @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import './components/forecasting_modal'; -import './components/timeseries_chart/timeseries_chart_directive'; -import './timeseriesexplorer_controller.js'; +import './timeseriesexplorer_directive.js'; +import './timeseriesexplorer_route.js'; import './timeseries_search_service.js'; -import 'plugins/ml/components/job_selector'; -import 'plugins/ml/components/chart_tooltip'; +import '../components/job_selector'; +import '../components/chart_tooltip'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js index d92a0143b7672..520cce3c73260 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseries_search_service.js @@ -8,10 +8,10 @@ import _ from 'lodash'; -import { ml } from 'plugins/ml/services/ml_api_service'; -import { isModelPlotEnabled } from 'plugins/ml/../common/util/job_utils'; -import { buildConfigFromDetector } from 'plugins/ml/util/chart_config_builder'; -import { mlResultsService } from 'plugins/ml/services/results_service'; +import { ml } from '../services/ml_api_service'; +import { isModelPlotEnabled } from '../../common/util/job_utils'; +import { buildConfigFromDetector } from '../util/chart_config_builder'; +import { mlResultsService } from '../services/results_service'; function getMetricData(job, detectorIndex, entityFields, earliestMs, latestMs, interval) { if (isModelPlotEnabled(job, detectorIndex, entityFields)) { diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html deleted file mode 100644 index 300832d05c741..0000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.html +++ /dev/null @@ -1,263 +0,0 @@ - - -
- - - -
-
-
-
-
-
-
-
- -
- - - -
- - - - - - -
- - - - - -
- - - -
-
-
-
-
-
- -
-
-
-
-
-
- -
-
- - - - - {{$first ? '(' : ''}}{{entity.fieldName}}: {{entity.fieldValue}}{{$last ? ')' : ', '}} - - - - - - - -
-
- - -
- -
- - -
- -
- - -
-
- -
- - - -
- -
- - - - -
-
- - - - - -
-
-
- -
- -
-
-
-
-
- -
- -
-
-
-
- - - -
-
- -
diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js new file mode 100644 index 0000000000000..9a5437199f5b2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer.js @@ -0,0 +1,1221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering Single Metric Viewer. + */ + +import { chain, difference, each, find, filter, first, get, has, isEqual, without } from 'lodash'; +import moment from 'moment-timezone'; +import { Subscription } from 'rxjs'; + +import PropTypes from 'prop-types'; +import React, { createRef, Fragment } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiButton, + EuiSelect, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import chrome from 'ui/chrome'; +import { parseInterval } from 'ui/utils/parse_interval'; +import { toastNotifications } from 'ui/notify'; +import { ResizeChecker } from 'ui/resize_checker'; + +import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../common/constants/search'; +import { + isModelPlotEnabled, + isSourceDataChartableForDetector, + isTimeSeriesViewJob, + isTimeSeriesViewDetector, + mlFunctionToESAggregation, +} from '../../common/util/job_utils'; + +import { jobSelectServiceFactory, setGlobalState, getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; +import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; +import { AnnotationsTable } from '../components/annotations/annotations_table'; +import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +import { EntityControl } from './components/entity_control'; +import { ForecastingModal } from './components/forecasting_modal/forecasting_modal'; +import { JobSelector } from '../components/job_selector'; +import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; +import { NavigationMenu } from '../components/navigation_menu/navigation_menu'; +import { severity$, SelectSeverity } from '../components/controls/select_severity/select_severity'; +import { interval$, SelectInterval } from '../components/controls/select_interval/select_interval'; +import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart'; +import { TimeseriesexplorerNoJobsFound } from './components/timeseriesexplorer_no_jobs_found'; +import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data'; + +import { annotationsRefresh$ } from '../services/annotations_service'; +import { ml } from '../services/ml_api_service'; +import { mlFieldFormatService } from '../services/field_format_service'; +import { mlForecastService } from '../services/forecast_service'; +import { mlJobService } from '../services/job_service'; +import { mlResultsService } from '../services/results_service'; +import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; + +import { getIndexPatterns } from '../util/index_utils'; +import { getBoundsRoundedToInterval } from '../util/ml_time_buckets'; + +import { APP_STATE_ACTION, CHARTS_POINT_TARGET, TIME_FIELD_NAME } from './timeseriesexplorer_constants'; +import { mlTimeSeriesSearchService } from './timeseries_search_service'; +import { + calculateAggregationInterval, + calculateDefaultFocusRange, + calculateInitialFocusRange, + createTimeSeriesJobData, + getAutoZoomDuration, + getFocusData, + processForecastResults, + processMetricPlotResults, + processRecordScoreResults, +} from './timeseriesexplorer_utils'; + +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); + +// Used to indicate the chart is being plotted across +// all partition field values, where the cardinality of the field cannot be +// obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values' +const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', { + defaultMessage: 'all', +}); + +function getTimeseriesexplorerDefaultState() { + return { + chartDetails: undefined, + contextChartData: undefined, + contextForecastData: undefined, + // Not chartable if e.g. model plot with terms for a varp detector + dataNotChartable: false, + detectorId: undefined, + detectors: [], + entities: [], + focusAnnotationData: [], + focusChartData: undefined, + focusForecastData: undefined, + hasResults: false, + jobs: [], + // Counter to keep track of what data sets have been loaded. + loadCounter: 0, + loading: false, + modelPlotEnabled: false, + selectedJob: undefined, + // Toggles display of annotations in the focus chart + showAnnotations: mlAnnotationsEnabled, + showAnnotationsCheckbox: mlAnnotationsEnabled, + // Toggles display of forecast data in the focus chart + showForecast: true, + showForecastCheckbox: false, + showModelBoundsCheckbox: false, + svgWidth: 0, + tableData: undefined, + zoomFrom: undefined, + zoomTo: undefined, + + // Toggles display of model bounds in the focus chart + showModelBounds: true, + }; +} + +const TimeSeriesExplorerPage = ({ children, jobSelectorProps, resizeRef }) => ( + + + +
+ {children} +
+
+); + +const containerPadding = 24; + +export class TimeSeriesExplorer extends React.Component { + static propTypes = { + appStateHandler: PropTypes.func.isRequired, + dateFormatTz: PropTypes.string.isRequired, + globalState: PropTypes.object.isRequired, + timefilter: PropTypes.object.isRequired, + }; + + state = getTimeseriesexplorerDefaultState(); + + subscriptions = new Subscription(); + + constructor(props) { + super(props); + const { jobSelectService, unsubscribeFromGlobalState } = jobSelectServiceFactory(props.globalState); + this.jobSelectService = jobSelectService; + this.unsubscribeFromGlobalState = unsubscribeFromGlobalState; + } + + resizeRef = createRef(); + resizeChecker = undefined; + resizeHandler = () => { + this.setState({ + svgWidth: (this.resizeRef.current !== null) ? this.resizeRef.current.offsetWidth - containerPadding : 0, + }); + } + + detectorIndexChangeHandler = (e) => { + const id = e.target.value; + if (id !== undefined) { + this.setState({ detectorId: id }); + } + this.updateControlsForDetector(); + this.loadEntityValues(); + }; + + toggleShowAnnotationsHandler = () => { + if (mlAnnotationsEnabled) { + this.setState(prevState => ({ + showAnnotations: !prevState.showAnnotations + })); + } + } + + toggleShowForecastHandler = () => { + this.setState(prevState => ({ + showForecast: !prevState.showForecast + })); + }; + + toggleShowModelBoundsHandler = () => { + this.setState({ + showModelBounds: !this.state.showModelBounds, + }); + } + + previousChartProps = {}; + previousShowAnnotations = undefined; + previousShowForecast = undefined; + previousShowModelBounds = undefined; + + tableFilter = (field, value, operator) => { + const { entities } = this.state; + + const entity = find(entities, { fieldName: field }); + if (entity !== undefined) { + if (operator === '+' && entity.fieldValue !== value) { + entity.fieldValue = value; + this.saveSeriesPropertiesAndRefresh(); + } else if (operator === '-' && entity.fieldValue === value) { + entity.fieldValue = ''; + this.saveSeriesPropertiesAndRefresh(); + } + } + } + + contextChartSelectedInitCallDone = false; + contextChartSelected = (selection) => { + const { appStateHandler } = this.props; + + const { + autoZoomDuration, + contextAggregationInterval, + contextChartData, + contextForecastData, + focusChartData, + jobs, + selectedJob, + zoomFrom, + zoomTo, + } = this.state; + + + if ((contextChartData === undefined || contextChartData.length === 0) && + (contextForecastData === undefined || contextForecastData.length === 0)) { + return; + } + + const stateUpdate = {}; + + const defaultRange = calculateDefaultFocusRange( + autoZoomDuration, + contextAggregationInterval, + contextChartData, + contextForecastData, + ); + + if ((selection.from.getTime() !== defaultRange[0].getTime() || selection.to.getTime() !== defaultRange[1].getTime()) && + (isNaN(Date.parse(selection.from)) === false && isNaN(Date.parse(selection.to)) === false)) { + const zoomState = { from: selection.from.toISOString(), to: selection.to.toISOString() }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + } else { + appStateHandler(APP_STATE_ACTION.UNSET_ZOOM); + } + + if ( + (this.contextChartSelectedInitCallDone === false && focusChartData === undefined) || + (zoomFrom.getTime() !== selection.from.getTime()) || + (zoomTo.getTime() !== selection.to.getTime()) + ) { + this.contextChartSelectedInitCallDone = true; + + // Calculate the aggregation interval for the focus chart. + const bounds = { min: moment(selection.from), max: moment(selection.to) }; + const focusAggregationInterval = calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + jobs, + selectedJob, + ); + stateUpdate.focusAggregationInterval = focusAggregationInterval; + + // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. + // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); + + const { + criteriaFields, + detectorId, + entities, + modelPlotEnabled, + } = this.state; + + getFocusData( + criteriaFields, + +detectorId, + focusAggregationInterval, + appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID), + modelPlotEnabled, + filter(entities, entity => entity.fieldValue.length > 0), + searchBounds, + selectedJob, + TIME_FIELD_NAME, + ).then((refreshFocusData) => { + // All the data is ready now for a state update. + this.setState({ + ...stateUpdate, + ...refreshFocusData, + loading: false, + showModelBoundsCheckbox: (modelPlotEnabled === true) && (refreshFocusData.focusChartData.length > 0), + }); + }); + + // Load the data for the anomalies table. + this.loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf()); + + this.setState({ + zoomFrom: selection.from, + zoomTo: selection.to, + }); + } + } + + entityFieldValueChanged = (entity, fieldValue) => { + this.setState(prevState => ({ + entities: prevState.entities.map(stateEntity => { + if (stateEntity.fieldName === entity.fieldName) { + stateEntity.fieldValue = fieldValue; + } + return stateEntity; + }) + })); + }; + + loadAnomaliesTableData = (earliestMs, latestMs) => { + const { dateFormatTz } = this.props; + const { criteriaFields, selectedJob } = this.state; + + ml.results.getAnomaliesTableData( + [selectedJob.job_id], + criteriaFields, + [], + interval$.getValue().val, + severity$.getValue().val, + earliestMs, + latestMs, + dateFormatTz, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ).then((resp) => { + const anomalies = resp.anomalies; + const detectorsByJob = mlJobService.detectorsByJob; + anomalies.forEach((anomaly) => { + // Add a detector property to each anomaly. + // Default to functionDescription if no description available. + // TODO - when job_service is moved server_side, move this to server endpoint. + const jobId = anomaly.jobId; + const detector = get(detectorsByJob, [jobId, anomaly.detectorIndex]); + anomaly.detector = get(detector, + ['detector_description'], + anomaly.source.function_description); + + // For detectors with rules, add a property with the rule count. + const customRules = detector.custom_rules; + if (customRules !== undefined) { + anomaly.rulesLength = customRules.length; + } + + // Add properties used for building the links menu. + // TODO - when job_service is moved server_side, move this to server endpoint. + if (has(mlJobService.customUrlsByJob, jobId)) { + anomaly.customUrls = mlJobService.customUrlsByJob[jobId]; + } + }); + + this.setState({ + tableData: { + anomalies, + interval: resp.interval, + examplesByJobId: resp.examplesByJobId, + showViewSeriesLink: false + } + }); + }).catch((resp) => { + console.log('Time series explorer - error loading data for anomalies table:', resp); + }); + } + + loadEntityValues = () => { + const { timefilter } = this.props; + const { detectorId, entities, selectedJob } = this.state; + + // Populate the entity input datalists with the values from the top records by score + // for the selected detector across the full time range. No need to pass through finish(). + const bounds = timefilter.getActiveBounds(); + const detectorIndex = +detectorId; + + mlResultsService.getRecordsForCriteria( + [selectedJob.job_id], + [{ 'fieldName': 'detector_index', 'fieldValue': detectorIndex }], + 0, + bounds.min.valueOf(), + bounds.max.valueOf(), + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE) + .then((resp) => { + if (resp.records && resp.records.length > 0) { + const firstRec = resp.records[0]; + + this.setState({ + entities: entities.map((entity) => { + if (firstRec.partition_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('partition_field_value').uniq().value(); + } + if (firstRec.over_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('over_field_value').uniq().value(); + } + if (firstRec.by_field_name === entity.fieldName) { + entity.fieldValues = chain(resp.records).pluck('by_field_value').uniq().value(); + } + return entity; + }) + }); + } + }); + } + + loadForForecastId = (forecastId) => { + const { appStateHandler, timefilter } = this.props; + const { autoZoomDuration, contextChartData, selectedJob } = this.state; + + mlForecastService.getForecastDateRange( + selectedJob, + forecastId + ).then((resp) => { + const bounds = timefilter.getActiveBounds(); + const earliest = moment(resp.earliest || timefilter.getTime().from); + const latest = moment(resp.latest || timefilter.getTime().to); + + // Store forecast ID in the appState. + appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); + + // Set the zoom to centre on the start of the forecast range, depending + // on the time range of the forecast and data. + const earliestDataDate = first(contextChartData).date; + const zoomLatestMs = Math.min(earliest + (autoZoomDuration / 2), latest.valueOf()); + const zoomEarliestMs = Math.max(zoomLatestMs - autoZoomDuration, earliestDataDate.getTime()); + + const zoomState = { + from: moment(zoomEarliestMs).toISOString(), + to: moment(zoomLatestMs).toISOString() + }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + + // Ensure the forecast data will be shown if hidden previously. + this.setState({ showForecast: true }); + + if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { + const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); + const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); + + timefilter.setTime({ + from: moment(earliestMs).toISOString(), + to: moment(latestMs).toISOString() + }); + } else { + // Refresh to show the requested forecast data. + this.refresh(); + } + }).catch((resp) => { + console.log('Time series explorer - error loading time range of forecast from elasticsearch:', resp); + }); + } + + refresh = () => { + const { appStateHandler, timefilter } = this.props; + const { + detectorId: currentDetectorId, + entities: currentEntities, + loadCounter: currentLoadCounter, + selectedJob: currentSelectedJob, + } = this.state; + + if (currentSelectedJob === undefined) { + return; + } + + this.contextChartSelectedInitCallDone = false; + + this.setState({ + chartDetails: undefined, + contextChartData: undefined, + contextForecastData: undefined, + focusChartData: undefined, + focusForecastData: undefined, + loadCounter: currentLoadCounter + 1, + loading: true, + modelPlotEnabled: isModelPlotEnabled(currentSelectedJob, +currentDetectorId, currentEntities), + hasResults: false, + dataNotChartable: false + }, () => { + const { detectorId, entities, loadCounter, jobs, modelPlotEnabled, selectedJob } = this.state; + const detectorIndex = +detectorId; + + let awaitingCount = 3; + + const stateUpdate = {}; + + // finish() function, called after each data set has been loaded and processed. + // The last one to call it will trigger the page render. + const finish = (counterVar) => { + awaitingCount--; + if (awaitingCount === 0 && (counterVar === loadCounter)) { + stateUpdate.hasResults = ( + (Array.isArray(stateUpdate.contextChartData) && stateUpdate.contextChartData.length > 0) || + (Array.isArray(stateUpdate.contextForecastData) && stateUpdate.contextForecastData.length > 0) + ); + stateUpdate.loading = false; + // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically + // selecting the specified range in the context chart, and so loading that date range in the focus chart. + if (stateUpdate.contextChartData.length) { + // Calculate the 'auto' zoom duration which shows data at bucket span granularity. + stateUpdate.autoZoomDuration = getAutoZoomDuration(jobs, selectedJob); + + // Check for a zoom parameter in the appState (URL). + let focusRange = calculateInitialFocusRange( + appStateHandler(APP_STATE_ACTION.GET_ZOOM), + stateUpdate.contextAggregationInterval, + timefilter + ); + + if (focusRange === undefined) { + focusRange = calculateDefaultFocusRange( + stateUpdate.autoZoomDuration, + stateUpdate.contextAggregationInterval, + stateUpdate.contextChartData, + stateUpdate.contextForecastData, + ); + } + + stateUpdate.zoomFrom = focusRange[0]; + stateUpdate.zoomTo = focusRange[1]; + } + + this.setState(stateUpdate); + } + }; + + // Only filter on the entity if the field has a value. + const nonBlankEntities = filter(currentEntities, (entity) => { return entity.fieldValue.length > 0; }); + stateUpdate.criteriaFields = [{ + 'fieldName': 'detector_index', + 'fieldValue': +currentDetectorId } + ].concat(nonBlankEntities); + + if (modelPlotEnabled === false && + isSourceDataChartableForDetector(selectedJob, detectorIndex) === false && + nonBlankEntities.length > 0) { + // For detectors where model plot has been enabled with a terms filter and the + // selected entity(s) are not in the terms list, indicate that data cannot be viewed. + stateUpdate.hasResults = false; + stateUpdate.loading = false; + stateUpdate.dataNotChartable = true; + this.setState(stateUpdate); + return; + } + + const bounds = timefilter.getActiveBounds(); + + // Calculate the aggregation interval for the context chart. + // Context chart swimlane will display bucket anomaly score at the same interval. + stateUpdate.contextAggregationInterval = calculateAggregationInterval( + bounds, + CHARTS_POINT_TARGET, + jobs, + selectedJob, + ); + + // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. + // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected + // to some extent with all detector functions if not searching complete buckets. + const searchBounds = getBoundsRoundedToInterval(bounds, stateUpdate.contextAggregationInterval, false); + + // Query 1 - load metric data at low granularity across full time range. + // Pass a counter flag into the finish() function to make sure we only process the results + // for the most recent call to the load the data in cases where the job selection and time filter + // have been altered in quick succession (such as from the job picker with 'Apply time range'). + const counter = loadCounter; + mlTimeSeriesSearchService.getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression + ).then((resp) => { + const fullRangeChartData = processMetricPlotResults(resp.results, modelPlotEnabled); + stateUpdate.contextChartData = fullRangeChartData; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting metric data from elasticsearch:', resp); + }); + + // Query 2 - load max record score at same granularity as context chart + // across full time range for use in the swimlane. + mlResultsService.getRecordMaxScoreByTime( + selectedJob.job_id, + stateUpdate.criteriaFields, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression + ).then((resp) => { + const fullRangeRecordScoreData = processRecordScoreResults(resp.results); + stateUpdate.swimlaneData = fullRangeRecordScoreData; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting bucket anomaly scores from elasticsearch:', resp); + }); + + // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). + mlTimeSeriesSearchService.getChartDetails( + selectedJob, + detectorIndex, + entities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf() + ).then((resp) => { + stateUpdate.chartDetails = resp.results; + finish(counter); + }).catch((resp) => { + console.log('Time series explorer - error getting entity counts from elasticsearch:', resp); + }); + + // Plus query for forecast data if there is a forecastId stored in the appState. + const forecastId = appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID); + if (forecastId !== undefined) { + awaitingCount++; + let aggType = undefined; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + const esAgg = mlFunctionToESAggregation(detector.function); + if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { + aggType = { avg: 'sum', max: 'sum', min: 'sum' }; + } + mlForecastService.getForecastData( + selectedJob, + detectorIndex, + forecastId, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + stateUpdate.contextAggregationInterval.expression, + aggType) + .then((resp) => { + stateUpdate.contextForecastData = processForecastResults(resp.results); + finish(counter); + }).catch((resp) => { + console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); + }); + } + + this.loadEntityValues(); + }); + } + + updateControlsForDetector = () => { + const { appStateHandler } = this.props; + const { detectorId, selectedJob } = this.state; + // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. + const detectorIndex = +detectorId; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + + const entities = []; + const entitiesState = appStateHandler(APP_STATE_ACTION.GET_ENTITIES); + const partitionFieldName = get(detector, 'partition_field_name'); + const overFieldName = get(detector, 'over_field_name'); + const byFieldName = get(detector, 'by_field_name'); + if (partitionFieldName !== undefined) { + const partitionFieldValue = get(entitiesState, partitionFieldName, ''); + entities.push({ fieldName: partitionFieldName, fieldValue: partitionFieldValue }); + } + if (overFieldName !== undefined) { + const overFieldValue = get(entitiesState, overFieldName, ''); + entities.push({ fieldName: overFieldName, fieldValue: overFieldValue }); + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + // TODO - metric data can be filtered by this field, so should only exclude + // from filter for the anomaly records. + if (byFieldName !== undefined && overFieldName === undefined) { + const byFieldValue = get(entitiesState, byFieldName, ''); + entities.push({ fieldName: byFieldName, fieldValue: byFieldValue }); + } + + this.setState({ entities }); + } + + loadForJobId(jobId, jobs) { + const { appStateHandler } = this.props; + + // Validation that the ID is for a time series job must already have been performed. + // Check if the job was created since the page was first loaded. + let jobPickerSelectedJob = find(jobs, { 'id': jobId }); + if (jobPickerSelectedJob === undefined) { + const newJobs = []; + each(mlJobService.jobs, (job) => { + if (isTimeSeriesViewJob(job) === true) { + const bucketSpan = parseInterval(job.analysis_config.bucket_span); + newJobs.push({ id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }); + } + }); + this.setState({ jobs: newJobs }); + jobPickerSelectedJob = find(newJobs, { 'id': jobId }); + } + + const selectedJob = mlJobService.getJob(jobId); + + // Read the detector index and entities out of the AppState. + const jobDetectors = selectedJob.analysis_config.detectors; + const viewableDetectors = []; + each(jobDetectors, (dtr, index) => { + if (isTimeSeriesViewDetector(selectedJob, index)) { + viewableDetectors.push({ index: '' + index, detector_description: dtr.detector_description }); + } + }); + const detectors = viewableDetectors; + + // Check the supplied index is valid. + const appStateDtrIdx = appStateHandler(APP_STATE_ACTION.GET_DETECTOR_INDEX); + let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +(viewableDetectors[0].index); + if (find(viewableDetectors, { 'index': '' + detectorIndex }) === undefined) { + const warningText = i18n.translate('xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { + defaultMessage: 'Requested detector index {detectorIndex} is not valid for job {jobId}', + values: { + detectorIndex, + jobId: selectedJob.job_id + } + }); + toastNotifications.addWarning(warningText); + detectorIndex = +(viewableDetectors[0].index); + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorIndex); + } + + // Store the detector index as a string so it can be used as ng-model in a select control. + const detectorId = '' + detectorIndex; + + this.setState( + { detectorId, detectors, selectedJob }, + () => { + this.updateControlsForDetector(); + + // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. + mlFieldFormatService.populateFormats([jobId], getIndexPatterns()) + .catch((err) => { console.log('Error populating field formats:', err); }) + // Load the data - if the FieldFormats failed to populate + // the default formatting will be used for metric values. + .then(() => { + this.refresh(); + }); + } + ); + } + + saveSeriesPropertiesAndRefresh = () => { + const { appStateHandler } = this.props; + const { detectorId, entities } = this.state; + + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +detectorId); + appStateHandler(APP_STATE_ACTION.SET_ENTITIES, entities.reduce((appStateEntities, entity) => { + appStateEntities[entity.fieldName] = entity.fieldValue; + return appStateEntities; + }, {})); + + this.refresh(); + } + + componentDidMount() { + const { appStateHandler, globalState, timefilter } = this.props; + + this.setState({ jobs: [] }); + + // Get the job info needed by the visualization, then do the first load. + if (mlJobService.jobs.length > 0) { + const jobs = createTimeSeriesJobData(mlJobService.jobs); + this.setState({ jobs }); + } else { + this.setState({ loading: false }); + } + + // Reload the anomalies table if the Interval or Threshold controls are changed. + const tableControlsListener = () => { + const { zoomFrom, zoomTo } = this.state; + if (zoomFrom !== undefined && zoomTo !== undefined) { + this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()); + } + }; + + this.subscriptions.add(annotationsRefresh$.subscribe(this.refresh)); + this.subscriptions.add(interval$.subscribe(tableControlsListener)); + this.subscriptions.add(severity$.subscribe(tableControlsListener)); + this.subscriptions.add(mlTimefilterRefresh$.subscribe(this.refresh)); + + // Listen for changes to job selection. + this.subscriptions.add(this.jobSelectService.subscribe(({ selection: selectedJobIds }) => { + const jobs = createTimeSeriesJobData(mlJobService.jobs); + + this.contextChartSelectedInitCallDone = false; + this.setState({ showForecastCheckbox: false }); + + const timeSeriesJobIds = jobs.map(j => j.id); + + // Check if any of the jobs set in the URL are not time series jobs + // (e.g. if switching to this view straight from the Anomaly Explorer). + const invalidIds = difference(selectedJobIds, timeSeriesJobIds); + selectedJobIds = without(selectedJobIds, ...invalidIds); + if (invalidIds.length > 0) { + let warningText = i18n.translate('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', { + defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, + values: { + invalidIdsCount: invalidIds.length, + invalidIds + } + }); + if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { + warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { + defaultMessage: ', auto selecting first job' + }); + } + toastNotifications.addWarning(warningText); + } + + if (selectedJobIds.length > 1) { + // if more than one job or a group has been loaded from the URL + if (selectedJobIds.length > 1) { + // if more than one job, select the first job from the selection. + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard' + }) + ); + + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else { + // if a group has been loaded + if (selectedJobIds.length > 0) { + // if the group contains valid jobs, select the first + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard' + }) + ); + + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else if (jobs.length > 0) { + // if there are no valid jobs in the group but there are valid jobs + // in the list of all jobs, select the first + setGlobalState(globalState, { selectedIds: [jobs[0].id] }); + this.jobSelectService.next({ selection: [jobs[0].id], resetSelection: true }); + } else { + // if there are no valid jobs left. + this.setState({ loading: false }); + } + } + } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { + // if some ids have been filtered out because they were invalid. + // refresh the URL with the first valid id + setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); + this.jobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); + } else if (selectedJobIds.length > 0) { + // normal behavior. a job ID has been loaded from the URL + if (this.state.selectedJob !== undefined && selectedJobIds[0] !== this.state.selectedJob.job_id) { + // Clear the detectorIndex, entities and forecast info. + appStateHandler(APP_STATE_ACTION.CLEAR); + } + this.loadForJobId(selectedJobIds[0], jobs); + } else { + if (selectedJobIds.length === 0 && jobs.length > 0) { + // no jobs were loaded from the URL, so add the first job + // from the full jobs list. + setGlobalState(globalState, { selectedIds: [jobs[0].id] }); + this.jobSelectService.next({ selection: [jobs[0].id], resetSelection: true }); + } else { + // Jobs exist, but no time series jobs. + this.setState({ loading: false }); + } + } + })); + + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + timefilter.on('timeUpdate', this.refresh); + + // Required to redraw the time series chart when the container is resized. + this.resizeChecker = new ResizeChecker(this.resizeRef.current); + this.resizeChecker.on('resize', () => { + this.resizeHandler(); + }); + this.resizeHandler(); + } + + componentWillUnmount() { + this.subscriptions.unsubscribe(); + this.props.timefilter.off('timeUpdate', this.refresh); + this.resizeChecker.destroy(); + this.unsubscribeFromGlobalState(); + } + + render() { + const { + dateFormatTz, + globalState, + timefilter, + } = this.props; + + const { + autoZoomDuration, + chartDetails, + contextAggregationInterval, + contextChartData, + contextForecastData, + dataNotChartable, + detectors, + detectorId, + entities, + focusAggregationInterval, + focusAnnotationData, + focusChartData, + focusForecastData, + hasResults, + jobs, + loading, + modelPlotEnabled, + selectedJob, + showAnnotations, + showAnnotationsCheckbox, + showForecast, + showForecastCheckbox, + showModelBounds, + showModelBoundsCheckbox, + svgWidth, + swimlaneData, + tableData, + zoomFrom, + zoomTo, + } = this.state; + + const chartProps = { + modelPlotEnabled, + contextChartData, + contextChartSelected: this.contextChartSelected, + contextForecastData, + contextAggregationInterval, + swimlaneData, + focusAnnotationData, + focusChartData, + focusForecastData, + focusAggregationInterval, + svgWidth, + zoomFrom, + zoomTo, + autoZoomDuration, + }; + + const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); + const jobSelectorProps = { + dateFormatTz, + globalState, + jobSelectService: this.jobSelectService, + selectedJobIds, + selectedGroups, + singleSelection: true, + timeseriesOnly: true, + }; + + if (jobs.length === 0) { + return ( + + + + ); + } + + const detectorSelectOptions = detectors.map(d => ({ + value: d.index, + text: d.detector_description + })); + + let renderFocusChartOnly = true; + + if ( + isEqual(this.previousChartProps.focusForecastData, chartProps.focusForecastData) && + isEqual(this.previousChartProps.focusChartData, chartProps.focusChartData) && + isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && + this.previousShowAnnotations === showAnnotations && + this.previousShowForecast === showForecast && + this.previousShowModelBounds === showModelBounds + ) { + renderFocusChartOnly = false; + } + + this.previousChartProps = chartProps; + this.previousShowAnnotations = showAnnotations; + this.previousShowForecast = showForecast; + this.previousShowModelBounds = showModelBounds; + + return ( + +
+ + + + + + + {entities.map((entity) => { + const entityKey = `${entity.fieldName}`; + return ( + + ); + })} + + + + {i18n.translate('xpack.ml.timeSeriesExplorer.refreshButtonAriaLabel', { + defaultMessage: 'Refresh' + })} + + + + + + + + + +
+ + {(loading === true) && ( + + )} + + {(jobs.length > 0 && loading === false && hasResults === false) && ( + + )} + + {(jobs.length > 0 && loading === false && hasResults === true) && ( + + + {i18n.translate('xpack.ml.timeSeriesExplorer.singleTimeSeriesAnalysisTitle', { + defaultMessage: 'Single time series analysis of {functionLabel}', + values: { functionLabel: chartDetails.functionLabel } + })} +   + + {chartDetails.entityData.count === 1 && ( + + {chartDetails.entityData.entities.length > 0 && '('} + {chartDetails.entityData.entities.map((entity) => { + return `${entity.fieldName}: ${entity.fieldValue}`; + }).join(', ')} + {chartDetails.entityData.entities.length > 0 && ')'} + + )} + + {chartDetails.entityData.count !== 1 && ( + + {chartDetails.entityData.entities.map((countData, i) => { + return ( + + {i18n.translate('xpack.ml.timeSeriesExplorer.countDataInChartDetailsDescription', { + defaultMessage: + '{openBrace}{cardinalityValue} distinct {fieldName} {cardinality, plural, one {} other { values}}{closeBrace}', + values: { + openBrace: (i === 0) ? '(' : '', + closeBrace: (i === (chartDetails.entityData.entities.length - 1)) ? ')' : '', + cardinalityValue: countData.cardinality === 0 ? allValuesLabel : countData.cardinality, + cardinality: countData.cardinality, + fieldName: countData.fieldName + } + })} + {(i !== (chartDetails.entityData.entities.length - 1)) ? ', ' : ''} + + ); + })} + + )} + + + {showModelBoundsCheckbox && ( + + + + )} + + {showAnnotationsCheckbox && ( + + + + )} + + {showForecastCheckbox && ( + + + + )} + + +
+ +
+ + {showAnnotations && focusAnnotationData.length > 0 && ( +
+ + {i18n.translate('xpack.ml.timeSeriesExplorer.annotationsTitle', { + defaultMessage: 'Annotations' + })} + + + +
+ )} + + + + + {i18n.translate('xpack.ml.timeSeriesExplorer.anomaliesTitle', { + defaultMessage: 'Anomalies' + })} + + + + + + + + + + + + + + + + + + + +
+ )} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js new file mode 100644 index 0000000000000..52590bb6824c1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_constants.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Contains values for ML time series explorer. + */ + + +export const APP_STATE_ACTION = { + CLEAR: 'CLEAR', + GET_DETECTOR_INDEX: 'GET_DETECTOR_INDEX', + SET_DETECTOR_INDEX: 'SET_DETECTOR_INDEX', + GET_ENTITIES: 'GET_ENTITIES', + SET_ENTITIES: 'SET_ENTITIES', + GET_FORECAST_ID: 'GET_FORECAST_ID', + SET_FORECAST_ID: 'SET_FORECAST_ID', + GET_ZOOM: 'GET_ZOOM', + SET_ZOOM: 'SET_ZOOM', + UNSET_ZOOM: 'UNSET_ZOOM', +}; + +export const CHARTS_POINT_TARGET = 500; + +// Max number of scheduled events displayed per bucket. +export const MAX_SCHEDULED_EVENTS = 10; + +export const TIME_FIELD_NAME = 'timestamp'; diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js deleted file mode 100644 index 1043936350b63..0000000000000 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_controller.js +++ /dev/null @@ -1,1061 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - - - -/* - * Angular controller for the Machine Learning Single Metric Viewer dashboard, which - * allows the user to explore a single time series. The controller makes multiple queries - * to Elasticsearch to obtain the data to populate all the components in the view. - */ - -import _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import moment from 'moment-timezone'; - -import 'plugins/ml/components/annotations/annotation_flyout/annotation_flyout_directive'; -import 'plugins/ml/components/annotations/annotations_table'; -import 'plugins/ml/components/anomalies_table'; -import 'plugins/ml/components/controls'; - -import { toastNotifications } from 'ui/notify'; -import uiRoutes from 'ui/routes'; -import { timefilter } from 'ui/timefilter'; -import { parseInterval } from 'ui/utils/parse_interval'; -import { checkFullLicense } from 'plugins/ml/license/check_license'; -import { checkGetJobsPrivilege, checkPermission } from 'plugins/ml/privilege/check_privilege'; -import { - isTimeSeriesViewJob, - isTimeSeriesViewDetector, - isModelPlotEnabled, - isSourceDataChartableForDetector, - mlFunctionToESAggregation } from 'plugins/ml/../common/util/job_utils'; -import { loadIndexPatterns, getIndexPatterns } from 'plugins/ml/util/index_utils'; -import { getSingleMetricViewerBreadcrumbs } from './breadcrumbs'; -import { - createTimeSeriesJobData, - processForecastResults, - processDataForFocusAnomalies, - processMetricPlotResults, - processRecordScoreResults, - processScheduledEventsForChart } from 'plugins/ml/timeseriesexplorer/timeseriesexplorer_utils'; -import { refreshIntervalWatcher } from 'plugins/ml/util/refresh_interval_watcher'; -import { MlTimeBuckets, getBoundsRoundedToInterval } from 'plugins/ml/util/ml_time_buckets'; -import { mlResultsService } from 'plugins/ml/services/results_service'; -import template from './timeseriesexplorer.html'; -import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes'; -import { ml } from 'plugins/ml/services/ml_api_service'; -import { mlJobService } from 'plugins/ml/services/job_service'; -import { mlFieldFormatService } from 'plugins/ml/services/field_format_service'; -import { mlForecastService } from 'plugins/ml/services/forecast_service'; -import { mlTimeSeriesSearchService } from 'plugins/ml/timeseriesexplorer/timeseries_search_service'; -import { - ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE -} from '../../common/constants/search'; -import { annotationsRefresh$ } from '../services/annotations_service'; -import { interval$ } from '../components/controls/select_interval/select_interval'; -import { severity$ } from '../components/controls/select_severity/select_severity'; -import { setGlobalState, getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; - - -import chrome from 'ui/chrome'; -let mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); - -uiRoutes - .when('/timeseriesexplorer/?', { - template, - k7Breadcrumbs: getSingleMetricViewerBreadcrumbs, - resolve: { - CheckLicense: checkFullLicense, - privileges: checkGetJobsPrivilege, - indexPatterns: loadIndexPatterns, - mlNodeCount: getMlNodeCount, - jobs: mlJobService.loadJobsWrapper - } - }); - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -module.controller('MlTimeSeriesExplorerController', function ( - $injector, - $scope, - $timeout, - Private, - AppState, - config, - globalState) { - - $injector.get('mlSelectIntervalService'); - $injector.get('mlSelectSeverityService'); - const mlJobSelectService = $injector.get('mlJobSelectService'); - - $scope.timeFieldName = 'timestamp'; - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - const CHARTS_POINT_TARGET = 500; - const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. - - $scope.jobPickerSelections = []; - $scope.selectedJob; - $scope.detectors = []; - $scope.loading = true; - $scope.loadCounter = 0; - $scope.hasResults = false; - $scope.dataNotChartable = false; // e.g. model plot with terms for a varp detector - $scope.anomalyRecords = []; - - $scope.modelPlotEnabled = false; - $scope.showModelBounds = true; // Toggles display of model bounds in the focus chart - $scope.showModelBoundsCheckbox = false; - $scope.showAnnotations = mlAnnotationsEnabled;// Toggles display of annotations in the focus chart - $scope.showAnnotationsCheckbox = mlAnnotationsEnabled; - $scope.showForecast = true; // Toggles display of forecast data in the focus chart - $scope.showForecastCheckbox = false; - - $scope.focusAnnotationData = []; - - // Used in the template to indicate the chart is being plotted across - // all partition field values, where the cardinality of the field cannot be - // obtained as it is not aggregatable e.g. 'all distinct kpi_indicator values' - $scope.allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionValuesLabel', { - defaultMessage: 'all', - }); - - // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. - const tzConfig = config.get('dateFormat:tz'); - const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); - - $scope.permissions = { - canForecastJob: checkPermission('canForecastJob') - }; - - $scope.initializeVis = function () { - // Initialize the AppState in which to store the zoom range. - const stateDefaults = { - mlTimeSeriesExplorer: {} - }; - $scope.appState = new AppState(stateDefaults); - - $scope.jobs = []; - - // Get the job info needed by the visualization, then do the first load. - if (mlJobService.jobs.length > 0) { - $scope.jobs = createTimeSeriesJobData(mlJobService.jobs); - const timeSeriesJobIds = $scope.jobs.map(j => j.id); - - // Select any jobs set in the global state (i.e. passed in the URL). - let { jobIds: selectedJobIds } = getSelectedJobIds(globalState); - - // Check if any of the jobs set in the URL are not time series jobs - // (e.g. if switching to this view straight from the Anomaly Explorer). - const invalidIds = _.difference(selectedJobIds, timeSeriesJobIds); - selectedJobIds = _.without(selectedJobIds, ...invalidIds); - if (invalidIds.length > 0) { - let warningText = i18n.translate('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', { - defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, - values: { - invalidIdsCount: invalidIds.length, - invalidIds - } - }); - if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { - warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { - defaultMessage: ', auto selecting first job' - }); - } - toastNotifications.addWarning(warningText); - } - - if (selectedJobIds.length > 1) { - // if more than one job or a group has been loaded from the URL - if (selectedJobIds.length > 1) { - // if more than one job, select the first job from the selection. - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard' - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else { - // if a group has been loaded - if (selectedJobIds.length > 0) { - // if the group contains valid jobs, select the first - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard' - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if ($scope.jobs.length > 0) { - // if there are no valid jobs in the group but there are valid jobs - // in the list of all jobs, select the first - setGlobalState(globalState, { selectedIds: [$scope.jobs[0].id] }); - mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true }); - } else { - // if there are no valid jobs left. - $scope.loading = false; - } - } - } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { - // if some ids have been filtered out because they were invalid. - // refresh the URL with the first valid id - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (selectedJobIds.length > 0) { - // normal behavior. a job ID has been loaded from the URL - loadForJobId(selectedJobIds[0]); - } else { - if (selectedJobIds.length === 0 && $scope.jobs.length > 0) { - // no jobs were loaded from the URL, so add the first job - // from the full jobs list. - setGlobalState(globalState, { selectedIds: [$scope.jobs[0].id] }); - mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true }); - } else { - // Jobs exist, but no time series jobs. - $scope.loading = false; - } - } - } else { - $scope.loading = false; - } - - $scope.$applyAsync(); - }; - - $scope.refresh = function () { - - if ($scope.selectedJob === undefined) { - return; - } - - $scope.loading = true; - $scope.hasResults = false; - $scope.dataNotChartable = false; - delete $scope.chartDetails; - delete $scope.contextChartData; - delete $scope.focusChartData; - delete $scope.contextForecastData; - delete $scope.focusForecastData; - - // Counter to keep track of what data sets have been loaded. - $scope.loadCounter++; - let awaitingCount = 3; - - // finish() function, called after each data set has been loaded and processed. - // The last one to call it will trigger the page render. - function finish(counterVar) { - awaitingCount--; - if (awaitingCount === 0 && (counterVar === $scope.loadCounter)) { - - if (($scope.contextChartData && $scope.contextChartData.length) || - ($scope.contextForecastData && $scope.contextForecastData.length)) { - $scope.hasResults = true; - } else { - $scope.hasResults = false; - } - $scope.loading = false; - - // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically - // selecting the specified range in the context chart, and so loading that date range in the focus chart. - if ($scope.contextChartData.length) { - const focusRange = calculateInitialFocusRange(); - $scope.zoomFrom = focusRange[0]; - $scope.zoomTo = focusRange[1]; - } - - // Tell the results container directives to render. - // Need to use $timeout to ensure the broadcast happens after the child scope is updated with the new data. - if (($scope.contextChartData && $scope.contextChartData.length) || - ($scope.contextForecastData && $scope.contextForecastData.length)) { - $timeout(() => { - $scope.$broadcast('render'); - }, 0); - } else { - // Call $applyAsync() if for any reason the upper condition doesn't trigger the $timeout. - // We still want to trigger a scope update about the changes above the condition. - $scope.$applyAsync(); - } - - } - } - - const bounds = timefilter.getActiveBounds(); - - const detectorIndex = +$scope.detectorId; - $scope.modelPlotEnabled = isModelPlotEnabled($scope.selectedJob, detectorIndex, $scope.entities); - - - // Only filter on the entity if the field has a value. - const nonBlankEntities = _.filter($scope.entities, (entity) => { return entity.fieldValue.length > 0; }); - $scope.criteriaFields = [{ - 'fieldName': 'detector_index', - 'fieldValue': detectorIndex } - ].concat(nonBlankEntities); - - if ($scope.modelPlotEnabled === false && - isSourceDataChartableForDetector($scope.selectedJob, detectorIndex) === false && - nonBlankEntities.length > 0) { - // For detectors where model plot has been enabled with a terms filter and the - // selected entity(s) are not in the terms list, indicate that data cannot be viewed. - $scope.hasResults = false; - $scope.loading = false; - $scope.dataNotChartable = true; - $scope.$applyAsync(); - return; - } - - // Calculate the aggregation interval for the context chart. - // Context chart swimlane will display bucket anomaly score at the same interval. - $scope.contextAggregationInterval = calculateAggregationInterval(bounds, CHARTS_POINT_TARGET, CHARTS_POINT_TARGET); - console.log('aggregationInterval for context data (s):', $scope.contextAggregationInterval.asSeconds()); - - // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. - // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected - // to some extent with all detector functions if not searching complete buckets. - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.contextAggregationInterval, false); - - // Query 1 - load metric data at low granularity across full time range. - // Pass a counter flag into the finish() function to make sure we only process the results - // for the most recent call to the load the data in cases where the job selection and time filter - // have been altered in quick succession (such as from the job picker with 'Apply time range'). - const counter = $scope.loadCounter; - mlTimeSeriesSearchService.getMetricData( - $scope.selectedJob, - detectorIndex, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression - ).then((resp) => { - const fullRangeChartData = processMetricPlotResults(resp.results, $scope.modelPlotEnabled); - $scope.contextChartData = fullRangeChartData; - console.log('Time series explorer context chart data set:', $scope.contextChartData); - - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting metric data from elasticsearch:', resp); - }); - - // Query 2 - load max record score at same granularity as context chart - // across full time range for use in the swimlane. - mlResultsService.getRecordMaxScoreByTime( - $scope.selectedJob.job_id, - $scope.criteriaFields, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression - ).then((resp) => { - const fullRangeRecordScoreData = processRecordScoreResults(resp.results); - $scope.swimlaneData = fullRangeRecordScoreData; - console.log('Time series explorer swimlane anomalies data set:', $scope.swimlaneData); - - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting bucket anomaly scores from elasticsearch:', resp); - }); - - // Query 3 - load details on the chart used in the chart title (charting function and entity(s)). - mlTimeSeriesSearchService.getChartDetails( - $scope.selectedJob, - detectorIndex, - $scope.entities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf() - ).then((resp) => { - $scope.chartDetails = resp.results; - finish(counter); - }).catch((resp) => { - console.log('Time series explorer - error getting entity counts from elasticsearch:', resp); - }); - - // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = _.get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); - if (forecastId !== undefined) { - awaitingCount++; - let aggType = undefined; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - const esAgg = mlFunctionToESAggregation(detector.function); - if ($scope.modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { - aggType = { avg: 'sum', max: 'sum', min: 'sum' }; - } - mlForecastService.getForecastData( - $scope.selectedJob, - detectorIndex, - forecastId, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.contextAggregationInterval.expression, - aggType) - .then((resp) => { - $scope.contextForecastData = processForecastResults(resp.results); - finish(counter); - }).catch((resp) => { - console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); - }); - } - - loadEntityValues(); - }; - - $scope.refreshFocusData = function (fromDate, toDate) { - - // Counter to keep track of the queries to populate the chart. - let awaitingCount = 4; - - // This object is used to store the results of individual remote requests - // before we transform it into the final data and apply it to $scope. Otherwise - // we might trigger multiple $digest cycles and depending on how deep $watches - // listen for changes we could miss updates. - const refreshFocusData = {}; - - // finish() function, called after each data set has been loaded and processed. - // The last one to call it will trigger the page render. - function finish() { - awaitingCount--; - if (awaitingCount === 0) { - // Tell the results container directives to render the focus chart. - refreshFocusData.focusChartData = processDataForFocusAnomalies( - refreshFocusData.focusChartData, - refreshFocusData.anomalyRecords, - $scope.timeFieldName, - $scope.focusAggregationInterval, - $scope.modelPlotEnabled); - - refreshFocusData.focusChartData = processScheduledEventsForChart( - refreshFocusData.focusChartData, - refreshFocusData.scheduledEvents); - - // All the data is ready now for a scope update. - // Use $evalAsync to ensure the update happens after the child scope is updated with the new data. - $scope.$evalAsync(() => { - $scope = Object.assign($scope, refreshFocusData); - console.log('Time series explorer focus chart data set:', $scope.focusChartData); - - $scope.loading = false; - - // If the annotations failed to load and the feature flag is set to `false`, - // make sure the checkbox toggle gets hidden. - if (mlAnnotationsEnabled === false) { - $scope.showAnnotationsCheckbox = false; - } - }); - } - } - - const detectorIndex = +$scope.detectorId; - const nonBlankEntities = _.filter($scope.entities, entity => entity.fieldValue.length > 0); - - // Calculate the aggregation interval for the focus chart. - const bounds = { min: moment(fromDate), max: moment(toDate) }; - $scope.focusAggregationInterval = calculateAggregationInterval(bounds, CHARTS_POINT_TARGET, CHARTS_POINT_TARGET); - - // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. - // For sum or count detectors, short buckets would hold smaller values, and model bounds would also be affected - // to some extent with all detector functions if not searching complete buckets. - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.focusAggregationInterval, false); - - // Query 1 - load metric data across selected time range. - mlTimeSeriesSearchService.getMetricData( - $scope.selectedJob, - detectorIndex, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression - ).then((resp) => { - refreshFocusData.focusChartData = processMetricPlotResults(resp.results, $scope.modelPlotEnabled); - $scope.showModelBoundsCheckbox = ($scope.modelPlotEnabled === true) && - (refreshFocusData.focusChartData.length > 0); - finish(); - }).catch((resp) => { - console.log('Time series explorer - error getting metric data from elasticsearch:', resp); - }); - - // Query 2 - load all the records across selected time range for the chart anomaly markers. - mlResultsService.getRecordsForCriteria( - [$scope.selectedJob.job_id], - $scope.criteriaFields, - 0, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE - ).then((resp) => { - // Sort in descending time order before storing in scope. - refreshFocusData.anomalyRecords = _.chain(resp.records) - .sortBy(record => record[$scope.timeFieldName]) - .reverse() - .value(); - console.log('Time series explorer anomalies:', refreshFocusData.anomalyRecords); - finish(); - }); - - // Query 3 - load any scheduled events for the selected job. - mlResultsService.getScheduledEventsByBucket( - [$scope.selectedJob.job_id], - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression, - 1, - MAX_SCHEDULED_EVENTS - ).then((resp) => { - refreshFocusData.scheduledEvents = resp.events[$scope.selectedJob.job_id]; - finish(); - }).catch((resp) => { - console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp); - }); - - // Query 4 - load any annotations for the selected job. - if (mlAnnotationsEnabled) { - ml.annotations.getAnnotations({ - jobIds: [$scope.selectedJob.job_id], - earliestMs: searchBounds.min.valueOf(), - latestMs: searchBounds.max.valueOf(), - maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE - }).then((resp) => { - refreshFocusData.focusAnnotationData = []; - - if (Array.isArray(resp.annotations[$scope.selectedJob.job_id])) { - refreshFocusData.focusAnnotationData = resp.annotations[$scope.selectedJob.job_id] - .sort((a, b) => { - return a.timestamp - b.timestamp; - }) - .map((d, i) => { - d.key = String.fromCharCode(65 + i); - return d; - }); - } - - finish(); - }).catch(() => { - // silently fail and disable annotations feature if loading annotations fails. - refreshFocusData.focusAnnotationData = []; - mlAnnotationsEnabled = false; - finish(); - }); - } else { - finish(); - } - - // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = _.get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); - if (forecastId !== undefined) { - awaitingCount++; - let aggType = undefined; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - const esAgg = mlFunctionToESAggregation(detector.function); - if ($scope.modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { - aggType = { avg: 'sum', max: 'sum', min: 'sum' }; - } - - mlForecastService.getForecastData( - $scope.selectedJob, - detectorIndex, - forecastId, - nonBlankEntities, - searchBounds.min.valueOf(), - searchBounds.max.valueOf(), - $scope.focusAggregationInterval.expression, - aggType) - .then((resp) => { - refreshFocusData.focusForecastData = processForecastResults(resp.results); - refreshFocusData.showForecastCheckbox = (refreshFocusData.focusForecastData.length > 0); - finish(); - }).catch((resp) => { - console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); - }); - } - - // Load the data for the anomalies table. - loadAnomaliesTableData(searchBounds.min.valueOf(), searchBounds.max.valueOf()); - - }; - - $scope.saveSeriesPropertiesAndRefresh = function () { - $scope.appState.mlTimeSeriesExplorer.detectorIndex = +$scope.detectorId; - $scope.appState.mlTimeSeriesExplorer.entities = {}; - _.each($scope.entities, (entity) => { - $scope.appState.mlTimeSeriesExplorer.entities[entity.fieldName] = entity.fieldValue; - }); - $scope.appState.save(); - - $scope.refresh(); - }; - - $scope.filter = function (field, value, operator) { - const entity = _.find($scope.entities, { fieldName: field }); - if (entity !== undefined) { - if (operator === '+' && entity.fieldValue !== value) { - entity.fieldValue = value; - $scope.saveSeriesPropertiesAndRefresh(); - } else if (operator === '-' && entity.fieldValue === value) { - entity.fieldValue = ''; - $scope.saveSeriesPropertiesAndRefresh(); - } - } - }; - - $scope.loadForForecastId = function (forecastId) { - mlForecastService.getForecastDateRange( - $scope.selectedJob, - forecastId - ).then((resp) => { - const bounds = timefilter.getActiveBounds(); - const earliest = moment(resp.earliest || timefilter.getTime().from); - const latest = moment(resp.latest || timefilter.getTime().to); - - // Store forecast ID in the appState. - $scope.appState.mlTimeSeriesExplorer.forecastId = forecastId; - - // Set the zoom to centre on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestDataDate = _.first($scope.contextChartData).date; - const zoomLatestMs = Math.min(earliest + ($scope.autoZoomDuration / 2), latest.valueOf()); - const zoomEarliestMs = Math.max(zoomLatestMs - $scope.autoZoomDuration, earliestDataDate.getTime()); - - const zoomState = { - from: moment(zoomEarliestMs).toISOString(), - to: moment(zoomLatestMs).toISOString() - }; - $scope.appState.mlTimeSeriesExplorer.zoom = zoomState; - - $scope.appState.save(); - - // Ensure the forecast data will be shown if hidden previously. - $scope.showForecast = true; - - - if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { - const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); - const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); - - timefilter.setTime({ - from: moment(earliestMs).toISOString(), - to: moment(latestMs).toISOString() - }); - } else { - // Refresh to show the requested forecast data. - $scope.refresh(); - } - - }).catch((resp) => { - console.log('Time series explorer - error loading time range of forecast from elasticsearch:', resp); - }); - }; - - $scope.detectorIndexChanged = function () { - updateControlsForDetector(); - loadEntityValues(); - }; - - $scope.toggleShowModelBounds = function () { - $timeout(() => { - $scope.showModelBounds = !$scope.showModelBounds; - }, 0); - }; - - if (mlAnnotationsEnabled) { - $scope.toggleShowAnnotations = function () { - $timeout(() => { - $scope.showAnnotations = !$scope.showAnnotations; - }, 0); - }; - } - - $scope.toggleShowForecast = function () { - $timeout(() => { - $scope.showForecast = !$scope.showForecast; - }, 0); - }; - - // Refresh the data when the time range is altered. - $scope.$listenAndDigestAsync(timefilter, 'fetch', $scope.refresh); - - // Add a watcher for auto-refresh of the time filter to refresh all the data. - const refreshWatcher = Private(refreshIntervalWatcher); - refreshWatcher.init(() => { - $scope.refresh(); - }); - - // Reload the anomalies table if the Interval or Threshold controls are changed. - const tableControlsListener = function () { - if ($scope.zoomFrom !== undefined && $scope.zoomTo !== undefined) { - loadAnomaliesTableData($scope.zoomFrom.getTime(), $scope.zoomTo.getTime()); - } - }; - - const intervalSub = interval$.subscribe(tableControlsListener); - const severitySub = severity$.subscribe(tableControlsListener); - const annotationsRefreshSub = annotationsRefresh$.subscribe($scope.refresh); - // Listen for changes to job selection. - const jobSelectServiceSub = mlJobSelectService.subscribe(({ selection }) => { - // Clear the detectorIndex, entities and forecast info. - if (selection.length > 0 && $scope.appState !== undefined) { - delete $scope.appState.mlTimeSeriesExplorer.detectorIndex; - delete $scope.appState.mlTimeSeriesExplorer.entities; - delete $scope.appState.mlTimeSeriesExplorer.forecastId; - $scope.appState.save(); - - $scope.showForecastCheckbox = false; - loadForJobId(selection[0]); - } - }); - - const timefilterRefreshServiceSub = mlTimefilterRefresh$.subscribe($scope.refresh); - - $scope.$on('$destroy', () => { - refreshWatcher.cancel(); - intervalSub.unsubscribe(); - severitySub.unsubscribe(); - annotationsRefreshSub.unsubscribe(); - jobSelectServiceSub.unsubscribe(); - timefilterRefreshServiceSub.unsubscribe(); - }); - - $scope.$on('contextChartSelected', function (event, selection) { - // Save state of zoom (adds to URL) if it is different to the default. - if (($scope.contextChartData === undefined || $scope.contextChartData.length === 0) && - ($scope.contextForecastData === undefined || $scope.contextForecastData.length === 0)) { - return; - } - - const defaultRange = calculateDefaultFocusRange(); - - if ((selection.from.getTime() !== defaultRange[0].getTime() || selection.to.getTime() !== defaultRange[1].getTime()) && - (isNaN(Date.parse(selection.from)) === false && isNaN(Date.parse(selection.to)) === false)) { - const zoomState = { from: selection.from.toISOString(), to: selection.to.toISOString() }; - $scope.appState.mlTimeSeriesExplorer.zoom = zoomState; - } else { - delete $scope.appState.mlTimeSeriesExplorer.zoom; - } - $scope.appState.save(); - - if ($scope.focusChartData === undefined || - ($scope.zoomFrom.getTime() !== selection.from.getTime()) || - ($scope.zoomTo.getTime() !== selection.to.getTime())) { - $scope.refreshFocusData(selection.from, selection.to); - } - - $scope.zoomFrom = selection.from; - $scope.zoomTo = selection.to; - - }); - - function loadForJobId(jobId) { - // Validation that the ID is for a time series job must already have been performed. - // Check if the job was created since the page was first loaded. - let jobPickerSelectedJob = _.find($scope.jobs, { 'id': jobId }); - if (jobPickerSelectedJob === undefined) { - const newJobs = []; - _.each(mlJobService.jobs, (job) => { - if (isTimeSeriesViewJob(job) === true) { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - newJobs.push({ id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }); - } - }); - $scope.jobs = newJobs; - jobPickerSelectedJob = _.find(newJobs, { 'id': jobId }); - } - - $scope.selectedJob = mlJobService.getJob(jobId); - $scope.jobPickerSelections = [jobPickerSelectedJob]; - - // Read the detector index and entities out of the AppState. - const jobDetectors = $scope.selectedJob.analysis_config.detectors; - const viewableDetectors = []; - _.each(jobDetectors, (dtr, index) => { - if (isTimeSeriesViewDetector($scope.selectedJob, index)) { - viewableDetectors.push({ index: '' + index, detector_description: dtr.detector_description }); - } - }); - $scope.detectors = viewableDetectors; - - // Check the supplied index is valid. - const appStateDtrIdx = $scope.appState.mlTimeSeriesExplorer.detectorIndex; - let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +(viewableDetectors[0].index); - if (_.find(viewableDetectors, { 'index': '' + detectorIndex }) === undefined) { - const warningText = i18n.translate('xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { - defaultMessage: 'Requested detector index {detectorIndex} is not valid for job {jobId}', - values: { - detectorIndex, - jobId: $scope.selectedJob.job_id - } - }); - toastNotifications.addWarning(warningText); - detectorIndex = +(viewableDetectors[0].index); - $scope.appState.mlTimeSeriesExplorer.detectorIndex = detectorIndex; - $scope.appState.save(); - } - - // Store the detector index as a string so it can be used as ng-model in a select control. - $scope.detectorId = '' + detectorIndex; - - updateControlsForDetector(); - - // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. - mlFieldFormatService.populateFormats([jobId], getIndexPatterns()) - .catch((err) => { console.log('Error populating field formats:', err); }) - // Load the data - if the FieldFormats failed to populate - // the default formatting will be used for metric values. - .then(() => { - $scope.refresh(); - }); - } - - function loadAnomaliesTableData(earliestMs, latestMs) { - - ml.results.getAnomaliesTableData( - [$scope.selectedJob.job_id], - $scope.criteriaFields, - [], - interval$.getValue().val, - severity$.getValue().val, - earliestMs, - latestMs, - dateFormatTz, - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE - ).then((resp) => { - const anomalies = resp.anomalies; - const detectorsByJob = mlJobService.detectorsByJob; - anomalies.forEach((anomaly) => { - // Add a detector property to each anomaly. - // Default to functionDescription if no description available. - // TODO - when job_service is moved server_side, move this to server endpoint. - const jobId = anomaly.jobId; - const detector = _.get(detectorsByJob, [jobId, anomaly.detectorIndex]); - anomaly.detector = _.get(detector, - ['detector_description'], - anomaly.source.function_description); - - // For detectors with rules, add a property with the rule count. - const customRules = detector.custom_rules; - if (customRules !== undefined) { - anomaly.rulesLength = customRules.length; - } - - // Add properties used for building the links menu. - // TODO - when job_service is moved server_side, move this to server endpoint. - if (_.has(mlJobService.customUrlsByJob, jobId)) { - anomaly.customUrls = mlJobService.customUrlsByJob[jobId]; - } - }); - - $scope.$evalAsync(() => { - $scope.tableData = { - anomalies, - interval: resp.interval, - examplesByJobId: resp.examplesByJobId, - showViewSeriesLink: false - }; - }); - - }).catch((resp) => { - console.log('Time series explorer - error loading data for anomalies table:', resp); - }); - } - - function updateControlsForDetector() { - // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. - const detectorIndex = +$scope.detectorId; - const detector = $scope.selectedJob.analysis_config.detectors[detectorIndex]; - - const entities = []; - const entitiesState = $scope.appState.mlTimeSeriesExplorer.entities || {}; - const partitionFieldName = _.get(detector, 'partition_field_name'); - const overFieldName = _.get(detector, 'over_field_name'); - const byFieldName = _.get(detector, 'by_field_name'); - if (partitionFieldName !== undefined) { - const partitionFieldValue = _.get(entitiesState, partitionFieldName, ''); - entities.push({ fieldName: partitionFieldName, fieldValue: partitionFieldValue }); - } - if (overFieldName !== undefined) { - const overFieldValue = _.get(entitiesState, overFieldName, ''); - entities.push({ fieldName: overFieldName, fieldValue: overFieldValue }); - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - // TODO - metric data can be filtered by this field, so should only exclude - // from filter for the anomaly records. - if (byFieldName !== undefined && overFieldName === undefined) { - const byFieldValue = _.get(entitiesState, byFieldName, ''); - entities.push({ fieldName: byFieldName, fieldValue: byFieldValue }); - } - - $scope.entities = entities; - } - - function loadEntityValues() { - // Populate the entity input datalists with the values from the top records by score - // for the selected detector across the full time range. No need to pass through finish(). - const bounds = timefilter.getActiveBounds(); - const detectorIndex = +$scope.detectorId; - - mlResultsService.getRecordsForCriteria( - [$scope.selectedJob.job_id], - [{ 'fieldName': 'detector_index', 'fieldValue': detectorIndex }], - 0, - bounds.min.valueOf(), - bounds.max.valueOf(), - ANOMALIES_TABLE_DEFAULT_QUERY_SIZE) - .then((resp) => { - if (resp.records && resp.records.length > 0) { - const firstRec = resp.records[0]; - - _.each($scope.entities, (entity) => { - if (firstRec.partition_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('partition_field_value').uniq().value(); - } - if (firstRec.over_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('over_field_value').uniq().value(); - } - if (firstRec.by_field_name === entity.fieldName) { - entity.fieldValues = _.chain(resp.records).pluck('by_field_value').uniq().value(); - } - }); - $scope.$applyAsync(); - } - - }); - } - - function calculateInitialFocusRange() { - // Check for a zoom parameter in the appState (URL). - const zoomState = $scope.appState.mlTimeSeriesExplorer.zoom; - if (zoomState !== undefined) { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - $scope.autoZoomDuration = getAutoZoomDuration(); - - // Check that the zoom times are valid. - // zoomFrom must be at or after context chart search bounds earliest, - // zoomTo must be at or before context chart search bounds latest. - const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const bounds = timefilter.getActiveBounds(); - const searchBounds = getBoundsRoundedToInterval(bounds, $scope.contextAggregationInterval, true); - const earliest = searchBounds.min; - const latest = searchBounds.max; - - if (zoomFrom.isValid() && zoomTo.isValid && - zoomTo.isAfter(zoomFrom) && - zoomFrom.isBetween(earliest, latest, null, '[]') && - zoomTo.isBetween(earliest, latest, null, '[]')) { - return [zoomFrom.toDate(), zoomTo.toDate()]; - } - } - - return calculateDefaultFocusRange(); - } - - function calculateDefaultFocusRange() { - - $scope.autoZoomDuration = getAutoZoomDuration(); - const isForecastData = $scope.contextForecastData !== undefined && $scope.contextForecastData.length > 0; - - const combinedData = (isForecastData === false) ? - $scope.contextChartData : $scope.contextChartData.concat($scope.contextForecastData); - const earliestDataDate = _.first(combinedData).date; - const latestDataDate = _.last(combinedData).date; - - let rangeEarliestMs; - let rangeLatestMs; - - if (isForecastData === true) { - // Return a range centred on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestForecastDataDate = _.first($scope.contextForecastData).date; - const latestForecastDataDate = _.last($scope.contextForecastData).date; - - rangeLatestMs = Math.min(earliestForecastDataDate.getTime() + ($scope.autoZoomDuration / 2), latestForecastDataDate.getTime()); - rangeEarliestMs = Math.max(rangeLatestMs - $scope.autoZoomDuration, earliestDataDate.getTime()); - } else { - // Returns the range that shows the most recent data at bucket span granularity. - rangeLatestMs = latestDataDate.getTime() + $scope.contextAggregationInterval.asMilliseconds(); - rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - $scope.autoZoomDuration); - } - - return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; - - } - - function calculateAggregationInterval(bounds, bucketsTarget) { - // Aggregation interval used in queries should be a function of the time span of the chart - // and the bucket span of the selected job(s). - const barTarget = (bucketsTarget !== undefined ? bucketsTarget : 100); - // Use a maxBars of 10% greater than the target. - const maxBars = Math.floor(1.1 * barTarget); - const buckets = new MlTimeBuckets(); - buckets.setInterval('auto'); - buckets.setBounds(bounds); - buckets.setBarTarget(Math.floor(barTarget)); - buckets.setMaxBars(maxBars); - - // Ensure the aggregation interval is always a multiple of the bucket span to avoid strange - // behaviour such as adjacent chart buckets holding different numbers of job results. - const bucketSpanSeconds = _.find($scope.jobs, { 'id': $scope.selectedJob.job_id }).bucketSpanSeconds; - let aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); - - // Set the interval back to the job bucket span if the auto interval is smaller. - const secs = aggInterval.asSeconds(); - if (secs < bucketSpanSeconds) { - buckets.setInterval(bucketSpanSeconds + 's'); - aggInterval = buckets.getInterval(); - } - - console.log('calculateAggregationInterval() barTarget,maxBars,returning:', bucketsTarget, maxBars, - (bounds.max.diff(bounds.min)) / aggInterval.asMilliseconds()); - - return aggInterval; - } - - function getAutoZoomDuration() { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - // Get the minimum bucket span of selected jobs. - // TODO - only look at jobs for which data has been returned? - const bucketSpanSeconds = _.find($scope.jobs, { 'id': $scope.selectedJob.job_id }).bucketSpanSeconds; - - // In most cases the duration can be obtained by simply multiplying the points target - // Check that this duration returns the bucket span when run back through the - // TimeBucket interval calculation. - let autoZoomDuration = (bucketSpanSeconds * 1000) * (CHARTS_POINT_TARGET - 1); - - // Use a maxBars of 10% greater than the target. - const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET); - const buckets = new MlTimeBuckets(); - buckets.setInterval('auto'); - buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET)); - buckets.setMaxBars(maxBars); - - // Set bounds from 'now' for testing the auto zoom duration. - const nowMs = new Date().getTime(); - const max = moment(nowMs); - const min = moment(nowMs - autoZoomDuration); - buckets.setBounds({ min, max }); - - const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); - const calculatedIntervalSecs = calculatedInterval.asSeconds(); - if (calculatedIntervalSecs !== bucketSpanSeconds) { - // If we haven't got the span back, which may occur depending on the 'auto' ranges - // used in TimeBuckets and the bucket span of the job, then multiply by the ratio - // of the bucket span to the calculated interval. - autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs); - } - - return autoZoomDuration; - - } - - $scope.initializeVis(); -}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js new file mode 100644 index 0000000000000..048a8dbc4a6db --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_directive.js @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import moment from 'moment-timezone'; +import { Subscription } from 'rxjs'; + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { uiModules } from 'ui/modules'; +const module = uiModules.get('apps/ml'); + +import { timefilter } from 'ui/timefilter'; +import { I18nContext } from 'ui/i18n'; + +import '../components/controls'; + +import { severity$ } from '../components/controls/select_severity/select_severity'; +import { interval$ } from '../components/controls/select_interval/select_interval'; +import { subscribeAppStateToObservable } from '../util/app_state_utils'; + +import { TimeSeriesExplorer } from './timeseriesexplorer'; +import { APP_STATE_ACTION } from './timeseriesexplorer_constants'; + +module.directive('mlTimeSeriesExplorer', function ($injector) { + function link($scope, $element) { + const globalState = $injector.get('globalState'); + const AppState = $injector.get('AppState'); + const config = $injector.get('config'); + + const subscriptions = new Subscription(); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$)); + subscriptions.add(subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$)); + + $scope.appState = new AppState({ mlTimeSeriesExplorer: {} }); + + const appStateHandler = (action, payload) => { + $scope.appState.fetch(); + switch (action) { + case APP_STATE_ACTION.CLEAR: + delete $scope.appState.mlTimeSeriesExplorer.detectorIndex; + delete $scope.appState.mlTimeSeriesExplorer.entities; + delete $scope.appState.mlTimeSeriesExplorer.forecastId; + break; + + case APP_STATE_ACTION.GET_DETECTOR_INDEX: + return get($scope, 'appState.mlTimeSeriesExplorer.detectorIndex'); + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + $scope.appState.mlTimeSeriesExplorer.detectorIndex = payload; + break; + + case APP_STATE_ACTION.GET_ENTITIES: + return get($scope, 'appState.mlTimeSeriesExplorer.entities', {}); + case APP_STATE_ACTION.SET_ENTITIES: + $scope.appState.mlTimeSeriesExplorer.entities = payload; + break; + + case APP_STATE_ACTION.GET_FORECAST_ID: + return get($scope, 'appState.mlTimeSeriesExplorer.forecastId'); + case APP_STATE_ACTION.SET_FORECAST_ID: + $scope.appState.mlTimeSeriesExplorer.forecastId = payload; + break; + + case APP_STATE_ACTION.GET_ZOOM: + return get($scope, 'appState.mlTimeSeriesExplorer.zoom'); + case APP_STATE_ACTION.SET_ZOOM: + $scope.appState.mlTimeSeriesExplorer.zoom = payload; + break; + case APP_STATE_ACTION.UNSET_ZOOM: + delete $scope.appState.mlTimeSeriesExplorer.zoom; + break; + } + $scope.appState.save(); + $scope.$applyAsync(); + }; + + function updateComponent() { + // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. + const tzConfig = config.get('dateFormat:tz'); + const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess(); + + ReactDOM.render( + + + , + $element[0] + ); + } + + $element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode($element[0]); + subscriptions.unsubscribe(); + }); + + updateComponent(); + } + + return { + link, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js new file mode 100644 index 0000000000000..c07dfad29f85d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_route.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uiRoutes from 'ui/routes'; + +import '../components/controls'; + +import { checkFullLicense } from '../license/check_license'; +import { getMlNodeCount } from '../ml_nodes_check/check_ml_nodes'; +import { checkGetJobsPrivilege } from '../privilege/check_privilege'; +import { mlJobService } from '../services/job_service'; +import { loadIndexPatterns } from '../util/index_utils'; + +import { getSingleMetricViewerBreadcrumbs } from './breadcrumbs'; + +uiRoutes + .when('/timeseriesexplorer/?', { + template: '', + k7Breadcrumbs: getSingleMetricViewerBreadcrumbs, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + indexPatterns: loadIndexPatterns, + mlNodeCount: getMlNodeCount, + jobs: mlJobService.loadJobsWrapper + } + }); diff --git a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js index 33048cb0f9d09..7a50b52c191a3 100644 --- a/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/timeseriesexplorer/timeseriesexplorer_utils.js @@ -13,9 +13,34 @@ */ import _ from 'lodash'; +import moment from 'moment-timezone'; import { parseInterval } from 'ui/utils/parse_interval'; -import { isTimeSeriesViewJob } from '../../common/util/job_utils'; + +import { + ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE +} from '../../common/constants/search'; +import { + isTimeSeriesViewJob, + mlFunctionToESAggregation, +} from '../../common/util/job_utils'; + +import { ml } from '../services/ml_api_service'; +import { mlForecastService } from '../services/forecast_service'; +import { mlResultsService } from '../services/results_service'; +import { MlTimeBuckets, getBoundsRoundedToInterval } from '../util/ml_time_buckets'; + +import { mlTimeSeriesSearchService } from './timeseries_search_service'; + +import { + CHARTS_POINT_TARGET, + MAX_SCHEDULED_EVENTS, + TIME_FIELD_NAME, +} from './timeseriesexplorer_constants'; + +import chrome from 'ui/chrome'; +const mlAnnotationsEnabled = chrome.getInjected('mlAnnotationsEnabled', false); // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. @@ -97,7 +122,6 @@ export function processRecordScoreResults(scoreData) { export function processDataForFocusAnomalies( chartData, anomalyRecords, - timeFieldName, aggregationInterval, modelPlotEnabled) { @@ -110,7 +134,7 @@ export function processDataForFocusAnomalies( lastChartDataPointTime = chartData[chartData.length - 1].date.getTime(); } anomalyRecords.forEach((record) => { - const recordTime = record[timeFieldName]; + const recordTime = record[TIME_FIELD_NAME]; const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); if (chartPoint === undefined) { const timeToAdd = (Math.floor(recordTime / intervalMs)) * intervalMs; @@ -141,7 +165,7 @@ export function processDataForFocusAnomalies( // Look for a chart point with the same time as the record. // If none found, find closest time in chartData set. - const recordTime = record[timeFieldName]; + const recordTime = record[TIME_FIELD_NAME]; const chartPoint = findChartPointForAnomalyTime(chartData, recordTime, aggregationInterval); if (chartPoint !== undefined) { // If chart aggregation interval > bucket span, there may be more than @@ -277,3 +301,275 @@ export function findChartPointForAnomalyTime(chartData, anomalyTime, aggregation return chartPoint; } + +export const getFocusData = function ( + criteriaFields, + detectorIndex, + focusAggregationInterval, + forecastId, + modelPlotEnabled, + nonBlankEntities, + searchBounds, + selectedJob, +) { + return new Promise((resolve, reject) => { + // Counter to keep track of the queries to populate the chart. + let awaitingCount = 4; + + // This object is used to store the results of individual remote requests + // before we transform it into the final data and apply it to $scope. Otherwise + // we might trigger multiple $digest cycles and depending on how deep $watches + // listen for changes we could miss updates. + const refreshFocusData = {}; + + // finish() function, called after each data set has been loaded and processed. + // The last one to call it will trigger the page render. + function finish() { + awaitingCount--; + if (awaitingCount === 0) { + // Tell the results container directives to render the focus chart. + refreshFocusData.focusChartData = processDataForFocusAnomalies( + refreshFocusData.focusChartData, + refreshFocusData.anomalyRecords, + focusAggregationInterval, + modelPlotEnabled, + ); + + refreshFocusData.focusChartData = processScheduledEventsForChart( + refreshFocusData.focusChartData, + refreshFocusData.scheduledEvents); + + resolve(refreshFocusData); + } + } + + // Query 1 - load metric data across selected time range. + mlTimeSeriesSearchService.getMetricData( + selectedJob, + detectorIndex, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression + ).then((resp) => { + refreshFocusData.focusChartData = processMetricPlotResults(resp.results, modelPlotEnabled); + finish(); + }).catch((resp) => { + console.log('Time series explorer - error getting metric data from elasticsearch:', resp); + reject(); + }); + + // Query 2 - load all the records across selected time range for the chart anomaly markers. + mlResultsService.getRecordsForCriteria( + [selectedJob.job_id], + criteriaFields, + 0, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + ANOMALIES_TABLE_DEFAULT_QUERY_SIZE + ).then((resp) => { + // Sort in descending time order before storing in scope. + refreshFocusData.anomalyRecords = _.chain(resp.records) + .sortBy(record => record[TIME_FIELD_NAME]) + .reverse() + .value(); + finish(); + }); + + // Query 3 - load any scheduled events for the selected job. + mlResultsService.getScheduledEventsByBucket( + [selectedJob.job_id], + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression, + 1, + MAX_SCHEDULED_EVENTS + ).then((resp) => { + refreshFocusData.scheduledEvents = resp.events[selectedJob.job_id]; + finish(); + }).catch((resp) => { + console.log('Time series explorer - error getting scheduled events from elasticsearch:', resp); + reject(); + }); + + // Query 4 - load any annotations for the selected job. + if (mlAnnotationsEnabled) { + ml.annotations.getAnnotations({ + jobIds: [selectedJob.job_id], + earliestMs: searchBounds.min.valueOf(), + latestMs: searchBounds.max.valueOf(), + maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE + }).then((resp) => { + refreshFocusData.focusAnnotationData = resp.annotations[selectedJob.job_id] + .sort((a, b) => { + return a.timestamp - b.timestamp; + }) + .map((d, i) => { + d.key = String.fromCharCode(65 + i); + return d; + }); + + finish(); + }).catch(() => { + // silent fail + refreshFocusData.focusAnnotationData = []; + finish(); + }); + } else { + finish(); + } + + // Plus query for forecast data if there is a forecastId stored in the appState. + if (forecastId !== undefined) { + awaitingCount++; + let aggType = undefined; + const detector = selectedJob.analysis_config.detectors[detectorIndex]; + const esAgg = mlFunctionToESAggregation(detector.function); + if (modelPlotEnabled === false && (esAgg === 'sum' || esAgg === 'count')) { + aggType = { avg: 'sum', max: 'sum', min: 'sum' }; + } + + mlForecastService.getForecastData( + selectedJob, + detectorIndex, + forecastId, + nonBlankEntities, + searchBounds.min.valueOf(), + searchBounds.max.valueOf(), + focusAggregationInterval.expression, + aggType) + .then((resp) => { + refreshFocusData.focusForecastData = processForecastResults(resp.results); + refreshFocusData.showForecastCheckbox = (refreshFocusData.focusForecastData.length > 0); + finish(); + }).catch((resp) => { + console.log(`Time series explorer - error loading data for forecast ID ${forecastId}`, resp); + reject(); + }); + } + }); +}; + +export function calculateAggregationInterval( + bounds, + bucketsTarget, + jobs, + selectedJob, +) { + // Aggregation interval used in queries should be a function of the time span of the chart + // and the bucket span of the selected job(s). + const barTarget = (bucketsTarget !== undefined ? bucketsTarget : 100); + // Use a maxBars of 10% greater than the target. + const maxBars = Math.floor(1.1 * barTarget); + const buckets = new MlTimeBuckets(); + buckets.setInterval('auto'); + buckets.setBounds(bounds); + buckets.setBarTarget(Math.floor(barTarget)); + buckets.setMaxBars(maxBars); + + // Ensure the aggregation interval is always a multiple of the bucket span to avoid strange + // behaviour such as adjacent chart buckets holding different numbers of job results. + const bucketSpanSeconds = _.find(jobs, { 'id': selectedJob.job_id }).bucketSpanSeconds; + let aggInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); + + // Set the interval back to the job bucket span if the auto interval is smaller. + const secs = aggInterval.asSeconds(); + if (secs < bucketSpanSeconds) { + buckets.setInterval(bucketSpanSeconds + 's'); + aggInterval = buckets.getInterval(); + } + + return aggInterval; +} + +export function calculateDefaultFocusRange( + autoZoomDuration, + contextAggregationInterval, + contextChartData, + contextForecastData, +) { + const isForecastData = contextForecastData !== undefined && contextForecastData.length > 0; + + const combinedData = (isForecastData === false) ? + contextChartData : contextChartData.concat(contextForecastData); + const earliestDataDate = _.first(combinedData).date; + const latestDataDate = _.last(combinedData).date; + + let rangeEarliestMs; + let rangeLatestMs; + + if (isForecastData === true) { + // Return a range centred on the start of the forecast range, depending + // on the time range of the forecast and data. + const earliestForecastDataDate = _.first(contextForecastData).date; + const latestForecastDataDate = _.last(contextForecastData).date; + + rangeLatestMs = Math.min(earliestForecastDataDate.getTime() + (autoZoomDuration / 2), latestForecastDataDate.getTime()); + rangeEarliestMs = Math.max(rangeLatestMs - autoZoomDuration, earliestDataDate.getTime()); + } else { + // Returns the range that shows the most recent data at bucket span granularity. + rangeLatestMs = latestDataDate.getTime() + contextAggregationInterval.asMilliseconds(); + rangeEarliestMs = Math.max(earliestDataDate.getTime(), rangeLatestMs - autoZoomDuration); + } + + return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; +} + +export function calculateInitialFocusRange(zoomState, contextAggregationInterval, timefilter) { + if (zoomState !== undefined) { + // Check that the zoom times are valid. + // zoomFrom must be at or after context chart search bounds earliest, + // zoomTo must be at or before context chart search bounds latest. + const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); + const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); + const bounds = timefilter.getActiveBounds(); + const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, true); + const earliest = searchBounds.min; + const latest = searchBounds.max; + + if (zoomFrom.isValid() && zoomTo.isValid && + zoomTo.isAfter(zoomFrom) && + zoomFrom.isBetween(earliest, latest, null, '[]') && + zoomTo.isBetween(earliest, latest, null, '[]')) { + return [zoomFrom.toDate(), zoomTo.toDate()]; + } + } + + return undefined; +} + +export function getAutoZoomDuration(jobs, selectedJob) { + // Calculate the 'auto' zoom duration which shows data at bucket span granularity. + // Get the minimum bucket span of selected jobs. + // TODO - only look at jobs for which data has been returned? + const bucketSpanSeconds = _.find(jobs, { 'id': selectedJob.job_id }).bucketSpanSeconds; + + // In most cases the duration can be obtained by simply multiplying the points target + // Check that this duration returns the bucket span when run back through the + // TimeBucket interval calculation. + let autoZoomDuration = (bucketSpanSeconds * 1000) * (CHARTS_POINT_TARGET - 1); + + // Use a maxBars of 10% greater than the target. + const maxBars = Math.floor(1.1 * CHARTS_POINT_TARGET); + const buckets = new MlTimeBuckets(); + buckets.setInterval('auto'); + buckets.setBarTarget(Math.floor(CHARTS_POINT_TARGET)); + buckets.setMaxBars(maxBars); + + // Set bounds from 'now' for testing the auto zoom duration. + const nowMs = new Date().getTime(); + const max = moment(nowMs); + const min = moment(nowMs - autoZoomDuration); + buckets.setBounds({ min, max }); + + const calculatedInterval = buckets.getIntervalToNearestMultiple(bucketSpanSeconds); + const calculatedIntervalSecs = calculatedInterval.asSeconds(); + if (calculatedIntervalSecs !== bucketSpanSeconds) { + // If we haven't got the span back, which may occur depending on the 'auto' ranges + // used in TimeBuckets and the bucket span of the job, then multiply by the ratio + // of the bucket span to the calculated interval. + autoZoomDuration = autoZoomDuration * (bucketSpanSeconds / calculatedIntervalSecs); + } + + return autoZoomDuration; +} diff --git a/x-pack/legacy/plugins/ml/public/util/app_state_utils.js b/x-pack/legacy/plugins/ml/public/util/app_state_utils.js index b60370de67da7..f3dc7086f7c2a 100644 --- a/x-pack/legacy/plugins/ml/public/util/app_state_utils.js +++ b/x-pack/legacy/plugins/ml/public/util/app_state_utils.js @@ -50,12 +50,17 @@ export function initializeAppState(AppState, stateName, defaultState) { return appState; } +// Some components like the show-chart-checkbox or severity/interval-dropdowns +// emit their state change to an observable. This utility function can be used +// to persist these state changes to AppState and save the state to the url. +// distinctUntilChanged() makes sure the callback is only triggered upon changes +// of the state and filters consecutive triggers of the same value. export function subscribeAppStateToObservable(AppState, appStateName, o$, callback) { const appState = initializeAppState(AppState, appStateName, o$.getValue()); o$.next(appState[appStateName]); - o$.pipe(distinctUntilChanged()).subscribe(payload => { + const subscription = o$.pipe(distinctUntilChanged()).subscribe(payload => { appState.fetch(); appState[appStateName] = payload; appState.save(); @@ -63,4 +68,6 @@ export function subscribeAppStateToObservable(AppState, appStateName, o$, callba callback(payload); } }); + + return subscription; } diff --git a/x-pack/legacy/plugins/ml/public/util/context_utils.tsx b/x-pack/legacy/plugins/ml/public/util/context_utils.tsx deleted file mode 100644 index 88d340bd6aa6b..0000000000000 --- a/x-pack/legacy/plugins/ml/public/util/context_utils.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext } from 'react'; - -import { Chrome } from 'ui/chrome'; -import { Timefilter } from 'ui/timefilter'; -import { TimeHistory } from 'ui/timefilter/time_history'; - -export const ChromeContext = React.createContext({} as Chrome); -export const TimefilterContext = React.createContext({} as Timefilter); -export const TimeHistoryContext = React.createContext({} as TimeHistory); - -interface NavigationMenuContextValue { - chrome: Chrome; - timefilter: Timefilter; - timeHistory: TimeHistory; -} -export const NavigationMenuContext = React.createContext({ - chrome: {} as Chrome, - timefilter: {} as Timefilter, - timeHistory: {} as TimeHistory, -}); - -export const useNavigationMenuContext = () => { - return useContext(NavigationMenuContext); -}; - -// testing mocks -export const chromeMock = { - getBasePath: () => 'basePath', - getUiSettingsClient: () => { - return { - get: (key: string) => { - switch (key) { - case 'dateFormat': - case 'timepicker:timeDefaults': - return {}; - case 'timepicker:refreshIntervalDefaults': - return { pause: false, value: 0 }; - default: - throw new Error(`Unexpected config key: ${key}`); - } - }, - }; - }, -} as Chrome; - -export const timefilterMock = ({ - getRefreshInterval: () => '30s', - getTime: () => ({ from: 0, to: 0 }), - on: (event: string, reload: () => void) => {}, -} as unknown) as Timefilter; - -export const timeHistoryMock = ({ - get: () => [{ from: 0, to: 0 }], -} as unknown) as TimeHistory; - -export const navigationMenuMock = { - chrome: chromeMock, - timefilter: timefilterMock, - timeHistory: timeHistoryMock, -}; diff --git a/x-pack/legacy/plugins/ml/public/util/field_types_utils.d.ts b/x-pack/legacy/plugins/ml/public/util/field_types_utils.d.ts new file mode 100644 index 0000000000000..23ca2d1237df5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/util/field_types_utils.d.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function kbnTypeToMLJobType(field: any): any; diff --git a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.d.ts b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.d.ts new file mode 100644 index 0000000000000..81c150efa2d24 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.d.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Moment } from 'moment'; + +declare interface TimeFilterBounds { + min: Moment; + max: Moment; +} + +export class MlTimeBuckets { + setBarTarget: (barTarget: number) => void; + setMaxBars: (maxBars: number) => void; + setInterval: (interval: string) => void; + setBounds: (bounds: TimeFilterBounds) => void; + getBounds: () => { min: any; max: any }; + getInterval: () => { + asMilliseconds: () => number; + }; +} diff --git a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js index c79a28d2b449e..3af986e3ca5da 100644 --- a/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js +++ b/x-pack/legacy/plugins/ml/public/util/ml_time_buckets.js @@ -16,8 +16,8 @@ import moment from 'moment'; import dateMath from '@elastic/datemath'; import chrome from 'ui/chrome'; -import { timeBucketsCalcAutoIntervalProvider } from 'plugins/ml/util/ml_calc_auto_interval'; -import { inherits } from 'plugins/ml/util/inherits'; +import { timeBucketsCalcAutoIntervalProvider } from './ml_calc_auto_interval'; +import { inherits } from './inherits'; const unitsDesc = dateMath.unitsDesc; const largeMax = unitsDesc.indexOf('w'); // Multiple units of week or longer converted to days for ES intervals. @@ -183,4 +183,3 @@ export function calcEsInterval(duration) { expression: ms + 'ms' }; } - diff --git a/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js b/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js deleted file mode 100644 index 2ddcb15bf933b..0000000000000 --- a/x-pack/legacy/plugins/ml/public/util/refresh_interval_watcher.js +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { timefilter } from 'ui/timefilter'; - -/* - * Watches for changes to the refresh interval of the page time filter, - * so that listeners can be notified when the auto-refresh interval has elapsed. - */ - -export function refreshIntervalWatcher($timeout) { - - let refresher; - let listener; - - const onRefreshIntervalChange = () => { - if (refresher) { - $timeout.cancel(refresher); - } - const interval = timefilter.getRefreshInterval(); - if (interval.value > 0 && !interval.pause) { - function startRefresh() { - refresher = $timeout(() => { - startRefresh(); - listener(); - }, interval.value); - } - startRefresh(); - } - }; - - function init(listenerCallback) { - listener = listenerCallback; - timefilter.on('refreshIntervalUpdate', onRefreshIntervalChange); - } - - function cancel() { - $timeout.cancel(refresher); - timefilter.off('refreshIntervalUpdate', onRefreshIntervalChange); - } - - return { - init, - cancel - }; -} diff --git a/x-pack/legacy/plugins/ml/public/util/string_utils.d.ts b/x-pack/legacy/plugins/ml/public/util/string_utils.d.ts new file mode 100644 index 0000000000000..10b0cbbe9aeb0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/util/string_utils.d.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function escapeForElasticsearchQuery(str: string): string; diff --git a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js index 748ce81a748b0..f7f44fb9b3225 100644 --- a/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js +++ b/x-pack/legacy/plugins/ml/server/client/elasticsearch_ml.js @@ -737,4 +737,18 @@ export const elasticsearchJsPlugin = (Client, config, components) => { method: 'POST' }); + ml.rollupIndexCapabilities = ca({ + urls: [ + { + fmt: '/<%=indexPattern%>/_rollup/data', + req: { + indexPattern: { + type: 'string' + } + } + } + ], + method: 'GET' + }); + }; diff --git a/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts b/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts index 8ec092a803cee..5da4f6b62bcec 100644 --- a/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts +++ b/x-pack/legacy/plugins/ml/server/lib/ml_telemetry/index.ts @@ -4,5 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './ml_telemetry'; +export { + createMlTelemetry, + getSavedObjectsClient, + incrementFileDataVisualizerIndexCreationCount, + storeMlTelemetry, + MlTelemetry, + MlTelemetrySavedObject, + ML_TELEMETRY_DOC_ID, +} from './ml_telemetry'; export { makeMlUsageCollector } from './make_ml_usage_collector'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/index.js b/x-pack/legacy/plugins/ml/server/models/job_service/index.js index ca896fe302e2f..c180a205cb850 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/index.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/index.js @@ -8,11 +8,15 @@ import { datafeedsProvider } from './datafeeds'; import { jobsProvider } from './jobs'; import { groupsProvider } from './groups'; +import { newJobCapsProvider } from './new_job_caps'; +import { newJobChartsProvider } from './new_job'; -export function jobServiceProvider(callWithRequest) { +export function jobServiceProvider(callWithRequest, request) { return { ...datafeedsProvider(callWithRequest), ...jobsProvider(callWithRequest), ...groupsProvider(callWithRequest), + ...newJobCapsProvider(callWithRequest, request), + ...newJobChartsProvider(callWithRequest, request), }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js index e0ddbf5f1113d..321a8b4a3048e 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js +++ b/x-pack/legacy/plugins/ml/server/models/job_service/jobs.js @@ -13,6 +13,7 @@ import { resultsServiceProvider } from '../results_service'; import { CalendarManager } from '../calendar'; import { fillResultsWithTimeouts, isRequestTimeout } from './error_utils'; import { getLatestDataOrBucketTimestamp, isTimeSeriesViewJob } from '../../../common/util/job_utils'; +import { groupsProvider } from './groups'; import { uniq } from 'lodash'; export function jobsProvider(callWithRequest) { @@ -200,6 +201,7 @@ export function jobsProvider(callWithRequest) { const datafeedStats = results[DATAFEED_STATS].datafeeds.find(ds => (ds.datafeed_id === datafeed.datafeed_id)); if (datafeedStats) { datafeed.state = datafeedStats.state; + datafeed.timing_stats = datafeedStats.timing_stats; } } datafeeds[datafeed.job_id] = datafeed; @@ -341,6 +343,50 @@ export function jobsProvider(callWithRequest) { return results; } + async function getAllJobAndGroupIds() { + const { getAllGroups } = groupsProvider(callWithRequest); + const jobs = await callWithRequest('ml.jobs'); + const jobIds = jobs.jobs.map(job => job.job_id); + const groups = await getAllGroups(); + const groupIds = groups.map(group => group.id); + + return { + jobIds, + groupIds, + }; + } + + async function getLookBackProgress(jobId, start, end) { + const datafeedId = `datafeed-${jobId}`; + const [jobStats, isRunning] = await Promise.all([ + callWithRequest('ml.jobStats', { jobId: [jobId] }), + isDatafeedRunning(datafeedId) + ]); + + if (jobStats.jobs.length) { + const time = jobStats.jobs[0].data_counts.latest_record_timestamp; + const progress = (time - start) / (end - start); + return { + progress: (progress > 0 ? Math.round(progress * 100) : 0), + isRunning + }; + } + return { progress: 0, isRunning: false }; + } + + async function isDatafeedRunning(datafeedId) { + const stats = await callWithRequest('ml.datafeedStats', { datafeedId: [datafeedId] }); + if (stats.datafeeds.length) { + const state = stats.datafeeds[0].state; + return ( + state === DATAFEED_STATE.STARTED || + state === DATAFEED_STATE.STARTING || + state === DATAFEED_STATE.STOPPING + ); + } + return false; + } + return { forceDeleteJob, deleteJobs, @@ -350,5 +396,7 @@ export function jobsProvider(callWithRequest) { createFullJobsList, deletingJobTasks, jobsExist, + getAllJobAndGroupIds, + getLookBackProgress, }; } diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts new file mode 100644 index 0000000000000..a8b6ba494a070 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/charts.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { newJobLineChartProvider } from './line_chart'; +import { newJobPopulationChartProvider } from './population_chart'; +export type callWithRequestType = (action: string, params: any) => Promise; + +export function newJobChartsProvider(callWithRequest: callWithRequestType) { + const { newJobLineChart } = newJobLineChartProvider(callWithRequest); + const { newJobPopulationChart } = newJobPopulationChartProvider(callWithRequest); + + return { + newJobLineChart, + newJobPopulationChart, + }; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts new file mode 100644 index 0000000000000..758b834ed7b3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { newJobChartsProvider } from './charts'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts new file mode 100644 index 0000000000000..59a33db1da2e9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +type DtrIndex = number; +type TimeStamp = number; +type Value = number | undefined | null; +interface Result { + time: TimeStamp; + value: Value; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +export function newJobLineChartProvider(callWithRequest: callWithRequestType) { + async function newJobLineChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null + ) { + const json: object = getSearchJsonFromConfig( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + ); + + const results = await callWithRequest('search', json); + return processSearchResults(results, aggFieldNamePairs.map(af => af.field)); + } + + return { + newJobLineChart, + }; +} + +function processSearchResults(resp: any, fields: string[]): ProcessedResults { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + + const tempResults: Record = {}; + fields.forEach((f, i) => (tempResults[i] = [])); + + aggregationsByTime.forEach((dataForTime: any) => { + const time: TimeStamp = +dataForTime.key; + const docCount = +dataForTime.doc_count; + + fields.forEach((field, i) => { + let value; + if (field === EVENT_RATE_FIELD_ID) { + value = docCount; + } else if (typeof dataForTime[i].value !== 'undefined') { + value = dataForTime[i].value; + } else if (typeof dataForTime[i].values !== 'undefined') { + value = dataForTime[i].values[ML_MEDIAN_PERCENTS]; + } + + tempResults[i].push({ + time, + value, + }); + }); + }); + + return { + success: true, + results: tempResults, + totalResults: resp.hits.total, + }; +} + +function getSearchJsonFromConfig( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: any, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null, + splitFieldValue: string | null +): object { + const json = { + index: indexPatternTitle, + size: 0, + rest_total_hits_as_int: true, + body: { + query: {}, + aggs: { + times: { + date_histogram: { + field: timeField, + interval: intervalMs, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: {}, + }, + }, + }, + }; + + query.bool.must.push({ + range: { + [timeField]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }); + + if (splitFieldName !== null && splitFieldValue !== null) { + query.bool.must.push({ + term: { + [splitFieldName]: splitFieldValue, + }, + }); + } + + json.body.query = query; + + const aggs: Record> = {}; + + aggFieldNamePairs.forEach(({ agg, field }, i) => { + if (field !== null && field !== EVENT_RATE_FIELD_ID) { + aggs[i] = { + [agg]: { field }, + }; + + if (agg === 'percentiles') { + aggs[i][agg].percents = [ML_MEDIAN_PERCENTS]; + } + } + }); + + json.body.aggs.times.aggs = aggs; + + return json; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts new file mode 100644 index 0000000000000..69a0472800bf6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; + +export type callWithRequestType = (action: string, params: any) => Promise; + +const OVER_FIELD_EXAMPLES_COUNT = 40; + +type DtrIndex = number; +type TimeStamp = number; +type Value = number | undefined | null; +interface Thing { + label: string; + value: Value; +} +interface Result { + time: TimeStamp; + values: Thing[]; +} + +interface ProcessedResults { + success: boolean; + results: Record; + totalResults: number; +} + +export function newJobPopulationChartProvider(callWithRequest: callWithRequestType) { + async function newJobPopulationChart( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: object, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null + ) { + const json: object = getPopulationSearchJsonFromConfig( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName + ); + + try { + const results = await callWithRequest('search', json); + return processSearchResults(results, aggFieldNamePairs.map(af => af.field)); + } catch (error) { + return { error }; + } + } + + return { + newJobPopulationChart, + }; +} + +function processSearchResults(resp: any, fields: string[]): ProcessedResults { + const aggregationsByTime = get(resp, ['aggregations', 'times', 'buckets'], []); + + const tempResults: Record = {}; + fields.forEach((f, i) => (tempResults[i] = [])); + + aggregationsByTime.forEach((dataForTime: any) => { + const time: TimeStamp = +dataForTime.key; + + fields.forEach((field, i) => { + const populationBuckets = get(dataForTime, ['population', 'buckets'], []); + const values: Thing[] = []; + if (field === EVENT_RATE_FIELD_ID) { + populationBuckets.forEach((b: any) => { + // check to see if the data is split. + if (b[i] === undefined) { + values.push({ label: b.key, value: b.doc_count }); + } else { + // a split is being used, so an additional filter was added to the search + values.push({ label: b.key, value: b[i].doc_count }); + } + }); + } else if (typeof dataForTime.population !== 'undefined') { + populationBuckets.forEach((b: any) => { + const tempBucket = b[i]; + let value = null; + // check to see if the data is split + // if the field has been split, an additional filter and aggregation + // has been added to the search in the form of splitValue + const tempValue = + tempBucket.value === undefined && tempBucket.splitValue !== undefined + ? tempBucket.splitValue + : tempBucket; + + // check to see if values is exists rather than value. + // if values exists, the aggregation was median + if (tempValue.value === undefined && tempValue.values !== undefined) { + value = tempValue.values[ML_MEDIAN_PERCENTS]; + } else { + value = tempValue.value; + } + values.push({ label: b.key, value: isFinite(value) ? value : null }); + }); + } + + tempResults[i].push({ + time, + values, + }); + }); + }); + + return { + success: true, + results: tempResults, + totalResults: resp.hits.total, + }; +} + +function getPopulationSearchJsonFromConfig( + indexPatternTitle: string, + timeField: string, + start: number, + end: number, + intervalMs: number, + query: any, + aggFieldNamePairs: AggFieldNamePair[], + splitFieldName: string | null +): object { + const json = { + index: indexPatternTitle, + size: 0, + rest_total_hits_as_int: true, + body: { + query: {}, + aggs: { + times: { + date_histogram: { + field: timeField, + interval: intervalMs, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: {}, + }, + }, + }, + }; + + query.bool.must.push({ + range: { + [timeField]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }); + + json.body.query = query; + + const aggs: any = {}; + + aggFieldNamePairs.forEach(({ agg, field, by }, i) => { + if (field === EVENT_RATE_FIELD_ID) { + if (by !== undefined && by.field !== null && by.value !== null) { + aggs[i] = { + filter: { + term: { + [by.field]: by.value, + }, + }, + }; + } + } else { + if (by !== undefined && by.field !== null && by.value !== null) { + // if the field is split, add a filter to the aggregation to just select the + // fields which match the first split value (the front chart + aggs[i] = { + filter: { + term: { + [by.field]: by.value, + }, + }, + aggs: { + splitValue: { + [agg]: { field }, + }, + }, + }; + if (agg === 'percentiles') { + aggs[i].aggs.splitValue[agg].percents = [ML_MEDIAN_PERCENTS]; + } + } else { + aggs[i] = { + [agg]: { field }, + }; + + if (agg === 'percentiles') { + aggs[i][agg].percents = [ML_MEDIAN_PERCENTS]; + } + } + } + }); + + if (splitFieldName !== undefined) { + // the over field should not be undefined. the user should not have got this far if it is. + // add the wrapping terms based aggregation to divide the results up into + // over field values. + // we just want the first 40, or whatever OVER_FIELD_EXAMPLES_COUNT is set to. + json.body.aggs.times.aggs = { + population: { + terms: { + field: splitFieldName, + size: OVER_FIELD_EXAMPLES_COUNT, + }, + aggs, + }, + }; + } else { + json.body.aggs.times.aggs = aggs; + } + return json; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json new file mode 100644 index 0000000000000..9a41c9649b848 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/cloudwatch_field_caps.json @@ -0,0 +1,175 @@ +{ + "indices": [ + "cloudwatch-2018.11.03", + "cloudwatch-2018.11.02", + "cloudwatch-2018.11.01", + "cloudwatch-2018.11.11", + "cloudwatch-2018.10.28", + "cloudwatch-2018.11.07", + "cloudwatch-2018.11.06", + "cloudwatch-2018.11.05", + "cloudwatch-2018.11.04", + "cloudwatch-2018.11.10", + "cloudwatch-2018.10.31", + "cloudwatch-2018.10.30", + "cloudwatch-2018.11.09", + "cloudwatch-2018.11.08", + "cloudwatch-2018.10.29" + ], + "fields": { + "_routing": { + "_routing": { + "type": "_routing", + "searchable": true, + "aggregatable": false + } + }, + "instance": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_index": { + "_index": { + "type": "_index", + "searchable": true, + "aggregatable": true + } + }, + "_feature": { + "_feature": { + "type": "_feature", + "searchable": true, + "aggregatable": false + } + }, + "DiskReadOps": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_type": { + "_type": { + "type": "_type", + "searchable": true, + "aggregatable": true + } + }, + "DiskReadBytes": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_ignored": { + "_ignored": { + "type": "_ignored", + "searchable": true, + "aggregatable": false + } + }, + "DiskWriteOps": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "NetworkOut": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "CPUUtilization": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_seq_no": { + "_seq_no": { + "type": "_seq_no", + "searchable": true, + "aggregatable": true + } + }, + "@timestamp": { + "date": { + "type": "date", + "searchable": true, + "aggregatable": true + } + }, + "DiskWriteBytes": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "_field_names": { + "_field_names": { + "type": "_field_names", + "searchable": true, + "aggregatable": false + } + }, + "NetworkIn": { + "double": { + "type": "double", + "searchable": true, + "aggregatable": true + } + }, + "sourcetype.keyword": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_source": { + "_source": { + "type": "_source", + "searchable": false, + "aggregatable": false + } + }, + "sourcetype": { + "text": { + "type": "text", + "searchable": true, + "aggregatable": false + } + }, + "_id": { + "_id": { + "type": "_id", + "searchable": true, + "aggregatable": true + } + }, + "region": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_version": { + "_version": { + "type": "_version", + "searchable": false, + "aggregatable": false + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json new file mode 100644 index 0000000000000..3bf00d6018700 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/farequote_field_caps.json @@ -0,0 +1,98 @@ +{ + "indices": [ + "farequote-2019" + ], + "fields": { + "_routing": { + "_routing": { + "type": "_routing", + "searchable": true, + "aggregatable": false + } + }, + "_index": { + "_index": { + "type": "_index", + "searchable": true, + "aggregatable": true + } + }, + "_feature": { + "_feature": { + "type": "_feature", + "searchable": true, + "aggregatable": false + } + }, + "_type": { + "_type": { + "type": "_type", + "searchable": true, + "aggregatable": true + } + }, + "_ignored": { + "_ignored": { + "type": "_ignored", + "searchable": true, + "aggregatable": false + } + }, + "_seq_no": { + "_seq_no": { + "type": "_seq_no", + "searchable": true, + "aggregatable": true + } + }, + "@timestamp": { + "date": { + "type": "date", + "searchable": true, + "aggregatable": true + } + }, + "_field_names": { + "_field_names": { + "type": "_field_names", + "searchable": true, + "aggregatable": false + } + }, + "responsetime": { + "float": { + "type": "float", + "searchable": true, + "aggregatable": true + } + }, + "_source": { + "_source": { + "type": "_source", + "searchable": false, + "aggregatable": false + } + }, + "_id": { + "_id": { + "type": "_id", + "searchable": true, + "aggregatable": true + } + }, + "airline": { + "keyword": { + "type": "keyword", + "searchable": true, + "aggregatable": true + } + }, + "_version": { + "_version": { + "type": "_version", + "searchable": false, + "aggregatable": false + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json new file mode 100644 index 0000000000000..ca356b2bede22 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/kibana_saved_objects.json @@ -0,0 +1,35 @@ +{ + "page": 1, + "per_page": 1000, + "total": 4, + "saved_objects": [ + { + "type": "index-pattern", + "id": "be0eebe0-65ac-11e9-aa86-0793be5f3670", + "attributes": { + "title": "farequote-*" + }, + "references": [], + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2019-04-23T09:47:02.203Z", + "version": "WzcsMV0=" + }, + { + "type": "index-pattern", + "id": "be14ceb0-66b1-11e9-91c9-ffa52374d341", + "attributes": { + "typeMeta": "{\"params\":{\"rollup_index\":\"cloud_roll_index\"},\"aggs\":{\"histogram\":{\"NetworkOut\":{\"agg\":\"histogram\",\"interval\":5},\"CPUUtilization\":{\"agg\":\"histogram\",\"interval\":5},\"NetworkIn\":{\"agg\":\"histogram\",\"interval\":5}},\"avg\":{\"NetworkOut\":{\"agg\":\"avg\"},\"CPUUtilization\":{\"agg\":\"avg\"},\"NetworkIn\":{\"agg\":\"avg\"},\"DiskReadBytes\":{\"agg\":\"avg\"}},\"min\":{\"NetworkOut\":{\"agg\":\"min\"},\"NetworkIn\":{\"agg\":\"min\"}},\"value_count\":{\"NetworkOut\":{\"agg\":\"value_count\"},\"DiskReadBytes\":{\"agg\":\"value_count\"},\"CPUUtilization\":{\"agg\":\"value_count\"},\"NetworkIn\":{\"agg\":\"value_count\"}},\"max\":{\"CPUUtilization\":{\"agg\":\"max\"},\"DiskReadBytes\":{\"agg\":\"max\"}},\"date_histogram\":{\"@timestamp\":{\"agg\":\"date_histogram\",\"delay\":\"1d\",\"interval\":\"5m\",\"time_zone\":\"UTC\"}},\"terms\":{\"instance\":{\"agg\":\"terms\"},\"sourcetype.keyword\":{\"agg\":\"terms\"},\"region\":{\"agg\":\"terms\"}},\"sum\":{\"DiskReadBytes\":{\"agg\":\"sum\"},\"NetworkOut\":{\"agg\":\"sum\"}}}}", + "title": "cloud_roll_index", + "type": "rollup" + }, + "references": [], + "migrationVersion": { + "index-pattern": "6.5.0" + }, + "updated_at": "2019-04-24T16:55:20.550Z", + "version": "Wzc0LDJd" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json new file mode 100644 index 0000000000000..2b2f8576d6769 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/responses/rollup_caps.json @@ -0,0 +1,185 @@ +{ + "cloud_roll_index": { + "rollup_jobs": [ + { + "job_id": "cloud_roll", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatch-*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + }, + { + "agg": "value_count" + } + ], + "CPUUtilization": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "max" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ], + "instance": [ + { + "agg": "terms" + } + ], + "NetworkIn": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + } + ], + "sourcetype.keyword": [ + { + "agg": "terms" + } + ], + "region": [ + { + "agg": "terms" + } + ], + "DiskReadBytes": [ + { + "agg": "avg" + }, + { + "agg": "max" + }, + { + "agg": "sum" + }, + { + "agg": "value_count" + } + ] + } + }, + { + "job_id": "cloud_roll2", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatch*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "sum" + }, + { + "agg": "value_count" + } + ], + "CPUUtilization": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "max" + }, + { + "agg": "value_count" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ], + "instance": [ + { + "agg": "terms" + } + ], + "NetworkIn": [ + { + "agg": "histogram", + "interval": 5 + }, + { + "agg": "avg" + }, + { + "agg": "min" + }, + { + "agg": "value_count" + } + ], + "region": [ + { + "agg": "terms" + } + ] + } + }, + { + "job_id": "cloud_roll3", + "rollup_index": "cloud_roll_index", + "index_pattern": "cloudwatc*", + "fields": { + "NetworkOut": [ + { + "agg": "histogram", + "interval": 15 + } + ], + "DiskWriteOps": [ + { + "agg": "sum" + } + ], + "@timestamp": [ + { + "agg": "date_histogram", + "delay": "1d", + "interval": "5m", + "time_zone": "UTC" + } + ] + } + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json new file mode 100644 index 0000000000000..90c44b6b6b8b8 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/cloudwatch_rollup_job_caps.json @@ -0,0 +1,196 @@ +{ + "cloud_roll_index": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "DiskReadBytes", + "NetworkOut", + "CPUUtilization", + "NetworkIn" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "DiskReadBytes", + "DiskWriteOps" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "NetworkOut", + "NetworkIn" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "DiskReadBytes", + "CPUUtilization" + ] + } + ], + "fields": [ + { + "id": "DiskReadBytes", + "name": "DiskReadBytes", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "max" + ] + }, + { + "id": "DiskWriteOps", + "name": "DiskWriteOps", + "type": "double", + "aggregatable": true, + "aggIds": [ + "sum", + "high_sum", + "low_sum" + ] + }, + { + "id": "NetworkOut", + "name": "NetworkOut", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "min" + ] + }, + { + "id": "CPUUtilization", + "name": "CPUUtilization", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "max" + ] + }, + { + "id": "NetworkIn", + "name": "NetworkIn", + "type": "double", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "min" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json new file mode 100644 index 0000000000000..b05462348a300 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps.json @@ -0,0 +1,206 @@ +{ + "farequote-*": { + "aggs": [ + { + "id": "mean", + "title": "Mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_mean", + "title": "High mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_mean", + "title": "Low mean", + "kibanaName": "avg", + "dslName": "avg", + "type": "metrics", + "mlModelPlotAgg": { + "max": "avg", + "min": "avg" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "sum", + "title": "Sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_sum", + "title": "High sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_sum", + "title": "Low sum", + "kibanaName": "sum", + "dslName": "sum", + "type": "metrics", + "mlModelPlotAgg": { + "max": "sum", + "min": "sum" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "median", + "title": "Median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "high_median", + "title": "High median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "low_median", + "title": "Low median", + "kibanaName": "median", + "dslName": "percentiles", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "min", + "title": "Min", + "kibanaName": "min", + "dslName": "min", + "type": "metrics", + "mlModelPlotAgg": { + "max": "min", + "min": "min" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "max", + "title": "Max", + "kibanaName": "max", + "dslName": "max", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "max" + }, + "fieldIds": [ + "responsetime" + ] + }, + { + "id": "distinct_count", + "title": "Distinct count", + "kibanaName": "cardinality", + "dslName": "cardinality", + "type": "metrics", + "mlModelPlotAgg": { + "max": "max", + "min": "min" + }, + "fieldIds": [ + "airline", + "responsetime" + ] + } + ], + "fields": [ + { + "id": "responsetime", + "name": "responsetime", + "type": "float", + "aggregatable": true, + "aggIds": [ + "mean", + "high_mean", + "low_mean", + "sum", + "high_sum", + "low_sum", + "median", + "high_median", + "low_median", + "min", + "max", + "distinct_count" + ] + }, + { + "id": "airline", + "name": "airline", + "type": "keyword", + "aggregatable": true, + "aggIds": [ + "distinct_count" + ] + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json new file mode 100644 index 0000000000000..3d4a97bb6cd53 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/__mocks__/results/farequote_job_caps_empty.json @@ -0,0 +1,6 @@ +{ + "farequote-*": { + "aggs": [], + "fields": [] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts new file mode 100644 index 0000000000000..06a81167fa5a4 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Aggregation } from '../../../../common/types/fields'; +import { + ML_JOB_AGGREGATION, + KIBANA_AGGREGATION, + ES_AGGREGATION, +} from '../../../../common/constants/aggregation_types'; + +export const aggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEAN, + title: 'Mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEAN, + title: 'High mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEAN, + title: 'Low mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.SUM, + title: 'Sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_SUM, + title: 'High sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_SUM, + title: 'Low sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEDIAN, + title: 'Median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEDIAN, + title: 'High median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEDIAN, + title: 'Low median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MIN, + title: 'Min', + kibanaName: KIBANA_AGGREGATION.MIN, + dslName: ES_AGGREGATION.MIN, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MIN, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MAX, + title: 'Max', + kibanaName: KIBANA_AGGREGATION.MAX, + dslName: ES_AGGREGATION.MAX, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MAX, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.DISTINCT_COUNT, + title: 'Distinct count', + kibanaName: KIBANA_AGGREGATION.CARDINALITY, + dslName: ES_AGGREGATION.CARDINALITY, + type: 'metrics', + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, +]; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts new file mode 100644 index 0000000000000..6bba4e90ad2e5 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import { Request } from 'src/legacy/server/kbn_server'; +import { Field, Aggregation, FieldId, NewJobCaps } from '../../../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../../../common/constants/field_types'; +import { rollupServiceProvider, RollupJob, RollupFields } from './rollup'; +import { aggregations } from './aggregations'; + +const METRIC_AGG_TYPE: string = 'metrics'; + +const supportedTypes: string[] = [ + ES_FIELD_TYPES.DATE, + ES_FIELD_TYPES.KEYWORD, + ES_FIELD_TYPES.TEXT, + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.INTEGER, + ES_FIELD_TYPES.FLOAT, + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.BYTE, + ES_FIELD_TYPES.HALF_FLOAT, + ES_FIELD_TYPES.SCALED_FLOAT, + ES_FIELD_TYPES.SHORT, +]; + +export function fieldServiceProvider( + indexPattern: string, + isRollup: boolean, + callWithRequest: any, + request: Request +) { + return new FieldsService(indexPattern, isRollup, callWithRequest, request); +} + +class FieldsService { + private _indexPattern: string; + private _isRollup: boolean; + private _callWithRequest: any; + private _request: Request; + + constructor(indexPattern: string, isRollup: boolean, callWithRequest: any, request: Request) { + this._indexPattern = indexPattern; + this._isRollup = isRollup; + this._callWithRequest = callWithRequest; + this._request = request; + } + + private async loadFieldCaps(): Promise { + return this._callWithRequest('fieldCaps', { + index: this._indexPattern, + fields: '*', + }); + } + + // create field object from the results from _field_caps + private async createFields(): Promise { + const fieldCaps = await this.loadFieldCaps(); + const fields: Field[] = []; + if (fieldCaps && fieldCaps.fields) { + Object.keys(fieldCaps.fields).forEach((k: FieldId) => { + const fc = fieldCaps.fields[k]; + const firstKey = Object.keys(fc)[0]; + if (firstKey !== undefined) { + const field = fc[firstKey]; + // add to the list of fields if the field type can be used by ML + if (supportedTypes.includes(field.type) === true) { + fields.push({ + id: k, + name: k, + type: field.type, + aggregatable: field.aggregatable, + aggs: [], + }); + } + } + }); + } + return fields; + } + + // public function to load fields from _field_caps and create a list + // of aggregations and fields that can be used for an ML job + // if the index is a rollup, the fields and aggs will be filtered + // based on what is available in the rollup job + // the _indexPattern will be replaced with a comma separated list + // of index patterns from all of the rollup jobs + public async getData(): Promise { + let rollupFields: RollupFields = {}; + + if (this._isRollup) { + const rollupService = await rollupServiceProvider( + this._indexPattern, + this._callWithRequest, + this._request + ); + const rollupConfigs: RollupJob[] | null = await rollupService.getRollupJobs(); + + // if a rollup index has been specified, yet there are no + // rollup configs, return with no results + if (rollupConfigs === null) { + return { + aggs: [], + fields: [], + }; + } else { + rollupFields = combineAllRollupFields(rollupConfigs); + this._indexPattern = rollupService.getIndexPattern(); + } + } + + const aggs = cloneDeep(aggregations); + const fields: Field[] = await this.createFields(); + + return await combineFieldsAndAggs(fields, aggs, rollupFields); + } +} + +// cross reference fields and aggs. +// fields contain a list of aggs that are compatible, and vice versa. +async function combineFieldsAndAggs( + fields: Field[], + aggs: Aggregation[], + rollupFields: RollupFields +): Promise { + const textAndKeywordFields = getTextAndKeywordFields(fields); + const numericalFields = getNumericalFields(fields); + + const mix = mixFactory(rollupFields); + + aggs.forEach(a => { + if (a.type === METRIC_AGG_TYPE) { + switch (a.kibanaName) { + case 'cardinality': + textAndKeywordFields.forEach(f => { + mix(f, a); + }); + numericalFields.forEach(f => { + mix(f, a); + }); + break; + case 'count': + break; + default: + numericalFields.forEach(f => { + mix(f, a); + }); + break; + } + } + }); + + return { + aggs: filterAggs(aggs), + fields: filterFields(fields), + }; +} + +// remove fields that have no aggs associated to them +function filterFields(fields: Field[]): Field[] { + return fields.filter(f => f.aggs && f.aggs.length); +} + +// remove aggs that have no fields associated to them +function filterAggs(aggs: Aggregation[]): Aggregation[] { + return aggs.filter(a => a.fields && a.fields.length); +} + +// returns a mix function that is used to cross-reference aggs and fields. +// wrapped in a provider to allow filtering based on rollup job capabilities +function mixFactory(rollupFields: RollupFields) { + const isRollup = Object.keys(rollupFields).length > 0; + + return function mix(field: Field, agg: Aggregation): void { + if ( + isRollup === false || + (rollupFields[field.id] && rollupFields[field.id].find(f => f.agg === agg.kibanaName)) + ) { + if (field.aggs !== undefined) { + field.aggs.push(agg); + } + if (agg.fields !== undefined) { + agg.fields.push(field); + } + } + }; +} + +function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { + const rollupFields: RollupFields = {}; + rollupConfigs.forEach(conf => { + Object.keys(conf.fields).forEach(fieldName => { + if (rollupFields[fieldName] === undefined) { + rollupFields[fieldName] = conf.fields[fieldName]; + } else { + const aggs = conf.fields[fieldName]; + aggs.forEach(agg => { + if (rollupFields[fieldName].find(f => f.agg === agg.agg) === null) { + rollupFields[fieldName].push(agg); + } + }); + } + }); + }); + return rollupFields; +} + +function getTextAndKeywordFields(fields: Field[]): Field[] { + return fields.filter(f => f.type === ES_FIELD_TYPES.KEYWORD || f.type === ES_FIELD_TYPES.TEXT); +} + +function getNumericalFields(fields: Field[]): Field[] { + return fields.filter( + f => + f.type === ES_FIELD_TYPES.DOUBLE || + f.type === ES_FIELD_TYPES.FLOAT || + f.type === ES_FIELD_TYPES.LONG + ); +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/index.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/index.ts new file mode 100644 index 0000000000000..9cf764bbb5a42 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { newJobCapsProvider } from './new_job_caps'; diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts new file mode 100644 index 0000000000000..87653a1e3f47d --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { newJobCapsProvider } from './index'; + +import farequoteFieldCaps from './__mocks__/responses/farequote_field_caps.json'; +import cloudwatchFieldCaps from './__mocks__/responses/cloudwatch_field_caps.json'; +import rollupCaps from './__mocks__/responses/rollup_caps.json'; +import kibanaSavedObjects from './__mocks__/responses/kibana_saved_objects.json'; + +import farequoteJobCaps from './__mocks__/results/farequote_job_caps.json'; +import farequoteJobCapsEmpty from './__mocks__/results/farequote_job_caps_empty.json'; +import cloudwatchJobCaps from './__mocks__/results/cloudwatch_rollup_job_caps.json'; + +describe('job_service - job_caps', () => { + let callWithRequestNonRollupMock: jest.Mock; + let callWithRequestRollupMock: jest.Mock; + let requestMock: any; + + beforeEach(() => { + callWithRequestNonRollupMock = jest.fn((action: string) => { + switch (action) { + case 'fieldCaps': + return farequoteFieldCaps; + } + }); + + callWithRequestRollupMock = jest.fn((action: string) => { + switch (action) { + case 'fieldCaps': + return cloudwatchFieldCaps; + case 'ml.rollupIndexCapabilities': + return Promise.resolve(rollupCaps); + } + }); + + requestMock = { + getSavedObjectsClient: jest.fn(() => { + return { + async find() { + return Promise.resolve(kibanaSavedObjects); + }, + }; + }), + }; + }); + + describe('farequote newJobCaps()', () => { + it('can get job caps for index pattern', async done => { + const indexPattern = 'farequote-*'; + const isRollup = false; + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(farequoteJobCaps); + done(); + }); + + it('can get rollup job caps for non rollup index pattern', async done => { + const indexPattern = 'farequote-*'; + const isRollup = true; + const { newJobCaps } = newJobCapsProvider(callWithRequestNonRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(farequoteJobCapsEmpty); + done(); + }); + + it('can get rollup job caps for rollup index pattern', async done => { + const indexPattern = 'cloud_roll_index'; + const isRollup = true; + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).toEqual(cloudwatchJobCaps); + done(); + }); + + it('can get non rollup job caps for rollup index pattern', async done => { + const indexPattern = 'cloud_roll_index'; + const isRollup = false; + const { newJobCaps } = newJobCapsProvider(callWithRequestRollupMock, requestMock); + const response = await newJobCaps(indexPattern, isRollup); + expect(response).not.toEqual(cloudwatchJobCaps); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts new file mode 100644 index 0000000000000..798790e1d37c3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/new_job_caps.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'src/legacy/server/kbn_server'; +import { Aggregation, Field, NewJobCaps } from '../../../../common/types/fields'; +import { fieldServiceProvider } from './field_service'; + +interface NewJobCapsResponse { + [indexPattern: string]: NewJobCaps; +} + +export function newJobCapsProvider(callWithRequest: any, request: Request) { + async function newJobCaps( + indexPattern: string, + isRollup: boolean = false + ): Promise { + const fieldService = fieldServiceProvider(indexPattern, isRollup, callWithRequest, request); + const { aggs, fields } = await fieldService.getData(); + convertForStringify(aggs, fields); + + return { + [indexPattern]: { + aggs, + fields, + }, + }; + } + return { + newJobCaps, + }; +} + +// replace the recursive field and agg references with a +// map of ids to allow it to be stringified for transportation +// over the network. +function convertForStringify(aggs: Aggregation[], fields: Field[]): void { + fields.forEach(f => { + f.aggIds = f.aggs ? f.aggs.map(a => a.id) : []; + delete f.aggs; + }); + aggs.forEach(a => { + a.fieldIds = a.fields ? a.fields.map(f => f.id) : []; + delete a.fields; + }); +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts new file mode 100644 index 0000000000000..91e07e96739c6 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Request } from 'src/legacy/server/kbn_server'; +import { SavedObject } from 'src/core/server'; +import { FieldId, AggId } from '../../../../common/types/fields'; + +export type RollupFields = Record]>; + +export interface RollupJob { + job_id: string; + rollup_index: string; + index_pattern: string; + fields: RollupFields; +} + +export async function rollupServiceProvider( + indexPattern: string, + callWithRequest: any, + request: Request +) { + const rollupIndexPatternObject = await loadRollupIndexPattern(indexPattern, request); + let jobIndexPatterns: string[] = [indexPattern]; + + async function getRollupJobs(): Promise { + if (rollupIndexPatternObject !== null) { + const parsedTypeMetaData = JSON.parse(rollupIndexPatternObject.attributes.typeMeta); + const rollUpIndex: string = parsedTypeMetaData.params.rollup_index; + const rollupCaps = await callWithRequest('ml.rollupIndexCapabilities', { + indexPattern: rollUpIndex, + }); + + const indexRollupCaps = rollupCaps[rollUpIndex]; + if (indexRollupCaps && indexRollupCaps.rollup_jobs) { + jobIndexPatterns = indexRollupCaps.rollup_jobs.map((j: RollupJob) => j.index_pattern); + + return indexRollupCaps.rollup_jobs; + } + } + + return null; + } + + function getIndexPattern() { + return jobIndexPatterns.join(','); + } + + return { + getRollupJobs, + getIndexPattern, + }; +} + +async function loadRollupIndexPattern( + indexPattern: string, + request: Request +): Promise { + const savedObjectsClient = request.getSavedObjectsClient(); + const resp = await savedObjectsClient.find({ + type: 'index-pattern', + fields: ['title', 'type', 'typeMeta'], + perPage: 1000, + }); + + const obj = resp.saved_objects.find( + r => + r.attributes && + r.attributes.type === 'rollup' && + r.attributes.title === indexPattern && + r.attributes.typeMeta !== undefined + ); + + return obj || null; +} diff --git a/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js b/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js index 92d2679243c15..72b26d2163045 100644 --- a/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js +++ b/x-pack/legacy/plugins/ml/server/models/job_validation/messages.js @@ -255,7 +255,7 @@ export const getMessages = () => { job_id_valid: { status: 'SUCCESS', heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdValidHeading', { - defaultMessage: 'Job id format is valid.', + defaultMessage: 'Job id format is valid', }), text: i18n.translate('xpack.ml.models.jobValidation.messages.jobIdValidMessage', { defaultMessage: 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, ' + @@ -274,7 +274,7 @@ export const getMessages = () => { job_group_id_valid: { status: 'SUCCESS', heading: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdValidHeading', { - defaultMessage: 'Job group id formats are valid.', + defaultMessage: 'Job group id formats are valid', }), text: i18n.translate('xpack.ml.models.jobValidation.messages.jobGroupIdValidMessage', { defaultMessage: 'Lowercase alphanumeric (a-z and 0-9) characters, hyphens or underscores, ' + diff --git a/x-pack/legacy/plugins/ml/server/routes/job_service.js b/x-pack/legacy/plugins/ml/server/routes/job_service.js index dc43f3b3160c8..6311d341618be 100644 --- a/x-pack/legacy/plugins/ml/server/routes/job_service.js +++ b/x-pack/legacy/plugins/ml/server/routes/job_service.js @@ -180,4 +180,115 @@ export function jobServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route } }); + route({ + method: 'GET', + path: '/api/ml/jobs/new_job_caps/{indexPattern}', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { indexPattern } = request.params; + const isRollup = (request.query.rollup === 'true'); + const { newJobCaps } = jobServiceProvider(callWithRequest, request); + return newJobCaps(indexPattern, isRollup) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/new_job_line_chart', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue + } = request.payload; + const { newJobLineChart } = jobServiceProvider(callWithRequest, request); + return newJobLineChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + splitFieldValue, + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/new_job_population_chart', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + } = request.payload; + const { newJobPopulationChart } = jobServiceProvider(callWithRequest, request); + return newJobPopulationChart( + indexPatternTitle, + timeField, + start, + end, + intervalMs, + query, + aggFieldNamePairs, + splitFieldName, + ).catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'GET', + path: '/api/ml/jobs/all_jobs_and_group_ids', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { getAllJobAndGroupIds } = jobServiceProvider(callWithRequest); + return getAllJobAndGroupIds() + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + + route({ + method: 'POST', + path: '/api/ml/jobs/look_back_progress', + handler(request) { + const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request); + const { getLookBackProgress } = jobServiceProvider(callWithRequest); + const { jobId, start, end } = request.payload; + return getLookBackProgress(jobId, start, end) + .catch(resp => wrapError(resp)); + }, + config: { + ...commonRouteConfig + } + }); + } diff --git a/x-pack/legacy/plugins/monitoring/common/constants.js b/x-pack/legacy/plugins/monitoring/common/constants.js index 56450cf614db2..3623d010a0cdb 100644 --- a/x-pack/legacy/plugins/monitoring/common/constants.js +++ b/x-pack/legacy/plugins/monitoring/common/constants.js @@ -167,6 +167,7 @@ export const METRICBEAT_INDEX_NAME_UNIQUE_TOKEN = '-mb-'; // We use this for metricbeat migration to identify specific products that we do not have constants for export const ELASTICSEARCH_CUSTOM_ID = 'elasticsearch'; +export const APM_CUSTOM_ID = 'apm'; /** * The id of the infra source owned by the monitoring plugin. */ diff --git a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js index b4a1d5ea85c16..04b6652c6ce0a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/apm/instances/instances.js @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment } from 'react'; import moment from 'moment'; -import { uniq } from 'lodash'; +import { uniq, get } from 'lodash'; import { EuiMonitoringTable } from '../../table'; -import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { EuiLink, EuiPage, EuiPageBody, EuiPageContent, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { Status } from './status'; import { formatMetric } from '../../../lib/format_number'; import { formatTimestampToDuration } from '../../../../common'; @@ -83,14 +83,37 @@ const columns = [ }, ]; -export function ApmServerInstances({ apms }) { +export function ApmServerInstances({ apms, setupMode }) { const { pagination, sorting, onTableChange, - data + data, } = apms; + let detectedInstanceMessage = null; + if (setupMode.enabled && setupMode.data && get(setupMode.data, 'detected.mightExist')) { + detectedInstanceMessage = ( + + +

+ {i18n.translate('xpack.monitoring.apm.instances.metricbeatMigration.detectedInstanceDescription', { + defaultMessage: `Based on your indices, we think you might have an APM server. Click the 'Setup monitoring' + button below to start monitoring this APM server.` + })} +

+
+ +
+ ); + } + const versions = uniq(data.apms.map(item => item.version)).map(version => { return { value: version }; }); @@ -101,12 +124,19 @@ export function ApmServerInstances({ apms }) { + {detectedInstanceMessage} + +

+ {i18n.translate('xpack.monitoring.beats.instances.metricbeatMigration.detectedInstanceDescription', { + defaultMessage: `Based on your indices, we think you might have a beats instance. Click the 'Setup monitoring' + button below to start monitoring this instance.` + })} +

+
+ + + ); + } const types = uniq(data.map(item => item.type)).map(type => { return { value: type }; @@ -92,9 +115,16 @@ export class Listing extends PureComponent { + {detectedInstanceMessage} 0) { + const { setupMode } = props; + const apmsTotal = get(props, 'apms.total') || 0; + // Do not show if we are not in setup mode + if (apmsTotal === 0 && !setupMode.enabled) { return null; } const goToApm = () => props.changeUrl('apm'); const goToInstances = () => props.changeUrl('apm/instances'); + const setupModeApmData = get(setupMode.data, 'apm'); + let setupModeInstancesData = null; + if (setupMode.enabled && setupMode.data) { + const migratedNodesCount = Object.values(setupModeApmData.byUuid).filter(node => node.isFullyMigrated).length; + let totalNodesCount = Object.values(setupModeApmData.byUuid).length; + if (totalNodesCount === 0 && get(setupMode.data, 'apm.detected.mightExist', false)) { + totalNodesCount = 1; + } + + const badgeColor = migratedNodesCount === totalNodesCount + ? 'secondary' + : 'danger'; + + setupModeInstancesData = ( + + + + {migratedNodesCount}/{totalNodesCount} + + + + ); + } + return (

- - +

@@ -90,27 +128,32 @@ export function ApmPanel(props) { - -

- - {props.apms.total}) }} - /> - -

-
+ + + +

+ + {apmsTotal}) }} + /> + +

+
+
+ {setupModeInstancesData} +
diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js index f4da1a0e8737b..1f569fc5043e5 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/beats_panel.js @@ -17,19 +17,56 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, EuiHorizontalRule, + EuiFlexGroup, + EuiToolTip, + EuiBadge } from '@elastic/eui'; -import { ClusterItemContainer } from './helpers'; +import { ClusterItemContainer, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; export function BeatsPanel(props) { - if (!get(props, 'beats.total', 0) > 0) { + const { setupMode } = props; + const beatsTotal = get(props, 'beats.total') || 0; + // Do not show if we are not in setup mode + if (beatsTotal === 0 && !setupMode.enabled) { return null; } const goToBeats = () => props.changeUrl('beats'); const goToInstances = () => props.changeUrl('beats/beats'); + const setupModeBeatsData = get(setupMode.data, 'beats'); + let setupModeInstancesData = null; + if (setupMode.enabled && setupMode.data) { + const migratedNodesCount = Object.values(setupModeBeatsData.byUuid).filter(node => node.isFullyMigrated).length; + let totalNodesCount = Object.values(setupModeBeatsData.byUuid).length; + if (totalNodesCount === 0 && get(setupMode.data, 'beats.detected.mightExist', false)) { + totalNodesCount = 1; + } + + const badgeColor = migratedNodesCount === totalNodesCount + ? 'secondary' + : 'danger'; + + setupModeInstancesData = ( + + + + {migratedNodesCount}/{totalNodesCount} + + + + ); + } + const beatTypes = props.beats.types.map((beat, index) => { return [

- - +

@@ -99,27 +138,32 @@ export function BeatsPanel(props) {
- -

- - {props.beats.total}) }} - /> - -

-
+ + + +

+ + {beatsTotal}) }} + /> + +

+
+
+ {setupModeInstancesData} +
{beatTypes} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index c891bc5373db3..3f45d6e07297c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -7,7 +7,13 @@ import React, { Fragment } from 'react'; import { get, capitalize } from 'lodash'; import { formatNumber } from 'plugins/monitoring/lib/format_number'; -import { ClusterItemContainer, HealthStatusIndicator, BytesUsage, BytesPercentageUsage } from './helpers'; +import { + ClusterItemContainer, + HealthStatusIndicator, + BytesUsage, + BytesPercentageUsage, + DisabledIfNoDataAndInSetupModeLink +} from './helpers'; import { EuiFlexGrid, EuiFlexItem, @@ -21,6 +27,7 @@ import { EuiBadge, EuiToolTip, EuiFlexGroup, + EuiIcon } from '@elastic/eui'; import { LicenseText } from './license_text'; import { i18n } from '@kbn/i18n'; @@ -151,6 +158,50 @@ export function ElasticsearchPanel(props) { ); + const licenseText = ; + + const setupModeElasticsearchData = get(setupMode.data, 'elasticsearch'); + let setupModeNodesData = null; + if (setupMode.enabled && setupModeElasticsearchData) { + const { + totalUniqueInstanceCount, + totalUniqueFullyMigratedCount, + totalUniquePartiallyMigratedCount + } = setupModeElasticsearchData; + const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && + (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); + const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; + if (!allMonitoredByMetricbeat || internalCollectionOn) { + let tooltipText = null; + + if (!allMonitoredByMetricbeat) { + tooltipText = i18n.translate('xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.oneInternal', { + defaultMessage: `There's at least one node that isn't being monitored using Metricbeat. Click the flag icon to visit the nodes + listing page and find out more information about the status of each node.` + }); + } + else if (internalCollectionOn) { + tooltipText = i18n.translate('xpack.monitoring.cluster.overview.elasticsearchPanel.setupModeNodesTooltip.disableInternal', { + defaultMessage: `All nodes are being monitored using Metricbeat but internal collection still needs to be turned off. Click the + flag icon to visit the nodes listing page and disable internal collection.` + }); + } + + setupModeNodesData = ( + + + + + + + + ); + } + } + const showMlJobs = () => { // if license doesn't support ML, then `ml === null` if (props.ml) { @@ -158,15 +209,25 @@ export function ElasticsearchPanel(props) { return ( <> - + - + - {props.ml.jobs} + + {props.ml.jobs} + ); @@ -174,36 +235,6 @@ export function ElasticsearchPanel(props) { return null; }; - const licenseText = ; - - let setupModeNodesData = null; - if (setupMode.enabled && setupMode.data) { - const elasticsearchData = get(setupMode.data, 'elasticsearch.byUuid'); - const migratedNodesCount = Object.values(elasticsearchData).filter(node => node.isFullyMigrated).length; - const totalNodesCount = Object.values(elasticsearchData).length; - - const badgeColor = migratedNodesCount === totalNodesCount - ? 'secondary' - : 'danger'; - - setupModeNodesData = ( - - - - {formatNumber(migratedNodesCount, 'int_commas')}/{formatNumber(totalNodesCount, 'int_commas')} - - - - ); - } - return (

- - +

@@ -318,7 +351,9 @@ export function ElasticsearchPanel(props) {

- - +

@@ -383,7 +418,9 @@ export function ElasticsearchPanel(props) {

- - +

diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js index af495b97270a2..182f64bd2e41e 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js @@ -5,8 +5,8 @@ */ import React from 'react'; +import { get } from 'lodash'; import { formatBytesUsage, formatPercentageUsage } from 'plugins/monitoring/lib/format_number'; - import { EuiSpacer, EuiFlexItem, @@ -15,6 +15,7 @@ import { EuiIcon, EuiHealth, EuiText, + EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -123,5 +124,15 @@ export function BytesPercentageUsage({ usedBytes, maxBytes }) { ); } - return null; + return 0; +} + +export function DisabledIfNoDataAndInSetupModeLink({ setupModeEnabled, setupModeData, children, ...props }) { + if (setupModeEnabled && get(setupModeData, 'totalUniqueInstanceCount', 0) === 0) { + return children; + } + + return ( + {children} + ); } diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js index 56814b6fae128..895c61f19785a 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/index.js @@ -45,11 +45,11 @@ export function Overview(props) { : null } - + - + - + ); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/kibana_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/kibana_panel.js index a6f75446d94f5..77dfa35072c26 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/kibana_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/kibana_panel.js @@ -6,7 +6,7 @@ import React from 'react'; import { formatNumber } from 'plugins/monitoring/lib/format_number'; -import { ClusterItemContainer, HealthStatusIndicator, BytesPercentageUsage } from './helpers'; +import { ClusterItemContainer, HealthStatusIndicator, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { get } from 'lodash'; import { EuiFlexGrid, @@ -19,7 +19,7 @@ import { EuiDescriptionListTitle, EuiDescriptionListDescription, EuiHorizontalRule, - EuiBadge, + EuiIcon, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -39,32 +39,46 @@ export function KibanaPanel(props) { const goToKibana = () => props.changeUrl('kibana'); const goToInstances = () => props.changeUrl('kibana/instances'); + const setupModeKibanaData = get(setupMode.data, 'kibana'); let setupModeInstancesData = null; if (setupMode.enabled && setupMode.data) { - const kibanaData = get(setupMode.data, 'kibana.byUuid'); - const migratedNodesCount = Object.values(kibanaData).filter(node => node.isFullyMigrated).length; - const totalNodesCount = Object.values(kibanaData).length; + const { + totalUniqueInstanceCount, + totalUniqueFullyMigratedCount, + totalUniquePartiallyMigratedCount + } = setupModeKibanaData; + const allMonitoredByMetricbeat = totalUniqueInstanceCount > 0 && + (totalUniqueFullyMigratedCount === totalUniqueInstanceCount || totalUniquePartiallyMigratedCount === totalUniqueInstanceCount); + const internalCollectionOn = totalUniquePartiallyMigratedCount > 0; + if (!allMonitoredByMetricbeat || internalCollectionOn) { + let tooltipText = null; - const badgeColor = migratedNodesCount === totalNodesCount - ? 'secondary' - : 'danger'; + if (!allMonitoredByMetricbeat) { + tooltipText = i18n.translate('xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.oneInternal', { + defaultMessage: `There's at least one instance that isn't being monitored using Metricbeat. Click the flag + icon to visit the instances listing page and find out more information about the status of each instance.` + }); + } + else if (internalCollectionOn) { + tooltipText = i18n.translate('xpack.monitoring.cluster.overview.kibanaPanel.setupModeNodesTooltip.disableInternal', { + defaultMessage: `All instances are being monitored using Metricbeat but internal collection still needs to be turned + off. Click the flag icon to visit the instances listing page and disable internal collection.` + }); + } - setupModeInstancesData = ( - - - - {formatNumber(migratedNodesCount, 'int_commas')}/{formatNumber(totalNodesCount, 'int_commas')} - - - - ); + setupModeInstancesData = ( + + + + + + + + ); + } } return ( @@ -81,7 +95,9 @@ export function KibanaPanel(props) {

- - +

diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js index 073dfbcbd6f7f..a3dfa7f2ef72b 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/logstash_panel.js @@ -6,7 +6,7 @@ import React from 'react'; import { formatNumber } from 'plugins/monitoring/lib/format_number'; -import { ClusterItemContainer, BytesPercentageUsage } from './helpers'; +import { ClusterItemContainer, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { LOGSTASH } from '../../../../common/constants'; import { @@ -21,12 +21,20 @@ import { EuiDescriptionListDescription, EuiHorizontalRule, EuiIconTip, + EuiToolTip, + EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; export function LogstashPanel(props) { - if (!props.node_count) { + const { setupMode } = props; + const nodesCount = props.node_count || 0; + const queueTypes = props.queue_types || {}; + + // Do not show if we are not in setup mode + if (!nodesCount && !setupMode.enabled) { return null; } @@ -34,6 +42,37 @@ export function LogstashPanel(props) { const goToNodes = () => props.changeUrl('logstash/nodes'); const goToPipelines = () => props.changeUrl('logstash/pipelines'); + const setupModeLogstashData = get(setupMode.data, 'logstash'); + let setupModeInstancesData = null; + if (setupMode.enabled && setupMode.data) { + const migratedNodesCount = Object.values(setupModeLogstashData.byUuid).filter(node => node.isFullyMigrated).length; + let totalNodesCount = Object.values(setupModeLogstashData.byUuid).length; + if (totalNodesCount === 0 && get(setupMode.data, 'logstash.detected.mightExist', false)) { + totalNodesCount = 1; + } + + const badgeColor = migratedNodesCount === totalNodesCount + ? 'secondary' + : 'danger'; + + setupModeInstancesData = ( + + + + {formatNumber(migratedNodesCount, 'int_commas')}/{formatNumber(totalNodesCount, 'int_commas')} + + + + ); + } + return (

- - +

@@ -86,27 +127,32 @@ export function LogstashPanel(props) { - -

- - { props.node_count }) }} - /> - -

-
+ + + +

+ + { nodesCount }) }} + /> + +

+
+
+ {setupModeInstancesData} +
@@ -116,7 +162,7 @@ export function LogstashPanel(props) { /> - { formatNumber(props.max_uptime, 'time_since') } + { props.max_uptime ? formatNumber(props.max_uptime, 'time_since') : 0 }

- { props.pipeline_count }) }} /> - +

@@ -177,14 +225,14 @@ export function LogstashPanel(props) { defaultMessage="With Memory Queues" /> - { props.queue_types[LOGSTASH.QUEUE_TYPES.MEMORY] } + { queueTypes[LOGSTASH.QUEUE_TYPES.MEMORY] || 0 } - { props.queue_types[LOGSTASH.QUEUE_TYPES.PERSISTED] } + { queueTypes[LOGSTASH.QUEUE_TYPES.PERSISTED] || 0 }
diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js index b9043529c9853..83bdb29406e25 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js @@ -15,6 +15,7 @@ export function NodeDetailStatus({ stats }) { transport_address: transportAddress, usedHeap, freeSpace, + totalSpace, documents, dataSize, indexCount, @@ -24,6 +25,8 @@ export function NodeDetailStatus({ stats }) { isOnline, } = stats; + const percentSpaceUsed = (freeSpace / totalSpace) * 100; + const metrics = [ { label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.transportAddress', { @@ -45,9 +48,9 @@ export function NodeDetailStatus({ stats }) { }, { label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.freeDiskSpaceLabel', { - defaultMessage: 'Free Disk Space' + defaultMessage: 'Free Disk Space', }), - value: formatMetric(freeSpace, '0.0 b'), + value: formatMetric(freeSpace, '0.0 b') + ' (' + formatMetric(percentSpaceUsed, '0,0.[00]', '%', { prependSpace: false }) + ')', 'data-test-subj': 'freeDiskSpace' }, { diff --git a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js index f976438f2f35d..f8073937a7e4d 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js +++ b/x-pack/legacy/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js @@ -330,6 +330,9 @@ export function ElasticsearchNodes({ clusterStatus, showCgroupMetricsElasticsear setupMode={setupMode} uuidField="resolver" nameField="name" + setupNewButtonLabel={i18n.translate('xpack.monitoring.elasticsearch.metricbeatMigration.setupNewButtonLabel', { + defaultMessage: 'Setup monitoring for new Elasticsearch node' + })} search={{ box: { incremental: true, diff --git a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js index a1f645e817219..3fbc9287e7ba3 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js +++ b/x-pack/legacy/plugins/monitoring/public/components/kibana/instances/instances.js @@ -227,6 +227,9 @@ export class KibanaInstances extends PureComponent { setupMode={setupMode} uuidField="kibana.uuid" nameField="name" + setupNewButtonLabel={i18n.translate('xpack.monitoring.kibana.metricbeatMigration.setupNewButtonLabel', { + defaultMessage: 'Setup monitoring for new Kibana instance' + })} search={{ box: { incremental: true, diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/__snapshots__/listing.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/__snapshots__/listing.test.js.snap index 7fa2bd05c2be9..987505dda09bb 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/__snapshots__/listing.test.js.snap +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/__snapshots__/listing.test.js.snap @@ -55,6 +55,7 @@ exports[`Listing should render with certain data pieces missing 1`] = ` ], } } + nameField="name" rows={ Array [ Object { @@ -80,6 +81,8 @@ exports[`Listing should render with certain data pieces missing 1`] = ` }, } } + setupMode={Object {}} + setupNewButtonLabel="Setup monitoring for new Logstash node" sorting={ Object { "sort": Object { @@ -90,6 +93,7 @@ exports[`Listing should render with certain data pieces missing 1`] = ` }, } } + uuidField="logstash.uuid" /> `; @@ -148,6 +152,7 @@ exports[`Listing should render with expected props 1`] = ` ], } } + nameField="name" rows={ Array [ Object { @@ -205,6 +210,8 @@ exports[`Listing should render with expected props 1`] = ` }, } } + setupMode={Object {}} + setupNewButtonLabel="Setup monitoring for new Logstash node" sorting={ Object { "sort": Object { @@ -215,5 +222,6 @@ exports[`Listing should render with expected props 1`] = ` }, } } + uuidField="logstash.uuid" /> `; diff --git a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js index c1f682c67119d..bc51d6278c142 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js +++ b/x-pack/legacy/plugins/monitoring/public/components/logstash/listing/listing.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent } from 'react'; +import React, { PureComponent, Fragment } from 'react'; import { get } from 'lodash'; -import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiPage, EuiLink, EuiPageBody, EuiPageContent, EuiPanel, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { ClusterStatus } from '..//cluster_status'; import { EuiMonitoringTable } from '../../table'; @@ -110,8 +110,9 @@ export class Listing extends PureComponent { } ]; } + render() { - const { data, stats, sorting, pagination, onTableChange } = this.props; + const { stats, sorting, pagination, onTableChange, data, setupMode } = this.props; const columns = this.getColumns(); const flattenedData = data.map(item => ({ ...item, @@ -123,6 +124,29 @@ export class Listing extends PureComponent { version: get(item, 'logstash.version', 'N/A'), })); + let netNewUserMessage = null; + if (setupMode.enabled && setupMode.data && get(setupMode.data, 'detected.mightExist')) { + netNewUserMessage = ( + + +

+ {i18n.translate('xpack.monitoring.logstash.nodes.metribeatMigration.netNewUserDescription', { + defaultMessage: `Based on your indices, we think you might have a Logstash node. Click the 'Setup monitoring' + button below to start monitoring this node.` + })} +

+
+ +
+ ); + } + return ( @@ -130,10 +154,17 @@ export class Listing extends PureComponent { + {netNewUserMessage} { }, sorting: { sort: 'asc' - } + }, + setupMode: {} }; const component = shallow(); @@ -79,7 +80,8 @@ describe('Listing', () => { }, sorting: { sort: 'asc' - } + }, + setupMode: {} }; const component = shallow(); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/flyout.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/flyout.js index d3ac352dc5c50..4f5e9135894b1 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/flyout.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/flyout/flyout.js @@ -20,19 +20,23 @@ import { EuiButtonEmpty, EuiLink, EuiText, + EuiCallOut, + EuiSpacer, + EuiCheckbox, } from '@elastic/eui'; import { getInstructionSteps } from '../instruction_steps'; import { Storage } from 'ui/storage'; import { STORAGE_KEY, ELASTICSEARCH_CUSTOM_ID } from '../../../../common/constants'; import { ensureMinimumTime } from '../../../lib/ensure_minimum_time'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { get } from 'lodash'; import { INSTRUCTION_STEP_SET_MONITORING_URL, INSTRUCTION_STEP_ENABLE_METRICBEAT, INSTRUCTION_STEP_DISABLE_INTERNAL } from '../constants'; -import { KIBANA_SYSTEM_ID } from '../../../../../telemetry/common/constants'; +import { KIBANA_SYSTEM_ID, BEATS_SYSTEM_ID } from '../../../../../telemetry/common/constants'; import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; import { setNewlyDiscoveredClusterUuid } from '../../../lib/setup_mode'; @@ -65,6 +69,7 @@ export class Flyout extends Component { [INSTRUCTION_STEP_DISABLE_INTERNAL]: false, }, checkingMigrationStatus: false, + userAcknowledgedNoClusterUuidPrompt: false }; } @@ -101,7 +106,9 @@ export class Flyout extends Component { checkForMigrationStatus = async () => { this.setState({ checkingMigrationStatus: true }); - await ensureMinimumTime(this.props.updateProduct(), 1000); + await ensureMinimumTime( + this.props.updateProduct(this.props.instance.uuid, true), 1000 + ); this.setState(state => ({ ...state, checkingMigrationStatus: false, @@ -177,7 +184,7 @@ export class Flyout extends Component { renderActiveStepNextButton() { const { product, productName } = this.props; - const { activeStep, esMonitoringUrl } = this.state; + const { activeStep, esMonitoringUrl, userAcknowledgedNoClusterUuidPrompt } = this.state; // It is possible that, during the migration steps, products are not reporting // monitoring data for a period of time outside the window of our server-side check @@ -205,6 +212,19 @@ export class Flyout extends Component { } } + // This is a possible scenario that come up during testing where logstash/beats + // is not outputing to ES, but has monitorining enabled. In these scenarios, + // the monitoring documents will not have a `cluster_uuid` so once migrated, + // the instance/node will actually live in the standalone cluster listing + // instead of the one it currently lives in. We need the user to understand + // this so we're going to force them to acknowledge a prompt saying this + if (product.isFullyMigrated && product.clusterUuid === null) { + // Did they acknowledge the prompt? + if (!userAcknowledgedNoClusterUuidPrompt) { + willDisableDoneButton = true; + } + } + if (willShowNextButton) { let isDisabled = false; let nextStep = null; @@ -237,7 +257,6 @@ export class Flyout extends Component { ); } - return ( + +

+ + Click here to view the Standalone cluster. + + ) + }} + /> +

+ + this.setState({ userAcknowledgedNoClusterUuidPrompt: e.target.checked })} + /> +
+ + + ); + } + return ( {this.renderActiveStep()} + {noClusterUuidPrompt} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/common_apm_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/common_apm_instructions.js new file mode 100644 index 0000000000000..643f299bca344 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/common_apm_instructions.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.statusTitle', { + defaultMessage: `Migration status` +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/disable_internal_collection_instructions.js new file mode 100644 index 0000000000000..827e535a57262 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/disable_internal_collection_instructions.js @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { formatTimestampToDuration } from '../../../../../common'; +import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle } from './common_apm_instructions'; + +export function getApmInstructionsForDisablingInternalCollection(product, meta, { + checkForMigrationStatus, + checkingMigrationStatus, + hasCheckedStatus, + autoCheckIntervalInMs, +}) { + const disableInternalCollectionStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.title', { + defaultMessage: 'Disable internal collection of the APM server\'s monitoring metrics' + }), + children: ( + + +

+ apm-server.yml + ) + }} + /> +

+
+ + + monitoring.enabled: false + + + +

+ +

+
+
+ ) + }; + + let migrationStatusStep = null; + if (!product || !product.isFullyMigrated) { + let status = null; + if (hasCheckedStatus) { + let lastInternallyCollectedMessage = ''; + // It is possible that, during the migration steps, products are not reporting + // monitoring data for a period of time outside the window of our server-side check + // and this is most likely temporary so we want to be defensive and not error out + // and hopefully wait for the next check and this state will be self-corrected. + if (product) { + const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; + const secondsSinceLastInternalCollectionLabel = + formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); + lastInternallyCollectedMessage = (); + } + + status = ( + + + +

+ +

+

+ {lastInternallyCollectedMessage} +

+
+
+ ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkingStatusButtonLabel', + { + defaultMessage: 'Checking...' + } + ); + } else { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.checkStatusButtonLabel', + { + defaultMessage: 'Check' + } + ); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate( + 'xpack.monitoring.metricbeatMigration.apmInstructions.disableInternalCollection.statusDescription', + { + defaultMessage: 'Check that no documents are coming from internal collection.' + } + )} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + disableInternalCollectionStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js new file mode 100644 index 0000000000000..cfc478761b62c --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/enable_metricbeat_instructions.js @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle } from './common_apm_instructions'; +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +export function getApmInstructionsForEnablingMetricbeat(product, _meta, { + esMonitoringUrl, + hasCheckedStatus, + checkingMigrationStatus, + checkForMigrationStatus, + autoCheckIntervalInMs +}) { + const securitySetup = ( + + + + + {` `} + + + + + ) + }} + /> + + )} + /> + + ); + const installMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.installMetricbeatTitle', { + defaultMessage: 'Install Metricbeat on the same server as the APM server' + }), + children: ( + +

+ + + +

+
+ ) + }; + + const enableMetricbeatModuleStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.enableMetricbeatModuleTitle', { + defaultMessage: 'Enable and configure the Beat x-pack module in Metricbeat' + }), + children: ( + + + metricbeat modules enable beat-xpack + + + +

+ hosts + ), + file: ( + modules.d/beat-xpack.yml + ) + }} + /> +

+
+ {securitySetup} +
+ ) + }; + + const configureMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.configureMetricbeatTitle', { + defaultMessage: 'Configure Metricbeat to send to the monitoring cluster' + }), + children: ( + + + metricbeat.yml + ) + }} + /> + + + + {`output.elasticsearch: + hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + + # Optional protocol and basic auth credentials. + #protocol: "https" + #username: "elastic" + #password: "changeme" +`} + + {securitySetup} + + + ) + }; + + const startMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.startMetricbeatTitle', { + defaultMessage: 'Start Metricbeat' + }), + children: ( + +

+ + + +

+
+ ) + }; + + let migrationStatusStep = null; + if (product.isInternalCollector || product.isNetNewUser) { + let status = null; + if (hasCheckedStatus) { + status = ( + + + + + ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.checkingStatusButtonLabel', { + defaultMessage: 'Checking for data...' + }); + } else { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.checkStatusButtonLabel', { + defaultMessage: 'Check for data' + }); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.apmInstructions.statusDescription', { + defaultMessage: 'Check that data is received from the Metricbeat' + })} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else if (product.isPartiallyMigrated || product.isFullyMigrated) { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + installMetricbeatStep, + enableMetricbeatModuleStep, + configureMetricbeatStep, + startMetricbeatStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/index.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/index.js new file mode 100644 index 0000000000000..bef6f9f36197e --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/apm/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getApmInstructionsForDisablingInternalCollection } from './disable_internal_collection_instructions'; +export { getApmInstructionsForEnablingMetricbeat } from './enable_metricbeat_instructions'; diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js new file mode 100644 index 0000000000000..0ada632f9779e --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/common_beats_instructions.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.statusTitle', { + defaultMessage: `Migration status` +}); + +export const UNDETECTED_BEAT_TYPE = 'beat'; +export const DEFAULT_BEAT_FOR_URLS = 'metricbeat'; diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js new file mode 100644 index 0000000000000..4a843ff286598 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/disable_internal_collection_instructions.js @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { formatTimestampToDuration } from '../../../../../common'; +import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle, UNDETECTED_BEAT_TYPE } from './common_beats_instructions'; + +export function getBeatsInstructionsForDisablingInternalCollection(product, meta, { + checkForMigrationStatus, + checkingMigrationStatus, + hasCheckedStatus, + autoCheckIntervalInMs, +}) { + const beatType = product.beatType; + const disableInternalCollectionStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.title', { + defaultMessage: 'Disable internal collection of {beatType}\'s monitoring metrics', + values: { + beatType: beatType || UNDETECTED_BEAT_TYPE + } + }), + children: ( + + +

+ {beatType}.yml + ) + }} + /> +

+
+ + + monitoring.enabled: false + + + +

+ +

+
+
+ ) + }; + + let migrationStatusStep = null; + if (!product || !product.isFullyMigrated) { + let status = null; + if (hasCheckedStatus) { + let lastInternallyCollectedMessage = ''; + // It is possible that, during the migration steps, products are not reporting + // monitoring data for a period of time outside the window of our server-side check + // and this is most likely temporary so we want to be defensive and not error out + // and hopefully wait for the next check and this state will be self-corrected. + if (product) { + const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; + const secondsSinceLastInternalCollectionLabel = + formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); + lastInternallyCollectedMessage = (); + } + + status = ( + + + +

+ +

+

+ {lastInternallyCollectedMessage} +

+
+
+ ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkingStatusButtonLabel', + { + defaultMessage: 'Checking...' + } + ); + } else { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.checkStatusButtonLabel', + { + defaultMessage: 'Check' + } + ); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate( + 'xpack.monitoring.metricbeatMigration.beatsInstructions.disableInternalCollection.statusDescription', + { + defaultMessage: 'Check that no documents are coming from internal collection.' + } + )} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + disableInternalCollectionStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js new file mode 100644 index 0000000000000..810304cf2a7ce --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/enable_metricbeat_instructions.js @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle, UNDETECTED_BEAT_TYPE, DEFAULT_BEAT_FOR_URLS } from './common_beats_instructions'; +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +export function getBeatsInstructionsForEnablingMetricbeat(product, _meta, { + esMonitoringUrl, + hasCheckedStatus, + checkingMigrationStatus, + checkForMigrationStatus, + autoCheckIntervalInMs +}) { + const beatType = product.beatType; + const securitySetup = ( + + + + + {` `} + + + + + ) + }} + /> + + )} + /> + + ); + const installMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.installMetricbeatTitle', { + defaultMessage: 'Install Metricbeat on the same server as this {beatType}', + values: { + beatType: beatType || UNDETECTED_BEAT_TYPE + } + }), + children: ( + +

+ + + +

+
+ ) + }; + + const httpEndpointUrl = `${ELASTIC_WEBSITE_URL}guide/en/beats/${beatType || DEFAULT_BEAT_FOR_URLS}` + + `/${DOC_LINK_VERSION}/http-endpoint.html`; + + const enableMetricbeatModuleStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.enableMetricbeatModuleTitle', { + defaultMessage: 'Enable and configure the Beat x-pack module in Metricbeat' + }), + children: ( + + + metricbeat modules enable beat-xpack + + + +

+ hosts + ), + file: ( + modules.d/beat-xpack.yml + ), + beatType: beatType || UNDETECTED_BEAT_TYPE + }} + /> +

+
+ + +

+ + + + ), + beatType: beatType || UNDETECTED_BEAT_TYPE + }} + /> +

+ + )} + /> + {securitySetup} +
+ ) + }; + + const configureMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.configureMetricbeatTitle', { + defaultMessage: 'Configure Metricbeat to send to the monitoring cluster' + }), + children: ( + + + metricbeat.yml + ) + }} + /> + + + + {`output.elasticsearch: + hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + + # Optional protocol and basic auth credentials. + #protocol: "https" + #username: "elastic" + #password: "changeme" +`} + + {securitySetup} + + + ) + }; + + const startMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.startMetricbeatTitle', { + defaultMessage: 'Start Metricbeat' + }), + children: ( + +

+ + + +

+
+ ) + }; + + let migrationStatusStep = null; + if (product.isInternalCollector || product.isNetNewUser) { + let status = null; + if (hasCheckedStatus) { + status = ( + + + + + ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.checkingStatusButtonLabel', { + defaultMessage: 'Checking for data...' + }); + } else { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.checkStatusButtonLabel', { + defaultMessage: 'Check for data' + }); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.beatsInstructions.statusDescription', { + defaultMessage: 'Check that data is received from the Metricbeat' + })} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else if (product.isPartiallyMigrated || product.isFullyMigrated) { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + installMetricbeatStep, + enableMetricbeatModuleStep, + configureMetricbeatStep, + startMetricbeatStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/index.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/index.js new file mode 100644 index 0000000000000..e6f002c56865a --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/beats/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getBeatsInstructionsForDisablingInternalCollection } from './disable_internal_collection_instructions'; +export { getBeatsInstructionsForEnablingMetricbeat } from './enable_metricbeat_instructions'; diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js index c12df364092a8..7075be3def9bb 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/get_instruction_steps.js @@ -12,27 +12,62 @@ import { getElasticsearchInstructionsForEnablingMetricbeat, getElasticsearchInstructionsForDisablingInternalCollection } from './elasticsearch'; +import { + getLogstashInstructionsForEnablingMetricbeat, + getLogstashInstructionsForDisablingInternalCollection, +} from './logstash'; +import { + getBeatsInstructionsForEnablingMetricbeat, + getBeatsInstructionsForDisablingInternalCollection, +} from './beats'; +import { + getApmInstructionsForEnablingMetricbeat, + getApmInstructionsForDisablingInternalCollection, +} from './apm'; import { INSTRUCTION_STEP_ENABLE_METRICBEAT, INSTRUCTION_STEP_DISABLE_INTERNAL } from '../constants'; +import { ELASTICSEARCH_CUSTOM_ID, APM_CUSTOM_ID } from '../../../../common/constants'; +import { KIBANA_SYSTEM_ID, LOGSTASH_SYSTEM_ID, BEATS_SYSTEM_ID } from '../../../../../telemetry/common/constants'; export function getInstructionSteps(productName, product, step, meta, opts) { switch (productName) { - case 'kibana': + case KIBANA_SYSTEM_ID: if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { return getKibanaInstructionsForEnablingMetricbeat(product, meta, opts); } if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { return getKibanaInstructionsForDisablingInternalCollection(product, meta, opts); } - case 'elasticsearch': + case ELASTICSEARCH_CUSTOM_ID: if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { return getElasticsearchInstructionsForEnablingMetricbeat(product, meta, opts); } if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { return getElasticsearchInstructionsForDisablingInternalCollection(product, meta, opts); } + case LOGSTASH_SYSTEM_ID: + if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { + return getLogstashInstructionsForEnablingMetricbeat(product, meta, opts); + } + if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { + return getLogstashInstructionsForDisablingInternalCollection(product, meta, opts); + } + case BEATS_SYSTEM_ID: + if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { + return getBeatsInstructionsForEnablingMetricbeat(product, meta, opts); + } + if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { + return getBeatsInstructionsForDisablingInternalCollection(product, meta, opts); + } + case APM_CUSTOM_ID: + if (step === INSTRUCTION_STEP_ENABLE_METRICBEAT) { + return getApmInstructionsForEnablingMetricbeat(product, meta, opts); + } + if (step === INSTRUCTION_STEP_DISABLE_INTERNAL) { + return getApmInstructionsForDisablingInternalCollection(product, meta, opts); + } } return []; } diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js new file mode 100644 index 0000000000000..642add4d43fc4 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/common_logstash_instructions.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const statusTitle = i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.statusTitle', { + defaultMessage: `Migration status` +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js new file mode 100644 index 0000000000000..9efc5a26ef822 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/disable_internal_collection_instructions.js @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { formatTimestampToDuration } from '../../../../../common'; +import { CALCULATE_DURATION_SINCE } from '../../../../../common/constants'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle } from './common_logstash_instructions'; + +export function getLogstashInstructionsForDisablingInternalCollection(product, meta, { + checkForMigrationStatus, + checkingMigrationStatus, + hasCheckedStatus, + autoCheckIntervalInMs, +}) { + const disableInternalCollectionStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.title', { + defaultMessage: 'Disable internal collection of Logstash monitoring metrics' + }), + children: ( + + +

+ logstash.yml + ) + }} + /> +

+
+ + + xpack.monitoring.enabled: false + + + +

+ +

+
+
+ ) + }; + + let migrationStatusStep = null; + if (!product || !product.isFullyMigrated) { + let status = null; + if (hasCheckedStatus) { + let lastInternallyCollectedMessage = ''; + // It is possible that, during the migration steps, products are not reporting + // monitoring data for a period of time outside the window of our server-side check + // and this is most likely temporary so we want to be defensive and not error out + // and hopefully wait for the next check and this state will be self-corrected. + if (product) { + const lastInternallyCollectedTimestamp = product.lastInternallyCollectedTimestamp || product.lastTimestamp; + const secondsSinceLastInternalCollectionLabel = + formatTimestampToDuration(lastInternallyCollectedTimestamp, CALCULATE_DURATION_SINCE); + lastInternallyCollectedMessage = (); + } + + status = ( + + + +

+ +

+

+ {lastInternallyCollectedMessage} +

+
+
+ ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkingStatusButtonLabel', + { + defaultMessage: 'Checking...' + } + ); + } else { + buttonLabel = i18n.translate( + 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.checkStatusButtonLabel', + { + defaultMessage: 'Check' + } + ); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate( + 'xpack.monitoring.metricbeatMigration.logstashInstructions.disableInternalCollection.statusDescription', + { + defaultMessage: 'Check that no documents are coming from internal collection.' + } + )} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + disableInternalCollectionStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js new file mode 100644 index 0000000000000..bbdd208ad3628 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/enable_metricbeat_instructions.js @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import React, { Fragment } from 'react'; +import { + EuiSpacer, + EuiCodeBlock, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiCallOut, + EuiText +} from '@elastic/eui'; +import { Monospace } from '../components/monospace'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { statusTitle } from './common_logstash_instructions'; +import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +export function getLogstashInstructionsForEnablingMetricbeat(product, _meta, { + esMonitoringUrl, + hasCheckedStatus, + checkingMigrationStatus, + checkForMigrationStatus, + autoCheckIntervalInMs +}) { + const securitySetup = ( + + + + + {` `} + + + + + ) + }} + /> + + )} + /> + + ); + const installMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.installMetricbeatTitle', { + defaultMessage: 'Install Metricbeat on the same server as Logstash' + }), + children: ( + +

+ + + +

+
+ ) + }; + + const enableMetricbeatModuleStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.enableMetricbeatModuleTitle', { + defaultMessage: 'Enable and configure the Logstash x-pack module in Metricbeat' + }), + children: ( + + + metricbeat modules enable logstash-xpack + + + +

+ hosts + ), + file: ( + modules.d/logstash-xpack.yml + ) + }} + /> +

+
+ {securitySetup} +
+ ) + }; + + const configureMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.configureMetricbeatTitle', { + defaultMessage: 'Configure Metricbeat to send to the monitoring cluster' + }), + children: ( + + + metricbeat.yml + ) + }} + /> + + + + {`output.elasticsearch: + hosts: ["${esMonitoringUrl}"] ## Monitoring cluster + + # Optional protocol and basic auth credentials. + #protocol: "https" + #username: "elastic" + #password: "changeme" +`} + + {securitySetup} + + + ) + }; + + const startMetricbeatStep = { + title: i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.startMetricbeatTitle', { + defaultMessage: 'Start Metricbeat' + }), + children: ( + +

+ + + +

+
+ ) + }; + + let migrationStatusStep = null; + if (product.isInternalCollector || product.isNetNewUser) { + let status = null; + if (hasCheckedStatus) { + status = ( + + + + + ); + } + + let buttonLabel; + if (checkingMigrationStatus) { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.checkingStatusButtonLabel', { + defaultMessage: 'Checking for data...' + }); + } else { + buttonLabel = i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.checkStatusButtonLabel', { + defaultMessage: 'Check for data' + }); + } + + migrationStatusStep = { + title: statusTitle, + status: 'incomplete', + children: ( + + + + +

+ {i18n.translate('xpack.monitoring.metricbeatMigration.logstashInstructions.statusDescription', { + defaultMessage: 'Check that data is received from the Metricbeat' + })} +

+
+
+ + + {buttonLabel} + + +
+ {status} +
+ ) + }; + } + else if (product.isPartiallyMigrated || product.isFullyMigrated) { + migrationStatusStep = { + title: statusTitle, + status: 'complete', + children: ( + +

+ +

+
+ ) + }; + } + + return [ + installMetricbeatStep, + enableMetricbeatModuleStep, + configureMetricbeatStep, + startMetricbeatStep, + migrationStatusStep + ]; +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/index.js b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/index.js new file mode 100644 index 0000000000000..c140c69db6bcc --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/metricbeat_migration/instruction_steps/logstash/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getLogstashInstructionsForDisablingInternalCollection } from './disable_internal_collection_instructions'; +export { getLogstashInstructionsForEnablingMetricbeat } from './enable_metricbeat_instructions'; diff --git a/x-pack/legacy/plugins/spaces/types.d.ts b/x-pack/legacy/plugins/monitoring/public/components/renderers/lib/find_new_uuid.js similarity index 60% rename from x-pack/legacy/plugins/spaces/types.d.ts rename to x-pack/legacy/plugins/monitoring/public/components/renderers/lib/find_new_uuid.js index 98a20203c13c1..d7fcb6beb0d79 100644 --- a/x-pack/legacy/plugins/spaces/types.d.ts +++ b/x-pack/legacy/plugins/monitoring/public/components/renderers/lib/find_new_uuid.js @@ -3,8 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; -export interface SpacesPlugin { - getSpaceId(request: Record): string; +export function findNewUuid(oldUuids, newUuids) { + for (const newUuid of newUuids) { + if (oldUuids.indexOf(newUuid) === -1) { + return newUuid; + } + } } diff --git a/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js index 097b5e2428cfe..9e664aada7efb 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/components/renderers/setup_mode.js @@ -6,42 +6,93 @@ import React from 'react'; import { getSetupModeState, initSetupModeState, updateSetupModeData } from '../../lib/setup_mode'; import { Flyout } from '../metricbeat_migration/flyout'; -import { ELASTICSEARCH_CUSTOM_ID } from '../../../common/constants'; +import { findNewUuid } from './lib/find_new_uuid'; export class SetupModeRenderer extends React.Component { state = { renderState: false, isFlyoutOpen: false, instance: null, + newProduct: null, + isSettingUpNew: false, } componentWillMount() { const { scope, injector } = this.props; - initSetupModeState(scope, injector, () => this.setState({ renderState: true })); + initSetupModeState(scope, injector, (_oldData) => { + const newState = { renderState: true }; + const { productName } = this.props; + if (!productName) { + this.setState(newState); + return; + } + + const setupModeState = getSetupModeState(); + if (!setupModeState.enabled || !setupModeState.data) { + this.setState(newState); + return; + } + + const data = setupModeState.data[productName]; + const oldData = _oldData ? _oldData[productName] : null; + if (data && oldData) { + const newUuid = findNewUuid(Object.keys(oldData.byUuid), Object.keys(data.byUuid)); + if (newUuid) { + newState.newProduct = data.byUuid[newUuid]; + } + } + + this.setState(newState); + }); + } + + reset() { + this.setState({ + renderState: false, + isFlyoutOpen: false, + instance: null, + newProduct: null, + isSettingUpNew: false, + }); } getFlyout(data, meta) { const { productName } = this.props; - const { isFlyoutOpen, instance } = this.state; + const { isFlyoutOpen, instance, isSettingUpNew, newProduct } = this.state; if (!data || !isFlyoutOpen) { return null; } - let product = instance ? data.byUuid[instance.uuid] : null; - const isFullyOrPartiallyMigrated = data.totalUniquePartiallyMigratedCount === data.totalUniqueInstanceCount - || data.totalUniqueFullyMigratedCount === data.totalUniqueInstanceCount; - if (!product && productName === ELASTICSEARCH_CUSTOM_ID && isFullyOrPartiallyMigrated) { - product = Object.values(data.byUuid)[0]; + let product = null; + if (newProduct) { + product = newProduct; + } + // For new instance discovery flow, we pass in empty instance object + else if (instance && Object.keys(instance).length) { + product = data.byUuid[instance.uuid]; + } + + if (!product) { + const uuids = Object.values(data.byUuid); + if (uuids.length && !isSettingUpNew) { + product = uuids[0]; + } + else { + product = { + isNetNewUser: true + }; + } } return ( this.setState({ isFlyoutOpen: false })} + onClose={() => this.reset()} productName={productName} product={product} meta={meta} instance={instance} updateProduct={updateSetupModeData} + isSettingUpNew={isSettingUpNew} /> ); } @@ -59,6 +110,7 @@ export class SetupModeRenderer extends React.Component { data = setupModeState.data; } } + const meta = setupModeState.data ? setupModeState.data._meta : null; return render({ @@ -67,7 +119,7 @@ export class SetupModeRenderer extends React.Component { enabled: setupModeState.enabled, productName, updateSetupModeData, - openFlyout: (instance) => this.setState({ isFlyoutOpen: true, instance }), + openFlyout: (instance, isSettingUpNew) => this.setState({ isFlyoutOpen: true, instance, isSettingUpNew }), closeFlyout: () => this.setState({ isFlyoutOpen: false }), }, flyoutComponent: this.getFlyout(data, meta), diff --git a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js index 09e60bdd6b8c2..a52bafd1858cd 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js +++ b/x-pack/legacy/plugins/monitoring/public/components/table/eui_table.js @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { get } from 'lodash'; import { EuiInMemoryTable, EuiBadge, EuiButtonEmpty, - EuiHealth + EuiHealth, + EuiButton, + EuiSpacer } from '@elastic/eui'; import { ELASTICSEARCH_CUSTOM_ID } from '../../../common/constants'; import { i18n } from '@kbn/i18n'; @@ -46,6 +48,7 @@ export class EuiMonitoringTable extends React.PureComponent { return column; }); + let footerContent = null; if (setupMode && setupMode.enabled) { columns.push({ name: i18n.translate('xpack.monitoring.euiTable.setupStatusTitle', { @@ -185,6 +188,15 @@ export class EuiMonitoringTable extends React.PureComponent { return null; } }); + + footerContent = ( + + + setupMode.openFlyout({}, true)}> + {props.setupNewButtonLabel} + + + ); } return ( @@ -195,6 +207,7 @@ export class EuiMonitoringTable extends React.PureComponent { columns={columns} {...props} /> + {footerContent}
); } diff --git a/x-pack/legacy/plugins/monitoring/public/directives/main/index.html b/x-pack/legacy/plugins/monitoring/public/directives/main/index.html index 823b3f763521c..7608c7743651b 100644 --- a/x-pack/legacy/plugins/monitoring/public/directives/main/index.html +++ b/x-pack/legacy/plugins/monitoring/public/directives/main/index.html @@ -6,13 +6,18 @@