diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 52648656bfa1..bc7e7b27c7d5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT +* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT @xinruiba diff --git a/CHANGELOG.md b/CHANGELOG.md index 0adcbc43f515..fe54b0d0deec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,11 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 - [CVE-2020-36604] Employ a patched version of hoek `6.1.3` ([#6148](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6148)) +- [CVE-2023-45857] Bump `axios` from `0.27.2` to `1.6.1` ([#5470](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5470)) +- Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 +- [WS-2021-0638] Bump mocha from `7.2.0` to `10.1.0` ([#2711](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2711)) - [CVE-2024-27088] Bump es5-ext from `0.10.59` to `0.10.64` ([#6021](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6021)) +- [CVE-2024-28849] Bump follow-redirect from `1.15.4` to `1.15.6` ([#6199](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6199)) ### 📈 Features/Enhancements @@ -25,6 +29,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) - [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) - [Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) +- [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) +- [Workspace] Add ACL related functions ([#5084](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5084/)) - [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) - [Multiple Datasource] Refactoring create and edit form to use authentication registry ([#6002](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6002)) - [Multiple Datasource] Handles auth methods from auth registry in DataSource SavedObjects Client Wrapper ([#6062](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6062)) @@ -43,13 +49,22 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add a workspace client in workspace plugin ([#6094](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6094)) - [Multiple Datasource] Add component to show single selected data source in read only mode ([#6125](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6125)) - [Multiple Datasource] Add data source aggregated view to show all compatible data sources or only show used data sources ([#6129](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6129)) +- [Multiple Datasource] Add datasource version number to newly created data source object([#6178](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6178)) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) - +- [Multiple Datasource] Use data source filter function before rendering ([#6175](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6175)) +- [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) +- [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) +- [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) +- [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058)) +- [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) +- [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) ### 🐛 Bug Fixes +- [Chore] Update deprecated url methods (url.parse(), url.format()) ([#2910](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2910)) +- Cleanup unused url ([#3847](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3847)) - [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) - [BUG][Discover] Add key to index pattern options for support deplicate index pattern names([#5946](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5946)) - [Discover] Fix table cell content overflowing in Safari ([#5948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5948)) @@ -62,19 +77,39 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) - [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) - [BUG][Multiple Datasource] Fix data source filter bug and add tests ([#6152](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6152)) +- [BUG][Multiple Datasource] Fix obsolete snapshots for test within data source management plugin ([#6185](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6185)) ### 🚞 Infrastructure +- Add an achievement badger to the PR ([#3721](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3721)) +- Re-enable CI workflows for feature branches ([#2908](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2908)) +- [Tests] Add Github workflow for Test Orchestrator in FT Repo to run cypress tests within Dashboards repo ([#5725](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5725)) +- Upgrade yarn version to be compatible with @opensearch-project/opensearch ([#3443](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3443)) + ### 📝 Documentation - Fix link to documentation for geoHash precision ([#5967](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5967)) +- [Doc] Add COMMUNICATIONS.md with info about Slack, forum, office hours ([#3837](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3837)) +- Add plugin development section in DEVELOPER_GUIDE.md ([#3989](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3989)) ### 🛠 Maintenance +- Removes `minimatch` manual resolution ([#3019](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3019)) +- Upgrade `vega-lite` dependency from `4.17.0` to `^5.6.0` ([#3076](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3076)). Backwards-compatible version included in v2.5.0 release. +- Bump `js-yaml` from `3.14.0` to `4.1.0` ([#3770](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3770)) +- Bump `chromedriver` from `107.0.3` to `119.0.1` ([#5465](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5465)) +- Bump `typescript` resolution from `4.0.2` to `4.6.4` ([#5470](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5470)) +- Bump `chromedriver` dependency to `121.0.1"` ([#5926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5926)) +- [Console] Remove unused ul element and its custom styling ([#3993](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3993)) +- Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) - Add @BionIT as a maintainer ([#5988](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5988)) +- Move @kristenTian to emeritus maintainer ([#6136](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6136)) +- Add @xinruiba as a maintainer ([#6217](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6217)) ### 🪛 Refactoring +- Remove unused Sass in `tile_map` plugin ([#4110](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4110)) + ### 🔩 Tests - Add functional test cypress workflow improvements and enable the workflow for in-house Dashboards tests ([#6061](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6061)) @@ -90,10 +125,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### 🛡 Security -- [WS-2021-0638] Bump mocha from `7.2.0` to `10.1.0` ([#2711](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2711)) - Add support for TLS v1.3 ([#5133](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5133)) - [CVE-2023-45133] Bump all babel dependencies from `7.16.x` to `7.22.9` to fix upstream vulnerability ([#5428](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5428)) -- [CVE-2023-45857] Bump `axios` from `0.27.2` to `1.6.1` ([#5470](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5470)) - [CVE-2023-26159] Bump `follow-redirects` from `1.15.2` to `1.15.4` ([#5669](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5669)) - [CVE-2023-52079] Bump `msgpackr` from `1.9.7` to `1.10.1` ([#5803](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5803)) - [CVE-2020-8203] Bump `cheerio` from `0.22.0` to `1.0.0-rc.1` to fix vulnerable `lodash` dependency ([#5797](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5797)) @@ -103,26 +136,20 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add support for read-only mode through tenants ([#4498](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4498)) - Replace OuiSelect component with OuiSuperSelect in data-source plugin ([#5315](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5315)) - [Workspace] Add core workspace service module to enable the implementation of workspace features within OSD plugins ([#5092](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5092)) -- [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075)) -- [Workspace] Add ACL related functions ([#5084](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5084/)) - [Decouple] Add new cross compatibility check core service which export functionality for plugins to verify if their OpenSearch plugin counterpart is installed on the cluster or has incompatible version to configure the plugin behavior([#4710](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4710)) - [Discover] Add long numerals support [#5592](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5592) - [Discover] Display inner properties in the left navigation bar [#5429](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5429) - [Discover] Added customizable pagination options based on Discover UI settings [#5610](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5610) -- [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) - [PM] Enhance single version requirements imposed during bootstrapping ([#5675](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5675)) - [Custom Branding] Relative URL should be allowed for logos ([#5572](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5572)) - Revert to legacy discover table and add toggle to new discover table ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) - [Discover] Add collapsible and resizeable sidebar ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) -- [Discover] Enhanced the data source selector with added sorting functionality ([#5609](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5609)) +- [Discover] Enhanced the data source selector with added sorting functionality ([#5719](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5719)) - [Multiple Datasource] Add datasource picker component and use it in devtools and tutorial page when multiple datasource is enabled ([#5756](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5756)) - [Multiple Datasource] Add datasource picker to import saved object flyout when multiple data source is enabled ([#5781](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5781)) ### 🐛 Bug Fixes -- [Chore] Update deprecated url methods (url.parse(), url.format()) ([#2910](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2910)) -- Cleanup unused url ([#3847](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3847)) -- [TSVB, Dashboards] Fix inconsistent dark mode code editor themes ([#4609](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4609)) - Fix `maps.proxyOpenSearchMapsServiceInMaps` config definition so it can be set ([#5170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5170)) - [Discover] Fix inactive state on 'Discover' tab in side navigation menu ([#5432](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5432)) - [BUG] Add platform "darwin-arm64" to unit test ([#5290](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5290)) @@ -147,44 +174,28 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### 🚞 Infrastructure -- Re-enable CI workflows for feature branches ([#2908](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2908)) -- Upgrade yarn version to be compatible with @opensearch-project/opensearch ([#3443](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3443)) -- Add an achievement badger to the PR ([#3721](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3721)) - [CI] Enable inputs for manually triggered Cypress test jobs ([#5134](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5134)) - [CI] Replace usage of deprecated `set-output` in workflows ([#5340](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5340)) - [Chore] Add `--security` for `opensearch snapshot` and `opensearch_dashboards` to configure local setup with the security plugin ([#5451](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5451)) -- [Tests] Add Github workflow for Test Orchestrator in FT Repo to run cypress tests within Dashboards repo ([#5725](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5725)) - [Chore] Updates default dev environment security credentials ([#5736](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5736)) - [Tests] Baseline screenshots for area and tsvb functional tests ([#5915](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5915)) ### 📝 Documentation -- [Doc] Add COMMUNICATIONS.md with info about Slack, forum, office hours ([#3837](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3837)) -- Add plugin development section in DEVELOPER_GUIDE.md ([#3989](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3989)) - Remove ftr test step from PR template ([#5217](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5217)) - [Doc] Update EUI doc site links to point to OUI doc site ([#5293](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5293)) - Adds Developer Docs generation using Docsify to the repository ([#5977](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5977)) ### 🛠 Maintenance -- Removes `minimatch` manual resolution ([#3019](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3019)) -- Upgrade `vega-lite` dependency from `4.17.0` to `^5.6.0` ([#3076](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3076)). Backwards-compatible version included in v2.5.0 release. -- Bump `js-yaml` from `3.14.0` to `4.1.0` ([#3770](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3770)) - Replace `node-sass` with `sass-embedded` ([#5338](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5338)) -- Bump `chromedriver` from `107.0.3` to `119.0.1` ([#5465](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5465)) -- Bump `typescript` resolution from `4.0.2` to `4.6.4` ([#5470](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5470)) - Bump `OUI` to `1.5.1` ([#5862](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5862)) - Add @SuZhou-Joe as a maintainer ([#5594](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5594)) - Move @seanneumann to emeritus maintainer ([#5634](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5634)) - Remove `ui-select` dev dependency ([#5660](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5660)) -- Bump `chromedriver` dependency to `121.0.1"` ([#5926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5926)) -- Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) -- Move @kristenTian to emeritus maintainer ([#6136](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6136)) ### 🪛 Refactoring -- [Console] Remove unused ul element and its custom styling ([#3993](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3993)) -- Remove unused Sass in `tile_map` plugin ([#4110](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4110)) - [Home] Remove unused tutorials ([#5212](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5212)) - [UiSharedDeps] Standardize theme JSON imports to be light/dark-mode aware ([#5662](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5662)) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 366445b4513b..49f3caa668e3 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -26,6 +26,8 @@ This guide applies to all development within the OpenSearch Dashboards project a - [React](#react) - [API endpoints](#api-endpoints) +> To view these docs and all the readme's in this repository as webpages, visit https://opensearch-project.github.io/OpenSearch-Dashboards/docs/index.html#/ + ## Getting started guide This guide is for any developer who wants a running local development environment where you can make, see, and test changes. It's opinionated to get you running as quickly and easily as possible, but it's not the only way to set up a development environment. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 9ba46abf4d32..4f9791b3949f 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -23,6 +23,7 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Su Zhou | [SuZhou-Joe](https://github.com/SuZhou-Joe) | Amazon | | Yulong Ruan | [ruanyl](https://github.com/ruanyl) | Amazon | | Lu Yu | [BionIT](https://github.com/BionIT) | Amazon | +| Xinrui Bai | [xinruiba](https://github.com/xinruiba) | Amazon | ## Emeritus diff --git a/release-notes/opensearch-dashboards.release-notes-2.13.0.md b/release-notes/opensearch-dashboards.release-notes-2.13.0.md new file mode 100644 index 000000000000..5a9b51383044 --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-2.13.0.md @@ -0,0 +1,59 @@ +## Version 2.13.0 Release Notes + +### 🛡 Security + +- Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 +- [CVE-2020-36604] Employ a patched version of hoek `6.1.3` ([#6148](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6148)) +- [CVE-2024-27088] Bump es5-ext from `0.10.59` to `0.10.64` ([#6021](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6021)) +- [CVE-2024-28849] Bump follow-redirect from `1.15.4` to `1.15.6` ([#6199](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6201)) + +### 📈 Features/Enhancements + +- [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042)) +- [Multiple Datasource] Add interfaces to register add-on authentication method from plug-in module ([#5851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5851)) +- [Multiple Datasource] Able to Hide "Local Cluster" option from datasource DropDown ([#5827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5827)) +- [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) +- [Multiple Datasource] Concatenate data source name with index pattern name and change delimiter to double colon ([#5907](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5907)) +- [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) +- [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) +- [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) +- [Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) +- [Multiple Datasource] Refactoring create and edit form to use authentication registry ([#6002](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6002)) +- [Multiple Datasource] Handles auth methods from auth registry in DataSource SavedObjects Client Wrapper ([#6062](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6062)) +- [Multiple Datasource] Expose a few properties for customize the appearance of the data source selector component ([#6057](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6057)) +- [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) +- [Multiple Datasource] Adds a session token to AWS credentials ([#6103](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6103)) +- [Multiple Datasource] Test connection schema validation for registered auth types ([#6109](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6109)) +- [Multiple DataSource] DataSource creation and edition page improvement to better support registered auth types ([#6122](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6122)) +- [Multiple Datasource] Export DataSourcePluginRequestContext at top level for plugins to use ([#6108](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6108)) +- [Multiple Datasource] Improves connection pooling support for AWSSigV4 clients in data sources ([#6135](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6135)) +- [Multiple Datasource] Add datasource version number to newly created data source object([#6178](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6178)) +- [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6186](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6186)) +- Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) +- Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) +- [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) +- [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) + +### 🐛 Bug Fixes + +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [BUG][Discover] Add key to index pattern options for support deplicate index pattern names([#5946](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5946)) +- [Discover] Fix table cell content overflowing in Safari ([#5948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5948)) +- [BUG][MD]Fix schema for test connection to separate validation based on auth type ([#5997](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5997)) +- [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956)) +- [BUG][Multiple Datasource] Fix missing customApiRegistryPromise param for test connection ([#5944](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5944)) +- [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) +- [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) +- [BUG][Multiple Datasource] Fix data source filter bug and add tests ([#6152](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6152)) + +### 📝 Documentation + +- Fix link to documentation for geoHash precision ([#5967](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5967)) + +### 🛠 Maintenance + +- Bump `chromedriver` dependency to `121.0.1"` ([#5926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5926)) +- Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) +- Add @BionIT as a maintainer ([#5988](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5988)) diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts index 2b50ba8e9b35..b2a6ae6fda65 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts @@ -5,7 +5,7 @@ import { mockUuidv4 } from './__mocks__'; import { SavedObjectReference, SavedObjectsImportRetry } from 'opensearch-dashboards/public'; -import { SavedObject } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsErrorHelpers } from '..'; import { checkConflictsForDataSource, @@ -24,6 +24,45 @@ const createObject = (type: string, id: string): SavedObjectType => ({ references: (Symbol() as unknown) as SavedObjectReference[], }); +const createVegaVisualizationObject = (id: string): SavedObjectType => { + const visState = + id.split('_').length > 1 + ? '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}' + : '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n }\\n }\\n}"}}'; + return { + type: 'visualization', + id, + attributes: { title: 'some-title', visState }, + references: + id.split('_').length > 1 + ? [{ id: id.split('_')[0], type: 'data-source', name: 'dataSource' }] + : [], + } as SavedObjectType; +}; + +const getSavedObjectClient = (): SavedObjectsClientContract => { + const savedObject = {} as SavedObjectsClientContract; + savedObject.get = jest.fn().mockImplementation((type, id) => { + if (type === 'data-source' && id === 'old-datasource-id') { + return Promise.resolve({ + attributes: { + title: 'old-datasource-title', + }, + }); + } else if (type === 'data-source') { + return Promise.resolve({ + attributes: { + title: 'some-datasource-title', + }, + }); + } + + return Promise.resolve(undefined); + }); + + return savedObject; +}; + const getResultMock = { conflict: (type: string, id: string) => { const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; @@ -56,6 +95,7 @@ describe('#checkConflictsForDataSource', () => { retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; dataSourceId?: string; + savedObjectsClient?: SavedObjectsClientContract; }): ConflictsForDataSourceParams => { return { ...partial }; }; @@ -140,4 +180,123 @@ describe('#checkConflictsForDataSource', () => { importIdMap: new Map(), }); }); + + /* + Vega test cases + */ + it('will attach datasource name to Vega spec when importing from local to datasource', async () => { + const vegaSavedObject = createVegaVisualizationObject('some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'some-datasource-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'some-datasource-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: some-datasource-title\\n }\\n }\\n}"}}', + }, + id: 'some-datasource-id_some-object-id', + references: [ + { + id: 'some-datasource-id', + type: 'data-source', + name: 'dataSource', + }, + ], + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'some-datasource-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); + + it('will not change Vega spec when importing from datasource to different datasource', async () => { + const vegaSavedObject = createVegaVisualizationObject('old-datasource-id_some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'some-datasource-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'some-datasource-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + id: 'some-datasource-id_some-object-id', + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'some-datasource-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); + + it('will not change Vega spec when dataSourceTitle is undefined', async () => { + const vegaSavedObject = createVegaVisualizationObject('old-datasource-id_some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'nonexistent-datasource-title-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'nonexistent-datasource-title-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + id: 'nonexistent-datasource-title-id_some-object-id', + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'nonexistent-datasource-title-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); }); diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts index 6611b01dfb2a..a0400c57d023 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts @@ -3,13 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObject, SavedObjectsImportError, SavedObjectsImportRetry } from '../types'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from '../types'; +import { + extractVegaSpecFromSavedObject, + getDataSourceTitleFromId, + updateDataSourceNameInVegaSpec, +} from './utils'; export interface ConflictsForDataSourceParams { objects: Array>; ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; dataSourceId?: string; + savedObjectsClient?: SavedObjectsClientContract; } interface ImportIdMapEntry { @@ -31,6 +42,7 @@ export async function checkConflictsForDataSource({ ignoreRegularConflicts, retries = [], dataSourceId, + savedObjectsClient, }: ConflictsForDataSourceParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; @@ -43,6 +55,12 @@ export async function checkConflictsForDataSource({ (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), new Map() ); + + const dataSourceTitle = + !!dataSourceId && !!savedObjectsClient + ? await getDataSourceTitleFromId(dataSourceId, savedObjectsClient) + : undefined; + objects.forEach((object) => { const { type, @@ -74,6 +92,33 @@ export async function checkConflictsForDataSource({ /** * Only update importIdMap and filtered objects */ + + // Some visualization types will need special modifications, like Vega visualizations + if (object.type === 'visualization') { + const vegaSpec = extractVegaSpecFromSavedObject(object); + + if (!!vegaSpec && !!dataSourceTitle) { + const updatedVegaSpec = updateDataSourceNameInVegaSpec({ + spec: vegaSpec, + newDataSourceName: dataSourceTitle, + }); + + // @ts-expect-error + const visStateObject = JSON.parse(object.attributes?.visState); + visStateObject.params.spec = updatedVegaSpec; + + // @ts-expect-error + object.attributes.visState = JSON.stringify(visStateObject); + if (!!dataSourceId) { + object.references.push({ + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }); + } + } + } + const omitOriginId = ignoreRegularConflicts; importIdMap.set(`${type}:${id}`, { id: `${dataSourceId}_${rawId}`, omitOriginId }); filteredObjects.push({ ...object, id: `${dataSourceId}_${rawId}` }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index f1118842c967..1a9e218f169d 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -115,6 +115,37 @@ const visualizationObj = { }, }, }; + +const getVegaVisualizationObj = (id: string) => ({ + type: 'visualization', + id, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n }\\n }\\n}"}}', + }, + references: [], + namespaces: ['default'], + version: 'some-version', + updated_at: 'some-date', +}); + +const getVegaMDSVisualizationObj = (id: string, dataSourceId: string) => ({ + type: 'visualization', + id: dataSourceId ? `${dataSourceId}_${id}` : id, + attributes: { + title: 'some-other-title', + visState: + '{"title":"some-other-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + references: [ + { + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }, + ], +}); // non-multi-namespace types shouldn't have origin IDs, but we include test cases to ensure it's handled gracefully // non-multi-namespace types by definition cannot result in an unresolvable conflict, so we don't include test cases for those const importId3 = 'id-foo'; @@ -142,8 +173,11 @@ describe('#createSavedObjects', () => { overwrite?: boolean; dataSourceId?: string; dataSourceTitle?: string; + savedObjectsCustomClient?: jest.Mocked; }): CreateSavedObjectsParams => { - savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient = !!partial.savedObjectsCustomClient + ? partial.savedObjectsCustomClient + : savedObjectsClientMock.create(); bulkCreate = savedObjectsClient.bulkCreate; return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; }; @@ -490,6 +524,29 @@ describe('#createSavedObjects', () => { expect(results).toEqual(expectedResultsWithDataSource); }; + const testVegaVisualizationsWithDataSources = async (params: { + objects: SavedObject[]; + expectedFilteredObjects: Array>; + dataSourceId?: string; + dataSourceTitle?: string; + }) => { + const savedObjectsCustomClient = savedObjectsClientMock.create(); + + const options = setupParams({ + ...params, + savedObjectsCustomClient, + }); + savedObjectsCustomClient.bulkCreate = jest.fn().mockResolvedValue({ + saved_objects: params.objects.map((obj) => { + return getResultMock.success(obj, options); + }), + }); + + const results = await createSavedObjects(options); + + expect(results.createdObjects).toMatchObject(params.expectedFilteredObjects); + }; + describe('with an undefined namespace', () => { test('calls bulkCreate once with input objects', async () => { await testBulkCreateObjects(); @@ -546,4 +603,61 @@ describe('#createSavedObjects', () => { ); }); }); + + describe('with a data source for Vega saved objects', () => { + test('can attach a data source name to the Vega spec if there is a local query', async () => { + const objects = [getVegaVisualizationObj('some-vega-id')]; + const expectedObject = getVegaVisualizationObj('some-vega-id'); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-title_dataSourceName', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: dataSourceName\\n }\\n }\\n}"}}', + }, + id: 'some-vega-id', + references: [ + { + id: 'some-datasource-id', + type: 'data-source', + name: 'dataSource', + }, + ], + }, + ]; + await testVegaVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + + test('will not update the data source name in the Vega spec if no local cluster queries', async () => { + const objects = [getVegaMDSVisualizationObj('some-vega-id', 'old-datasource-id')]; + const expectedObject = getVegaMDSVisualizationObj('some-vega-id', 'old-datasource-id'); + expectedObject.references.push({ + id: 'some-datasource-id', + name: 'dataSource', + type: 'data-source', + }); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-other-title_dataSourceName', + visState: + '{"title":"some-other-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + }, + ]; + await testVegaVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + }); }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 6b0015851baf..fa471d0d44d9 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -31,6 +31,7 @@ import { SavedObject, SavedObjectsClientContract, SavedObjectsImportError } from '../types'; import { extractErrors } from './extract_errors'; import { CreatedObject } from './types'; +import { extractVegaSpecFromSavedObject, updateDataSourceNameInVegaSpec } from './utils'; interface CreateSavedObjectsParams { objects: Array>; @@ -82,89 +83,116 @@ export const createSavedObjects = async ({ ); // filter out the 'version' field of each object, if it exists - - const objectsToCreate = filteredObjects.map(({ version, ...object }) => { - if (dataSourceId) { - // @ts-expect-error - if (dataSourceTitle && object.attributes.title) { - if ( - object.type === 'dashboard' || - object.type === 'visualization' || - object.type === 'search' - ) { - // @ts-expect-error - object.attributes.title = object.attributes.title + `_${dataSourceTitle}`; + const objectsToCreate = await Promise.all( + filteredObjects.map(({ version, ...object }) => { + if (dataSourceId) { + // @ts-expect-error + if (dataSourceTitle && object.attributes.title) { + if ( + object.type === 'dashboard' || + object.type === 'visualization' || + object.type === 'search' + ) { + // @ts-expect-error + object.attributes.title = object.attributes.title + `_${dataSourceTitle}`; + } } - } - if (object.type === 'index-pattern') { - object.references = [ - { - id: `${dataSourceId}`, - type: 'data-source', - name: 'dataSource', - }, - ]; - } + // Some visualization types will need special modifications, like Vega visualizations + if (object.type === 'visualization') { + const vegaSpec = extractVegaSpecFromSavedObject(object); - if (object.type === 'visualization' || object.type === 'search') { - // @ts-expect-error - const searchSourceString = object.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; - // @ts-expect-error - const visStateString = object.attributes?.visState; + if (!!vegaSpec && !!dataSourceTitle) { + const updatedVegaSpec = updateDataSourceNameInVegaSpec({ + spec: vegaSpec, + newDataSourceName: dataSourceTitle, + }); - if (searchSourceString) { - const searchSource = JSON.parse(searchSourceString); - if (searchSource.index) { - const searchSourceIndex = searchSource.index.includes('_') - ? searchSource.index.split('_')[searchSource.index.split('_').length - 1] - : searchSource.index; - searchSource.index = `${dataSourceId}_` + searchSourceIndex; + // @ts-expect-error + const visStateObject = JSON.parse(object.attributes?.visState); + visStateObject.params.spec = updatedVegaSpec; // @ts-expect-error - object.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + object.attributes.visState = JSON.stringify(visStateObject); + object.references.push({ + id: `${dataSourceId}`, + type: 'data-source', + name: 'dataSource', + }); } } - if (visStateString) { - const visState = JSON.parse(visStateString); - const controlList = visState.params?.controls; - if (controlList) { + if (object.type === 'index-pattern') { + object.references = [ + { + id: `${dataSourceId}`, + type: 'data-source', + name: 'dataSource', + }, + ]; + } + + if (object.type === 'visualization' || object.type === 'search') { + // @ts-expect-error + const searchSourceString = object.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + // @ts-expect-error + const visStateString = object.attributes?.visState; + + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + const searchSourceIndex = searchSource.index.includes('_') + ? searchSource.index.split('_')[searchSource.index.split('_').length - 1] + : searchSource.index; + searchSource.index = `${dataSourceId}_` + searchSourceIndex; + + // @ts-expect-error + object.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( + searchSource + ); + } + } + + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + // @ts-expect-error + controlList.map((control) => { + if (control.indexPattern) { + const controlIndexPattern = control.indexPattern.includes('_') + ? control.indexPattern.split('_')[control.indexPattern.split('_').length - 1] + : control.indexPattern; + control.indexPattern = `${dataSourceId}_` + controlIndexPattern; + } + }); + } // @ts-expect-error - controlList.map((control) => { - if (control.indexPattern) { - const controlIndexPattern = control.indexPattern.includes('_') - ? control.indexPattern.split('_')[control.indexPattern.split('_').length - 1] - : control.indexPattern; - control.indexPattern = `${dataSourceId}_` + controlIndexPattern; - } - }); + object.attributes.visState = JSON.stringify(visState); } - // @ts-expect-error - object.attributes.visState = JSON.stringify(visState); } } - } - // use the import ID map to ensure that each reference is being created with the correct ID - const references = object.references?.map((reference) => { - const { type, id } = reference; - const importIdEntry = importIdMap.get(`${type}:${id}`); + // use the import ID map to ensure that each reference is being created with the correct ID + const references = object.references?.map((reference) => { + const { type, id } = reference; + const importIdEntry = importIdMap.get(`${type}:${id}`); + if (importIdEntry?.id) { + return { ...reference, id: importIdEntry.id }; + } + return reference; + }); + // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on + // the created object if it did not have one (or is omitted if specified) + const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); if (importIdEntry?.id) { - return { ...reference, id: importIdEntry.id }; + objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); + const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; + return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; } - return reference; - }); - // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on - // the created object if it did not have one (or is omitted if specified) - const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); - if (importIdEntry?.id) { - objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); - const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; - return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; - } - return { ...object, ...(references && { references }) }; - }); + return { ...object, ...(references && { references }) }; + }) + ); const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; let expectedResults = objectsToCreate; if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) { 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 3dda6931bd1e..fff5b60c89cc 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 @@ -259,6 +259,7 @@ describe('#importSavedObjectsFromStream', () => { objects: collectedObjects, ignoreRegularConflicts: overwrite, dataSourceId: testDataSourceId, + savedObjectsClient, }; expect(checkConflictsForDataSource).toHaveBeenCalledWith(checkConflictsForDataSourceParams); }); 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 f2833c198e1b..e82b4e634e0f 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -119,6 +119,7 @@ export async function importSavedObjectsFromStream({ objects: checkConflictsResult.filteredObjects, ignoreRegularConflicts: overwrite, dataSourceId, + savedObjectsClient, }); checkOriginConflictsParams.objects = checkConflictsForDataSourceResult.filteredObjects; } diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson new file mode 100644 index 000000000000..98e791db851d --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson @@ -0,0 +1,147 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json new file mode 100644 index 000000000000..8ec22019e828 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson new file mode 100644 index 000000000000..6cf4dcb16db1 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson @@ -0,0 +1,148 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: some datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json new file mode 100644 index 000000000000..41c14b079915 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "data_source_name": "some datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson new file mode 100644 index 000000000000..17f3f2e482ea --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson @@ -0,0 +1,61 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json new file mode 100644 index 000000000000..49392f5de16e --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson new file mode 100644 index 000000000000..8c4a0193ba97 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson @@ -0,0 +1,117 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 400 + height: 200 + padding: 5 + // Data contained entirely within the spec + data: [ + { + name: table + values: [ + { + category: A + count: 28 + } + { + category: B + count: 55 + } + { + category: C + count: 43 + } + { + category: D + count: 91 + } + { + category: E + count: 81 + } + { + category: F + count: 53 + } + { + category: G + count: 19 + } + { + category: H + count: 87 + } + ] + } + ] + scales: [ + { + name: xscale + type: band + domain: { + data: table + field: category + } + range: width + padding: 0.05 + round: true + } + { + name: yscale + type: linear + domain: { + data: table + field: count + } + range: height + nice: true + } + ] + axes: [ + { + orient: bottom + scale: xscale + } + { + orient: left + scale: yscale + } + ] + marks: [ + { + type: rect + from: { + data: table + } + encode: { + enter: { + x: { + scale: xscale + field: category + } + width: { + scale: xscale + band: 1 + } + y: { + scale: yscale + field: count + } + y2: { + scale: yscale + value: 0 + } + } + update: { + fill: { + value: steelblue + } + } + hover: { + fill: { + value: red + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json new file mode 100644 index 000000000000..d24b9b207372 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 400, + "height": 200, + "padding": 5, + + "data": [ + { + "name": "table", + "values": [ + {"category": "A", "count": 28}, + {"category": "B", "count": 55}, + {"category": "C", "count": 43}, + {"category": "D", "count": 91}, + {"category": "E", "count": 81}, + {"category": "F", "count": 53}, + {"category": "G", "count": 19}, + {"category": "H", "count": 87} + ] + } + ], + + "scales": [ + { + "name": "xscale", + "type": "band", + "domain": {"data": "table", "field": "category"}, + "range": "width", + "padding": 0.05, + "round": true + }, + { + "name": "yscale", + "type": "linear", + "domain": {"data": "table", "field": "count"}, + "range": "height", + "nice": true + } + ], + + "axes": [ + { "orient": "bottom", "scale": "xscale" }, + { "orient": "left", "scale": "yscale" } + ], + + "marks": [ + { + "type": "rect", + "from": {"data": "table"}, + "encode": { + "enter": { + "x": {"scale": "xscale", "field": "category"}, + "width": {"scale": "xscale", "band": 1}, + "y": {"scale": "yscale", "field": "count"}, + "y2": {"scale": "yscale", "value": 0} + }, + "update": { + "fill": {"value": "steelblue"} + }, + "hover": { + "fill": {"value": "red"} + } + } + } + ] + } diff --git a/src/core/server/saved_objects/import/utils.test.ts b/src/core/server/saved_objects/import/utils.test.ts new file mode 100644 index 000000000000..604b6f6d473f --- /dev/null +++ b/src/core/server/saved_objects/import/utils.test.ts @@ -0,0 +1,247 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; +import { + extractVegaSpecFromSavedObject, + getDataSourceTitleFromId, + updateDataSourceNameInVegaSpec, +} from './utils'; +import { parse } from 'hjson'; +import { isEqual } from 'lodash'; +import { join } from 'path'; +import { SavedObject, SavedObjectsClientContract } from '../types'; + +describe('updateDataSourceNameInVegaSpec()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const loadJSONFromFile = (filepath: string) => { + return JSON.parse(readFileSync(join(__dirname, filepath)).toString()); + }; + + /* + JSON Test cases + */ + test('(JSON) When data has only one url body and it is an opensearch query, add data_source_name field to the spec', () => { + const openSearchQueryJSON = loadJSONFromFile( + './test_utils/vega_spec_with_opensearch_query.json' + ); + const jsonString = JSON.stringify(openSearchQueryJSON); + const modifiedString = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(modifiedString.data.url.hasOwnProperty('data_source_name')).toBe(true); + expect(modifiedString.data.url.data_source_name).toBe('newDataSource'); + + // These fields should be unchanged + Object.keys(openSearchQueryJSON).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + modifiedString[field as keyof typeof openSearchQueryJSON], + openSearchQueryJSON[field as keyof typeof openSearchQueryJSON] + ) + ).toBe(true); + } + }); + }); + + test('(JSON) When data has only one url body and it is not an opensearch query, change nothing', () => { + const nonOpenSearchQueryJSON = loadJSONFromFile( + './test_utils/vega_spec_without_opensearch_query.json' + ); + const jsonString = JSON.stringify(nonOpenSearchQueryJSON); + const modifiedJSON = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'noDataSource' }) + ); + expect(isEqual(modifiedJSON, nonOpenSearchQueryJSON)).toBe(true); + }); + + test('(JSON) When data has multiple url bodies, make sure only opensearch queries are updated with data_source_names', () => { + const multipleDataSourcesJSON = loadJSONFromFile( + './test_utils/vega_spec_with_multiple_urls.json' + ); + const jsonString = JSON.stringify(multipleDataSourcesJSON); + const modifiedString = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(modifiedString.data.length).toBe(multipleDataSourcesJSON.data.length); + for (let i = 0; i < modifiedString.data.length; i++) { + const originalUrlBody = multipleDataSourcesJSON.data[i]; + const urlBody = modifiedString.data[i]; + + if (urlBody.name !== 'exampleIndexSource') { + expect(isEqual(originalUrlBody, urlBody)).toBe(true); + } else { + expect(urlBody.url.hasOwnProperty('data_source_name')).toBe(true); + expect(urlBody.url.data_source_name).toBe('newDataSource'); + } + } + + // These fields should be unchanged + Object.keys(multipleDataSourcesJSON).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + modifiedString[field as keyof typeof multipleDataSourcesJSON], + multipleDataSourcesJSON[field as keyof typeof multipleDataSourcesJSON] + ) + ).toBe(true); + } + }); + }); + + test('(JSON) When an MDS object does not reference local queries, return the same spec', () => { + const multipleDataSourcesJSONMds = loadJSONFromFile( + './test_utils/vega_spec_with_multiple_urls_mds.json' + ); + const jsonString = JSON.stringify(multipleDataSourcesJSONMds); + const modifiedJSON = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'noDataSource' }) + ); + expect(isEqual(modifiedJSON, multipleDataSourcesJSONMds)).toBe(true); + }); + + /* + HJSON Test cases + */ + test('(HJSON) When data has only one url body and it is an opensearch query, add data_source_name field to the spec', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query.hjson' + ); + + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }), + { + keepWsc: true, + } + ); + + expect(hjsonParse.data.url.hasOwnProperty('data_source_name')).toBe(true); + expect(hjsonParse.data.url.data_source_name).toBe('newDataSource'); + + // These fields should be unchanged + Object.keys(originalHJSONParse).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + originalHJSONParse[field as keyof typeof originalHJSONParse], + hjsonParse[field as keyof typeof originalHJSONParse] + ) + ).toBe(true); + } + }); + }); + + test('(HJSON) When data has only one url body and it is not an opensearch query, change nothing', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_without_opensearch_query.hjson' + ); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'noDataSource' }) + ); + + expect(isEqual(originalHJSONParse, hjsonParse)).toBe(true); + }); + + test('(HJSON) When data has multiple url bodies, make sure only opensearch queries are updated with data_source_names', () => { + const hjsonString = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls.hjson'); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(hjsonParse.data.length).toBe(originalHJSONParse.data.length); + for (let i = 0; i < hjsonParse.data.length; i++) { + const originalUrlBody = originalHJSONParse.data[i]; + const urlBody = hjsonParse.data[i]; + + if (urlBody.name !== 'exampleIndexSource') { + expect(isEqual(originalUrlBody, urlBody)).toBe(true); + } else { + expect(urlBody.url.hasOwnProperty('data_source_name')).toBe(true); + expect(urlBody.url.data_source_name).toBe('newDataSource'); + } + } + + // These fields should be unchanged + Object.keys(originalHJSONParse).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + originalHJSONParse[field as keyof typeof originalHJSONParse], + hjsonParse[field as keyof typeof originalHJSONParse] + ) + ).toBe(true); + } + }); + }); + + test('(HJSON) When an MDS object does not reference local queries, return the same spec', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.hjson' + ); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(isEqual(originalHJSONParse, hjsonParse)).toBe(true); + }); +}); + +describe('extractVegaSpecFromSavedObject()', () => { + test('For a Vega visualization saved object, return its spec', () => { + const spec = 'some-vega-spec'; + const vegaSavedObject = { + attributes: { + visState: `{"type": "vega", "params": {"spec": "${spec}"}}`, + }, + } as SavedObject; + + expect(extractVegaSpecFromSavedObject(vegaSavedObject)).toBe(spec); + }); + + test('For another saved object type, return undefined', () => { + const nonVegaSavedObject = { + attributes: { + visState: `{"type": "area", "params": {"spec": "some-spec"}}`, + }, + } as SavedObject; + + expect(extractVegaSpecFromSavedObject(nonVegaSavedObject)).toBe(undefined); + }); +}); + +describe('getDataSourceTitleFromId()', () => { + const savedObjectsClient = {} as SavedObjectsClientContract; + savedObjectsClient.get = jest.fn().mockImplementation((type, id) => { + if (type === 'data-source' && id === 'valid-id') { + return Promise.resolve({ + attributes: { + title: 'some-datasource-title', + }, + }); + } + + return Promise.resolve({}); + }); + + test('When a valid id is passed, return the correct title', async () => { + expect(await getDataSourceTitleFromId('valid-id', savedObjectsClient)).toBe( + 'some-datasource-title' + ); + }); + + test('When a nonexistent id is passed, return nothing', async () => { + expect(await getDataSourceTitleFromId('nonexistent-id', savedObjectsClient)).toBe(undefined); + }); +}); diff --git a/src/core/server/saved_objects/import/utils.ts b/src/core/server/saved_objects/import/utils.ts new file mode 100644 index 000000000000..9bb1d10cd0eb --- /dev/null +++ b/src/core/server/saved_objects/import/utils.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parse, stringify } from 'hjson'; +import { SavedObject, SavedObjectsClientContract } from '../types'; + +export interface UpdateDataSourceNameInVegaSpecProps { + spec: string; + newDataSourceName: string; +} + +export const updateDataSourceNameInVegaSpec = ( + props: UpdateDataSourceNameInVegaSpecProps +): string => { + const { spec } = props; + + let parsedSpec = parseJSONSpec(spec); + const isJSONString = !!parsedSpec; + if (!parsedSpec) { + parsedSpec = parse(spec, { keepWsc: true }); + } + + const dataField = parsedSpec.data; + + if (dataField instanceof Array) { + parsedSpec.data = dataField.map((dataObject) => { + return updateDataSourceNameForDataObject(dataObject, props); + }); + } else if (dataField instanceof Object) { + parsedSpec.data = updateDataSourceNameForDataObject(dataField, props); + } else { + throw new Error(`"data" field should be an object or an array of objects`); + } + + return isJSONString + ? JSON.stringify(parsedSpec) + : stringify(parsedSpec, { + bracesSameLine: true, + keepWsc: true, + }); +}; + +export const getDataSourceTitleFromId = async ( + dataSourceId: string, + savedObjectsClient: SavedObjectsClientContract +) => { + return await savedObjectsClient.get('data-source', dataSourceId).then((response) => { + // @ts-expect-error + return response?.attributes?.title ?? undefined; + }); +}; + +export const extractVegaSpecFromSavedObject = (savedObject: SavedObject) => { + if (isVegaVisualization(savedObject)) { + // @ts-expect-error + const visStateObject = JSON.parse(savedObject.attributes?.visState); + return visStateObject.params.spec; + } + + return undefined; +}; + +const isVegaVisualization = (savedObject: SavedObject) => { + // @ts-expect-error + const visState = savedObject.attributes?.visState; + if (!!visState) { + const visStateObject = JSON.parse(visState); + return !!visStateObject.type && visStateObject.type === 'vega'; + } + return false; +}; + +const updateDataSourceNameForDataObject = ( + dataObject: any, + props: UpdateDataSourceNameInVegaSpecProps +) => { + const { newDataSourceName } = props; + if ( + dataObject.hasOwnProperty('url') && + dataObject.url.hasOwnProperty('index') && + !dataObject.url.hasOwnProperty('data_source_name') + ) { + dataObject.url.data_source_name = newDataSourceName; + } + + return dataObject; +}; + +const parseJSONSpec = (spec: string) => { + try { + const jsonSpec = JSON.parse(spec); + + if (jsonSpec && typeof jsonSpec === 'object') { + return jsonSpec; + } + } catch (e) { + return undefined; + } + + return undefined; +}; diff --git a/src/plugins/application_config/README.md b/src/plugins/application_config/README.md index cad28722d63e..1637bdd9688f 100755 --- a/src/plugins/application_config/README.md +++ b/src/plugins/application_config/README.md @@ -61,15 +61,17 @@ Let's call this plugin `MyConfigurationClientPlugin`. First, this plugin will need to implement a class `MyConfigurationClient` based on interface `ConfigurationClient` defined in the `types.ts` under directory `src/plugins/application_config/server/types.ts`. Below are the functions inside the interface. ``` - getConfig(): Promise>; + getConfig(options?: ConfigurationClientOptions): Promise>; - getEntityConfig(entity: string): Promise; + getEntityConfig(entity: string, options?: ConfigurationClientOptions): Promise; - updateEntityConfig(entity: string, newValue: string): Promise; + updateEntityConfig(entity: string, newValue: string, options?: ConfigurationClientOptions): Promise; - deleteEntityConfig(entity: string): Promise; + deleteEntityConfig(entity: string, options?: ConfigurationClientOptions): Promise; ``` +`ConfigurationClientOptions` wraps some additional parameters including request headers. + Second, this plugin needs to declare `applicationConfig` as its dependency by adding it to `requiredPlugins` in its own `opensearch_dashboards.json`. Third, the plugin will define a new type called `AppPluginSetupDependencies` as follows in its own `types.ts`. diff --git a/src/plugins/application_config/server/routes/index.test.ts b/src/plugins/application_config/server/routes/index.test.ts index 086baa646d2b..0aa161bf560f 100644 --- a/src/plugins/application_config/server/routes/index.test.ts +++ b/src/plugins/application_config/server/routes/index.test.ts @@ -79,6 +79,8 @@ describe('application config routes', () => { getConfig: jest.fn().mockReturnValue(configurations), }; + const request = {}; + const okResponse = { statusCode: 200, }; @@ -89,7 +91,7 @@ describe('application config routes', () => { const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, response, logger); + const returnedResponse = await handleGetConfig(client, request, response, logger); expect(returnedResponse).toBe(okResponse); @@ -109,13 +111,15 @@ describe('application config routes', () => { }), }; + const request = {}; + const response = { customError: jest.fn().mockReturnValue(ERROR_RESPONSE), }; const logger = loggerMock.create(); - const returnedResponse = await handleGetConfig(client, response, logger); + const returnedResponse = await handleGetConfig(client, request, response, logger); expect(returnedResponse).toBe(ERROR_RESPONSE); diff --git a/src/plugins/application_config/server/routes/index.ts b/src/plugins/application_config/server/routes/index.ts index 7a059bf52f35..b6ec638e1aa9 100644 --- a/src/plugins/application_config/server/routes/index.ts +++ b/src/plugins/application_config/server/routes/index.ts @@ -26,7 +26,7 @@ export function defineRoutes( async (context, request, response) => { const client = getConfigurationClient(context.core.opensearch.client); - return await handleGetConfig(client, response, logger); + return await handleGetConfig(client, request, response, logger); } ); router.get( @@ -85,8 +85,12 @@ export async function handleGetEntityConfig( response: OpenSearchDashboardsResponseFactory, logger: Logger ) { + logger.info(`Received a request to get entity config for ${request.params.entity}.`); + try { - const result = await client.getEntityConfig(request.params.entity); + const result = await client.getEntityConfig(request.params.entity, { + headers: request.headers, + }); return response.ok({ body: { value: result, @@ -104,8 +108,14 @@ export async function handleUpdateEntityConfig( response: OpenSearchDashboardsResponseFactory, logger: Logger ) { + logger.info( + `Received a request to update entity ${request.params.entity} with new value ${request.body.newValue}.` + ); + try { - const result = await client.updateEntityConfig(request.params.entity, request.body.newValue); + const result = await client.updateEntityConfig(request.params.entity, request.body.newValue, { + headers: request.headers, + }); return response.ok({ body: { newValue: result, @@ -123,8 +133,12 @@ export async function handleDeleteEntityConfig( response: OpenSearchDashboardsResponseFactory, logger: Logger ) { + logger.info(`Received a request to delete entity ${request.params.entity}.`); + try { - const result = await client.deleteEntityConfig(request.params.entity); + const result = await client.deleteEntityConfig(request.params.entity, { + headers: request.headers, + }); return response.ok({ body: { deletedEntity: result, @@ -138,11 +152,14 @@ export async function handleDeleteEntityConfig( export async function handleGetConfig( client: ConfigurationClient, + request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory, logger: Logger ) { + logger.info('Received a request to get all configurations.'); + try { - const result = await client.getConfig(); + const result = await client.getConfig({ headers: request.headers }); return response.ok({ body: { value: result, diff --git a/src/plugins/application_config/server/types.ts b/src/plugins/application_config/server/types.ts index 49fc11d99c53..416d0258169e 100644 --- a/src/plugins/application_config/server/types.ts +++ b/src/plugins/application_config/server/types.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IScopedClusterClient } from 'src/core/server'; +import { IScopedClusterClient, Headers } from 'src/core/server'; export interface ApplicationConfigPluginSetup { getConfigurationClient: (inputOpenSearchClient: IScopedClusterClient) => ConfigurationClient; @@ -12,6 +12,10 @@ export interface ApplicationConfigPluginSetup { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ApplicationConfigPluginStart {} +export interface ConfigurationClientOptions { + headers: Headers; +} + /** * The interface defines the operations against the application configurations at both entity level and whole level. * @@ -20,33 +24,40 @@ export interface ConfigurationClient { /** * Get all the configurations. * - * @param {array} array of connections - * @returns {ConnectionPool} + * @param {options} options, an optional parameter + * @returns {Map} all the configurations */ - getConfig(): Promise>; + getConfig(options?: ConfigurationClientOptions): Promise>; /** * Get the value for the input entity. * * @param {entity} name of the entity + * @param {options} options, an optional parameter * @returns {string} value of the entity */ - getEntityConfig(entity: string): Promise; + getEntityConfig(entity: string, options?: ConfigurationClientOptions): Promise; /** * Update the input entity with a new value. * * @param {entity} name of the entity * @param {newValue} new configuration value of the entity + * @param {options} options, an optional parameter * @returns {string} updated configuration value of the entity */ - updateEntityConfig(entity: string, newValue: string): Promise; + updateEntityConfig( + entity: string, + newValue: string, + options?: ConfigurationClientOptions + ): Promise; /** * Delete the input entity from configurations. * * @param {entity} name of the entity + * @param {options} options, an optional parameter * @returns {string} name of the deleted entity */ - deleteEntityConfig(entity: string): Promise; + deleteEntityConfig(entity: string, options?: ConfigurationClientOptions): Promise; } diff --git a/src/plugins/csp_handler/server/csp_handlers.ts b/src/plugins/csp_handler/server/csp_handlers.ts index cc14da74aed5..3bfa90115518 100644 --- a/src/plugins/csp_handler/server/csp_handlers.ts +++ b/src/plugins/csp_handler/server/csp_handlers.ts @@ -51,7 +51,9 @@ export function createCspRulesPreResponseHandler( const client = getConfigurationClient(coreStart.opensearch.client.asScoped(request)); - const cspRules = await client.getEntityConfig(CSP_RULES_CONFIG_KEY); + const cspRules = await client.getEntityConfig(CSP_RULES_CONFIG_KEY, { + headers: request.headers, + }); if (!cspRules) { return appendFrameAncestorsWhenMissing(cspHeader, toolkit); diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index 12e01692b2e6..c239c816594e 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -30,6 +30,7 @@ import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../common'; import { ensureRawRequest } from '../../../../src/core/server/http/router'; import { createDataSourceError } from './lib/error'; import { registerTestConnectionRoute } from './routes/test_connection'; +import { registerFetchDataSourceVersionRoute } from './routes/fetch_data_source_version'; import { AuthenticationMethodRegistery, IAuthenticationMethodRegistery } from './auth_registry'; import { CustomApiSchemaRegistry } from './schema_registry'; @@ -133,6 +134,13 @@ export class DataSourcePlugin implements Plugin { this.logger.debug(`Registered Credential Provider for authType = ${method.name}`); diff --git a/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts b/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts index 219888199016..bb7a8b91d673 100644 --- a/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts +++ b/src/plugins/data_source/server/routes/data_source_connection_validator.test.ts @@ -24,6 +24,23 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { expect(validateDataSourcesResponse.statusCode).toBe(200); }); + test('fetchDataSourceVersion - Success: opensearch client response code is 200 and response body have version number', async () => { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: 200, + body: { + version: { + number: '2.11.0', + }, + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe('2.11.0'); + }); + test('failure: opensearch client response code is 200 but response body not have cluster name', async () => { try { const opensearchClient = opensearchServiceMock.createOpenSearchClient(); @@ -43,6 +60,22 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { } }); + // In case fetchDataSourceVersion call succeeded yet did not return version number, return an empty version instead of raising exceptions + test('fetchDataSourceVersion - Success:opensearch client response code is 200 but response body does not have version number', async () => { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: 200, + body: { + Message: 'Response without version number.', + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe(''); + }); + test('failure: opensearch client response code is other than 200', async () => { const statusCodeList = [100, 202, 300, 400, 500]; statusCodeList.forEach(async function (code) { @@ -64,6 +97,25 @@ describe('DataSourceManagement: data_source_connection_validator.ts', () => { } }); }); + + // In case fetchDataSourceVersion call failed, return an empty version instead of raising exceptions + test('fetchDataSourceVersion - Failure: opensearch client response code is other than 200', async () => { + const statusCodeList = [100, 202, 300, 400, 500]; + statusCodeList.forEach(async function (code) { + const opensearchClient = opensearchServiceMock.createOpenSearchClient(); + opensearchClient.info.mockResolvedValue( + opensearchServiceMock.createApiResponse({ + statusCode: code, + body: { + Message: 'Your request is not correct.', + }, + }) + ); + const dataSourceValidator = new DataSourceConnectionValidator(opensearchClient, {}); + const fetchDataSourcesVersionResponse = await dataSourceValidator.fetchDataSourceVersion(); + expect(fetchDataSourcesVersionResponse).toBe(''); + }); + }); }); describe('Test datasource connection for SigV4 auth', () => { diff --git a/src/plugins/data_source/server/routes/data_source_connection_validator.ts b/src/plugins/data_source/server/routes/data_source_connection_validator.ts index 735d1429414c..60e00d855658 100644 --- a/src/plugins/data_source/server/routes/data_source_connection_validator.ts +++ b/src/plugins/data_source/server/routes/data_source_connection_validator.ts @@ -35,4 +35,27 @@ export class DataSourceConnectionValidator { throw createDataSourceError(e); } } + + async fetchDataSourceVersion() { + let dataSourceVersion = ''; + try { + // OpenSearch Serverless does not have version concept + if ( + this.dataSourceAttr.auth?.credentials?.service === SigV4ServiceName.OpenSearchServerless + ) { + return dataSourceVersion; + } + await this.callDataCluster + .info() + .then((response) => response.body) + .then((body) => { + dataSourceVersion = body.version.number; + }); + + return dataSourceVersion; + } catch (e) { + // return empty dataSoyrce version instead of throwing exception in case info() api call fails + return dataSourceVersion; + } + } } diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts new file mode 100644 index 000000000000..d81073f9beba --- /dev/null +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts @@ -0,0 +1,345 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@osd/utility-types'; +import { setupServer } from '../../../../../src/core/server/test_utils'; + +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { CustomApiSchemaRegistry } from '../schema_registry'; +import { DataSourceServiceSetup } from '../../server/data_source_service'; +import { CryptographyServiceSetup } from '../cryptography_service'; +import { registerFetchDataSourceVersionRoute } from './fetch_data_source_version'; +import { AuthType } from '../../common/data_sources'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { opensearchClientMock } from '../../../../../src/core/server/opensearch/client/mocks'; + +type SetupServerReturn = UnwrapPromise>; + +const URL = '/internal/data-source-management/fetchDataSourceVersion'; + +describe(`Fetch DataSource Version ${URL}`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let cryptographyMock: jest.Mocked; + const customApiSchemaRegistry = new CustomApiSchemaRegistry(); + let customApiSchemaRegistryPromise: Promise; + let dataSourceClient: ReturnType; + let dataSourceServiceSetupMock: DataSourceServiceSetup; + let authRegistryPromiseMock: Promise; + const dataSourceAttr = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testUser', + password: 'testPassword', + }, + }, + }; + + const dataSourceAttrMissingCredentialForNoAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.NoAuth, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }; + + const dataSourceAttrMissingCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }; + + const dataSourceAttrPartialCredentialForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'service', + }, + }, + }; + + const dataSourceAttrPartialCredentialForBasicAuth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: { + username: 'testName', + }, + }, + }; + + const dataSourceAttrForSigV4Auth = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: { + accessKey: 'testKey', + service: 'es', + secretKey: 'testSecret', + region: 'testRegion', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + credentials: { + firstField: 'some value', + secondField: 'some value', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithEmptyCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + credentials: {}, + }, + }; + + const dataSourceAttrForRegisteredAuthWithoutCredentials = { + endpoint: 'https://test.com', + auth: { + type: 'Some Registered Type', + }, + }; + + const dataSourceAttrForRegisteredAuthWithNoAuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.NoAuth, + credentials: { + field: 'some value', + }, + }, + }; + + const dataSourceAttrForRegisteredAuthWithBasicAuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.UsernamePasswordType, + credentials: {}, + }, + }; + + const dataSourceAttrForRegisteredAuthWithSigV4AuthType = { + endpoint: 'https://test.com', + auth: { + type: AuthType.SigV4, + credentials: {}, + }, + }; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + customApiSchemaRegistryPromise = Promise.resolve(customApiSchemaRegistry); + authRegistryPromiseMock = Promise.resolve(authenticationMethodRegisteryMock.create()); + dataSourceClient = opensearchClientMock.createInternalClient(); + + dataSourceServiceSetupMock = { + getDataSourceClient: jest.fn(() => Promise.resolve(dataSourceClient)), + getDataSourceLegacyClient: jest.fn(), + }; + + const router = httpSetup.createRouter(''); + dataSourceClient.info.mockImplementationOnce(() => + opensearchClientMock.createSuccessTransportRequestPromise({ version: { number: '2.11.0' } }) + ); + registerFetchDataSourceVersionRoute( + router, + dataSourceServiceSetupMock, + cryptographyMock, + authRegistryPromiseMock, + customApiSchemaRegistryPromise + ); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('shows successful response', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + expect(dataSourceServiceSetupMock.getDataSourceClient).toHaveBeenCalledWith( + expect.objectContaining({ + savedObjects: handlerContext.savedObjects.client, + cryptography: cryptographyMock, + dataSourceId: 'testId', + testClientDataSourceAttr: dataSourceAttr, + customApiSchemaRegistryPromise, + }) + ); + }); + + it('no credential with no auth should succeed', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForNoAuth, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('no credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('no credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrMissingCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with sigv4 auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForSigV4Auth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('partial credential with basic auth should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrPartialCredentialForBasicAuth, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + }); + + it('registered Auth with NoAuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithNoAuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('registered Auth with Basic AuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithBasicAuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('registered Auth with sigV4 AuthType should fail', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithSigV4AuthType, + }) + .expect(400); + expect(result.body.error).toEqual('Bad Request'); + expect(result.body.message).toContain( + `Must not be no_auth or username_password or sigv4 for registered auth types` + ); + }); + + it('full credential with sigV4 auth should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForSigV4Auth, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('empty credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithEmptyCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); + + it('no credential with registered auth type should success', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + id: 'testId', + dataSourceAttr: dataSourceAttrForRegisteredAuthWithoutCredentials, + }) + .expect(200); + expect(result.body).toEqual({ dataSourceVersion: '2.11.0' }); + }); +}); diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.ts new file mode 100644 index 000000000000..5bd53f728e44 --- /dev/null +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.ts @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter, OpenSearchClient } from 'opensearch-dashboards/server'; +import { AuthType, DataSourceAttributes, SigV4ServiceName } from '../../common/data_sources'; +import { DataSourceConnectionValidator } from './data_source_connection_validator'; +import { DataSourceServiceSetup } from '../data_source_service'; +import { CryptographyServiceSetup } from '../cryptography_service'; +import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { CustomApiSchemaRegistry } from '../schema_registry/custom_api_schema_registry'; + +export const registerFetchDataSourceVersionRoute = async ( + router: IRouter, + dataSourceServiceSetup: DataSourceServiceSetup, + cryptography: CryptographyServiceSetup, + authRegistryPromise: Promise, + customApiSchemaRegistryPromise: Promise +) => { + const authRegistry = await authRegistryPromise; + router.post( + { + path: '/internal/data-source-management/fetchDataSourceVersion', + validate: { + body: schema.object({ + id: schema.maybe(schema.string()), + dataSourceAttr: schema.object({ + endpoint: schema.string(), + auth: schema.maybe( + schema.oneOf([ + schema.object({ + type: schema.literal(AuthType.NoAuth), + credentials: schema.object({}), + }), + schema.object({ + type: schema.literal(AuthType.UsernamePasswordType), + credentials: schema.object({ + username: schema.string(), + password: schema.string(), + }), + }), + schema.object({ + type: schema.literal(AuthType.SigV4), + credentials: schema.object({ + region: schema.string(), + accessKey: schema.string(), + secretKey: schema.string(), + service: schema.oneOf([ + schema.literal(SigV4ServiceName.OpenSearch), + schema.literal(SigV4ServiceName.OpenSearchServerless), + ]), + }), + }), + schema.object({ + type: schema.string({ + validate: (value) => { + if ( + value === AuthType.NoAuth || + value === AuthType.UsernamePasswordType || + value === AuthType.SigV4 + ) { + return `Must not be no_auth or username_password or sigv4 for registered auth types`; + } + }, + }), + credentials: schema.nullable(schema.any()), + }), + ]) + ), + }), + }), + }, + }, + async (context, request, response) => { + const { dataSourceAttr, id: dataSourceId } = request.body; + let dataSourceVersion = ''; + + try { + const dataSourceClient: OpenSearchClient = await dataSourceServiceSetup.getDataSourceClient( + { + savedObjects: context.core.savedObjects.client, + cryptography, + dataSourceId, + testClientDataSourceAttr: dataSourceAttr as DataSourceAttributes, + request, + authRegistry, + customApiSchemaRegistryPromise, + } + ); + + const dataSourceValidator = new DataSourceConnectionValidator( + dataSourceClient, + dataSourceAttr + ); + + dataSourceVersion = await dataSourceValidator.fetchDataSourceVersion(); + + return response.ok({ + body: { + dataSourceVersion, + }, + }); + } catch (err) { + return response.customError({ + statusCode: err.statusCode || 500, + body: { + message: err.message, + attributes: { + error: err.body?.error || err.message, + }, + }, + }); + } + } + ); +}; diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx index adfbe8808637..1fe6e4f5d499 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { + fetchDataSourceVersion, getMappedDataSources, mockDataSourceAttributesWithAuth, mockManagementPlugin, @@ -27,6 +28,9 @@ describe('Datasource Management: Create Datasource Wizard', () => { describe('case1: should load resources successfully', () => { beforeEach(async () => { spyOn(utils, 'getDataSources').and.returnValue(Promise.resolve(getMappedDataSources)); + spyOn(utils, 'fetchDataSourceVersion').and.returnValue( + Promise.resolve(fetchDataSourceVersion) + ); await act(async () => { component = mount( wrapWithIntl( diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx index 05489ca6258a..06b77efd9b94 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/create_data_source_wizard.tsx @@ -16,7 +16,12 @@ import { } from '../../types'; import { getCreateBreadcrumbs } from '../breadcrumbs'; import { CreateDataSourceForm } from './components/create_form'; -import { createSingleDataSource, getDataSources, testConnection } from '../utils'; +import { + createSingleDataSource, + getDataSources, + testConnection, + fetchDataSourceVersion, +} from '../utils'; import { LoadingMask } from '../loading_mask'; type CreateDataSourceWizardProps = RouteComponentProps; @@ -68,6 +73,8 @@ export const CreateDataSourceWizard: React.FunctionComponent { setIsLoading(true); try { + const version = await fetchDataSourceVersion(http, attributes); + attributes.dataSourceVersion = version.dataSourceVersion; await createSingleDataSource(savedObjects.client, attributes); props.history.push(''); } catch (e) { diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap index 2be5b7c11b8a..2c789f04da12 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap @@ -1,5 +1,59 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DataSourceAggregatedView should render normally with data source filter 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; + exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 1`] = ` `; -exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 2`] = ` +exports[`DataSourceAggregatedView should render normally with local cluster hidden and all options 1`] = ` + + +`; + +exports[`DataSourceAggregatedView should render normally with local cluster not hidden and all options 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + `; -exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 3`] = ` +exports[`DataSourceAggregatedView should render popup when clicking on info icon 1`] = ` Object { "asFragment": [Function], "baseElement": @@ -339,111 +447,3 @@ Object { "unmount": [Function], } `; - -exports[`DataSourceAggregatedView should render normally with local cluster hidden and all options 1`] = ` - - - Data sources - - - All - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="dataSourceSViewContextMenuPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > - - - -`; - -exports[`DataSourceAggregatedView should render normally with local cluster not hidden and all options 1`] = ` - - - Data sources - - - All - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="dataSourceSViewContextMenuPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > - - - -`; diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index e7503cba645a..3e9f4c377160 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -32,8 +32,8 @@ export interface DataSourceSelectorProps { } interface DataSourceSelectorState { - dataSourceOptions: DataSourceOption[]; selectedOption: DataSourceOption[]; + allDataSources: Array>; } export interface DataSourceOption { @@ -52,11 +52,7 @@ export class DataSourceSelector extends React.Component< super(props); this.state = { - dataSourceOptions: this.props.defaultOption - ? this.props.defaultOption - : this.props.hideLocalCluster - ? [] - : [LocalCluster], + allDataSources: [], selectedOption: this.props.defaultOption ? this.props.defaultOption : this.props.hideLocalCluster @@ -74,26 +70,10 @@ export class DataSourceSelector extends React.Component< getDataSourcesWithFields(this.props.savedObjectsClient, ['id', 'title', 'auth.type']) .then((fetchedDataSources) => { if (fetchedDataSources?.length) { - let filteredDataSources = fetchedDataSources; - if (this.props.dataSourceFilter) { - filteredDataSources = fetchedDataSources.filter((ds) => - this.props.dataSourceFilter!(ds) - ); - } - - const dataSourceOptions = filteredDataSources.map((dataSource) => ({ - id: dataSource.id, - label: dataSource.attributes?.title || '', - })); - - if (!this.props.hideLocalCluster) { - dataSourceOptions.unshift(LocalCluster); - } - if (!this._isMounted) return; this.setState({ ...this.state, - dataSourceOptions, + allDataSources: fetchedDataSources, }); } }) @@ -119,6 +99,16 @@ export class DataSourceSelector extends React.Component< this.props.placeholderText === undefined ? 'Select a data source' : this.props.placeholderText; + + const dataSources = this.props.dataSourceFilter + ? this.state.allDataSources.filter((ds) => this.props.dataSourceFilter!(ds)) + : this.state.allDataSources; + + const options = dataSources.map((ds) => ({ id: ds.id, label: ds.attributes?.title || '' })); + if (!this.props.hideLocalCluster) { + options.unshift(LocalCluster); + } + return ( this.onChange(e)} prepend={ diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx index 06d0af486bba..e5f73c503f6e 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx @@ -76,8 +76,10 @@ describe('Datasource Management: Edit Datasource Form', () => { @@ -245,7 +247,9 @@ describe('Datasource Management: Edit Datasource Form', () => { { expect(mockFn).toHaveBeenCalled(); }); + test('should set as the default datasource from header', () => { + // @ts-ignore + component.find('Header').prop('onClickSetDefault')(); + expect(mockFn).toHaveBeenCalled(); + }); + /* Save Changes */ test('should update the form with NoAuth on click save changes', async () => { await new Promise((resolve) => @@ -383,8 +393,10 @@ describe('With Registered Authentication', () => { @@ -422,8 +434,10 @@ describe('With Registered Authentication', () => { diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx index 2cb1db4515dd..6714881c4a11 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx @@ -50,9 +50,11 @@ import { extractRegisteredAuthTypeCredentials, getDefaultAuthMethod } from '../. export interface EditDataSourceProps { existingDataSource: DataSourceAttributes; existingDatasourceNamesList: string[]; + isDefault: boolean; handleSubmit: (formValues: DataSourceAttributes) => Promise; handleTestConnection: (formValues: DataSourceAttributes) => Promise; onDeleteDataSource?: () => Promise; + onSetDefaultDataSource: () => Promise; displayToastMessage: (info: ToastMessageItem) => void; } export interface EditDataSourceState { @@ -400,6 +402,12 @@ export class EditDataSourceForm extends React.Component { + if (this.props.onSetDefaultDataSource) { + await this.props.onSetDefaultDataSource(); + } + }; + onClickTestConnection = async () => { this.setState({ isLoading: true }); const isNewCredential = !!(this.state.auth.type !== this.props.existingDataSource.auth.type); @@ -634,6 +642,8 @@ export class EditDataSourceForm extends React.Component ); }; diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx index f679a7db6e67..5a23b72881a1 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx @@ -14,6 +14,7 @@ import { act } from 'react-dom/test-utils'; const headerTitleIdentifier = '[data-test-subj="editDataSourceTitle"]'; const deleteIconIdentifier = '[data-test-subj="editDatasourceDeleteIcon"]'; const confirmModalIdentifier = '[data-test-subj="editDatasourceDeleteConfirmModal"]'; +const setDefaultButtonIdentifier = '[data-test-subj="editSetDefaultDataSource"]'; describe('Datasource Management: Edit Datasource Header', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); @@ -31,6 +32,8 @@ describe('Datasource Management: Edit Datasource Header', () => { onClickDeleteIcon={mockFn} onClickTestConnection={mockFn} dataSourceName={dataSourceName} + onClickSetDefault={mockFn} + isDefault={false} /> ), { @@ -82,6 +85,8 @@ describe('Datasource Management: Edit Datasource Header', () => { onClickDeleteIcon={mockFn} onClickTestConnection={mockFn} dataSourceName={dataSourceName} + onClickSetDefault={mockFn} + isDefault={false} /> ), { @@ -97,4 +102,76 @@ describe('Datasource Management: Edit Datasource Header', () => { expect(component.find(deleteIconIdentifier).exists()).toBe(false); }); }); + describe('should render default icon as "Set as default" when isDefaultDataSourceState is false', () => { + const onClickSetDefault = jest.fn(); + const isDefaultDataSourceState = false; + beforeEach(() => { + component = mount( + wrapWithIntl( +
+ ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + }); + + test('should render normally', () => { + expect(component.find(setDefaultButtonIdentifier).exists()).toBe(true); + }); + test('default button should show as "Set as default" and should be clickable', () => { + expect(component.find(setDefaultButtonIdentifier).first().text()).toBe('Set as default'); + expect(component.find(setDefaultButtonIdentifier).first().prop('disabled')).toBe(false); + expect(component.find(setDefaultButtonIdentifier).first().prop('iconType')).toBe('starEmpty'); + component.find(setDefaultButtonIdentifier).first().simulate('click'); + expect(onClickSetDefault).toHaveBeenCalled(); + }); + }); + describe('should render default icon as "Default" when isDefaultDataSourceState is true', () => { + const onClickSetDefault = jest.fn(); + const isDefaultDataSourceState = true; + beforeEach(() => { + component = mount( + wrapWithIntl( +
+ ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + }); + + test('should render normally', () => { + expect(component.find(setDefaultButtonIdentifier).exists()).toBe(true); + }); + test('default button should show as "Default" and should be disabled.', () => { + expect(component.find(setDefaultButtonIdentifier).first().text()).toBe('Default'); + expect(component.find(setDefaultButtonIdentifier).first().prop('disabled')).toBe(true); + expect(component.find(setDefaultButtonIdentifier).first().prop('iconType')).toBe( + 'starFilled' + ); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx index 49c100b7ec9a..264647882574 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx @@ -14,6 +14,7 @@ import { EuiButtonIcon, EuiConfirmModal, EuiButton, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -25,22 +26,51 @@ export const Header = ({ isFormValid, onClickDeleteIcon, onClickTestConnection, + onClickSetDefault, dataSourceName, + isDefault, }: { showDeleteIcon: boolean; isFormValid: boolean; onClickDeleteIcon: () => void; onClickTestConnection: () => void; + onClickSetDefault: () => void; dataSourceName: string; + isDefault: boolean; }) => { /* State Variables */ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [isDefaultDataSourceState, setIsDefaultDataSourceState] = useState(isDefault); const changeTitle = useOpenSearchDashboards().services.chrome .docTitle.change; changeTitle(dataSourceName); + const setDefaultAriaLabel = i18n.translate( + 'dataSourcesManagement.editDataSource.setDefaultDataSource', + { + defaultMessage: 'Set as a default Data Source.', + } + ); + + const renderDefaultIcon = () => { + return ( + { + onClickSetDefault(); + setIsDefaultDataSourceState(!isDefaultDataSourceState); + }} + disabled={isDefaultDataSourceState} + iconType={isDefaultDataSourceState ? 'starFilled' : 'starEmpty'} + aria-label={setDefaultAriaLabel} + data-test-subj="editSetDefaultDataSource" + > + {isDefaultDataSourceState ? 'Default' : 'Set as default'} + + ); + }; + const renderDeleteButton = () => { return ( <> @@ -144,6 +174,8 @@ export const Header = ({ {/* Right side buttons */} + {/* Test default button */} + {renderDefaultIcon()} {/* Test connection button */} {renderTestConnectionButton()} {/* Delete icon button */} diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx index c1516a507d4a..6cab62b49c8c 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx @@ -29,6 +29,7 @@ const notFoundIdentifier = '[data-test-subj="dataSourceNotFound"]'; describe('Datasource Management: Edit Datasource Wizard', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const uiSettings = mockedContext.uiSettings; mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( noAuthCredentialAuthMethod ); @@ -125,6 +126,16 @@ describe('Datasource Management: Edit Datasource Wizard', () => { component.update(); expect(utils.updateDataSourceById).toHaveBeenCalled(); }); + test('should set default data source', async () => { + spyOn(uiSettings, 'set').and.returnValue({}); + await act(async () => { + // @ts-ignore + await component.find(formIdentifier).first().prop('onSetDefaultDataSource')( + mockDataSourceAttributesWithAuth + ); + }); + expect(uiSettings.set).toHaveBeenCalled(); + }); test('should delete datasource successfully', async () => { spyOn(utils, 'deleteDataSourceById').and.returnValue({}); diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx index bc2bac5b66b8..46e253b2b85b 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx @@ -38,6 +38,7 @@ export const EditDataSource: React.FunctionComponent { /* Initialization */ const { + uiSettings, savedObjects, setBreadcrumbs, http, @@ -83,6 +84,12 @@ export const EditDataSource: React.FunctionComponent { + await uiSettings.set('defaultDataSource', dataSourceID); + }; + + const isDefaultDataSource = uiSettings.get('defaultDataSource', null) === dataSourceID; + /* Handle submit - create data source*/ const handleSubmit = async (attributes: DataSourceAttributes) => { await updateDataSourceById(savedObjects.client, dataSourceID, attributes); @@ -128,7 +135,9 @@ export const EditDataSource: React.FunctionComponent { try { const url = new URL(endpoint); diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index 2d539cf19e12..e472860893ef 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -180,6 +180,10 @@ export const getMappedDataSources = [ }, ]; +export const fetchDataSourceVersion = { + dataSourceVersion: '2.11.0', +}; + export const mockDataSourceAttributesWithAuth = { id: 'test', title: 'create-test-ds', diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index bf0743468fd5..32c9a20b7f03 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -140,6 +140,7 @@ export interface DataSourceAttributes extends SavedObjectAttributes { title: string; description?: string; endpoint?: string; + dataSourceVersion?: string; auth: { type: AuthType | string; credentials: diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 1b20c444e860..6760416ab8c9 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -5,7 +5,6 @@ import { i18n } from '@osd/i18n'; import React from 'react'; -import { EuiText } from '@elastic/eui'; import { DiscoverViewServices } from '../../../build_services'; import { SavedSearch } from '../../../saved_searches'; import { Adapters } from '../../../../../inspector/public'; @@ -25,7 +24,6 @@ import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../ import { getSortForSearchSource } from '../../view_components/utils/get_sort_for_search_source'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { syncQueryStateWithUrl } from '../../../../../data/public'; -import { getNewDiscoverSetting, setNewDiscoverSetting } from '../utils/local_storage'; import { OpenSearchPanel } from './open_search_panel'; export const getTopNavLinks = ( @@ -44,7 +42,6 @@ export const getTopNavLinks = ( store, data: { query }, osdUrlStateStorage, - storage, } = services; const newSearch = { @@ -234,61 +231,7 @@ export const getTopNavLinks = ( }, }; - const newDiscoverButtonLabel = i18n.translate('discover.localMenu.discoverButton.label.new', { - defaultMessage: 'Try new Discover', - }); - const oldDiscoverButtonLabel = i18n.translate('discover.localMenu.discoverButton.label.old', { - defaultMessage: 'Use legacy Discover', - }); - const isNewDiscover = getNewDiscoverSetting(storage); - const newTable: TopNavMenuData = { - id: 'table-datagrid', - label: isNewDiscover ? oldDiscoverButtonLabel : newDiscoverButtonLabel, - description: i18n.translate('discover.localMenu.newTableDescription', { - defaultMessage: 'New Discover toggle Experience', - }), - testId: 'datagridTableButton', - run: async () => { - // Read the current state from localStorage - const newDiscoverEnabled = getNewDiscoverSetting(storage); - if (newDiscoverEnabled) { - const confirmed = await services.overlays.openConfirm( - toMountPoint( - -

- Help drive future improvements by{' '} - - providing feedback - {' '} - about your experience. -

-
- ), - { - title: i18n.translate('discover.localMenu.newTableConfirmModalTitle', { - defaultMessage: 'Share your thoughts on the latest Discover features', - }), - cancelButtonText: 'Cancel', - confirmButtonText: 'Turn off new features', - defaultFocusedButton: 'confirm', - } - ); - - if (confirmed) { - setNewDiscoverSetting(false, storage); - window.location.reload(); - } - } else { - // Save the new setting to localStorage - setNewDiscoverSetting(true, storage); - window.location.reload(); - } - }, - iconType: isNewDiscover ? 'editorUndo' : 'cheer', - }; - return [ - newTable, newSearch, ...(capabilities.discover?.save ? [saveSearch] : []), openSearch, diff --git a/src/plugins/discover/public/application/components/utils/local_storage.ts b/src/plugins/discover/public/application/components/utils/local_storage.ts index 5e812de8e97d..68bba0aafc99 100644 --- a/src/plugins/discover/public/application/components/utils/local_storage.ts +++ b/src/plugins/discover/public/application/components/utils/local_storage.ts @@ -9,9 +9,9 @@ export const NEW_DISCOVER_KEY = 'discover:newExpereince'; export const getNewDiscoverSetting = (storage: Storage): boolean => { const storedValue = storage.get(NEW_DISCOVER_KEY); - return storedValue !== null ? JSON.parse(storedValue) : false; + return storedValue !== null ? storedValue : false; }; export const setNewDiscoverSetting = (value: boolean, storage: Storage) => { - storage.set(NEW_DISCOVER_KEY, JSON.stringify(value)); + storage.set(NEW_DISCOVER_KEY, value); }; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss index 36408bd88366..2c2c8dfe8ebb 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss +++ b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss @@ -9,6 +9,13 @@ &_results { margin-left: $euiSizeM; + position: relative; + } + + &_options { + position: absolute; + top: 0; + right: 0; } } diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 1c2681995f98..ab34878750a7 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { EuiPanel } from '@elastic/eui'; +import { EuiButtonIcon, EuiContextMenu, EuiPanel, EuiPopover, EuiSwitch } from '@elastic/eui'; import { TopNav } from './top_nav'; import { ViewProps } from '../../../../../data_explorer/public'; import { DiscoverTable } from './discover_table'; @@ -21,13 +21,14 @@ import { filterColumns } from '../utils/filter_columns'; import { DEFAULT_COLUMNS_SETTING, MODIFY_COLUMNS_ON_SWITCH } from '../../../../common'; import { OpenSearchSearchHit } from '../../../application/doc_views/doc_views_types'; import './discover_canvas.scss'; +import { getNewDiscoverSetting, setNewDiscoverSetting } from '../../components/utils/local_storage'; // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { - services: { uiSettings }, + services: { uiSettings, storage }, } = useOpenSearchDashboards(); const { columns } = useSelector((state) => state.discover); const filteredColumns = filterColumns( @@ -96,6 +97,50 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro } }; + const [isOptionsOpen, setOptionsOpen] = useState(false); + const [useLegacy, setUseLegacy] = useState(!getNewDiscoverSetting(storage)); + const DiscoverOptions = () => ( + setOptionsOpen(!isOptionsOpen)} + /> + } + closePopover={() => setOptionsOpen(false)} + isOpen={isOptionsOpen} + panelPaddingSize="none" + className="dscCanvas_options" + > + + { + const checked = e.target.checked; + setUseLegacy(checked); + setNewDiscoverSetting(!checked, storage); + window.location.reload(); + }} + /> + + ), + }, + ]} + /> + + ); + return ( + )} diff --git a/src/plugins/vis_type_vega/server/plugin.ts b/src/plugins/vis_type_vega/server/plugin.ts index cf3339211698..4451cb70a28f 100644 --- a/src/plugins/vis_type_vega/server/plugin.ts +++ b/src/plugins/vis_type_vega/server/plugin.ts @@ -36,6 +36,11 @@ import { VisTypeVegaPluginSetup, VisTypeVegaPluginStart, } from './types'; +import { + VEGA_VISUALIZATION_CLIENT_WRAPPER_ID, + vegaVisualizationClientWrapper, +} from './vega_visualization_client_wrapper'; +import { setDataSourceEnabled } from './services'; export class VisTypeVegaPlugin implements Plugin { private readonly config: ConfigObservable; @@ -44,10 +49,19 @@ export class VisTypeVegaPlugin implements Plugin('DataSource'); diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson new file mode 100644 index 000000000000..5b23c66e67fb --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson @@ -0,0 +1,223 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: a-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: b-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSourceC + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name_c + data_source_name: c-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSourceD + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name_d + data_source_name: d-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson new file mode 100644 index 000000000000..8336fe9ac7de --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson @@ -0,0 +1,185 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: a-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: b-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson new file mode 100644 index 000000000000..d8085c5923f3 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson @@ -0,0 +1,165 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json new file mode 100644 index 000000000000..440fc26784e8 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "otherExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_other_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson new file mode 100644 index 000000000000..b92cdfca9886 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson @@ -0,0 +1,185 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: some other datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: some datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json new file mode 100644 index 000000000000..3e883388bc5c --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "localExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "local_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "otherExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_other_index_name", + "data_source_name": "some other datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "data_source_name": "some datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson new file mode 100644 index 000000000000..17f3f2e482ea --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson @@ -0,0 +1,61 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json new file mode 100644 index 000000000000..49392f5de16e --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson new file mode 100644 index 000000000000..7f307e84b0af --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson @@ -0,0 +1,62 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + data_source_name: example data source + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json new file mode 100644 index 000000000000..7b90845be17d --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "data_source_name": "example data source", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson new file mode 100644 index 000000000000..8c4a0193ba97 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson @@ -0,0 +1,117 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 400 + height: 200 + padding: 5 + // Data contained entirely within the spec + data: [ + { + name: table + values: [ + { + category: A + count: 28 + } + { + category: B + count: 55 + } + { + category: C + count: 43 + } + { + category: D + count: 91 + } + { + category: E + count: 81 + } + { + category: F + count: 53 + } + { + category: G + count: 19 + } + { + category: H + count: 87 + } + ] + } + ] + scales: [ + { + name: xscale + type: band + domain: { + data: table + field: category + } + range: width + padding: 0.05 + round: true + } + { + name: yscale + type: linear + domain: { + data: table + field: count + } + range: height + nice: true + } + ] + axes: [ + { + orient: bottom + scale: xscale + } + { + orient: left + scale: yscale + } + ] + marks: [ + { + type: rect + from: { + data: table + } + encode: { + enter: { + x: { + scale: xscale + field: category + } + width: { + scale: xscale + band: 1 + } + y: { + scale: yscale + field: count + } + y2: { + scale: yscale + value: 0 + } + } + update: { + fill: { + value: steelblue + } + } + hover: { + fill: { + value: red + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json new file mode 100644 index 000000000000..d24b9b207372 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 400, + "height": 200, + "padding": 5, + + "data": [ + { + "name": "table", + "values": [ + {"category": "A", "count": 28}, + {"category": "B", "count": 55}, + {"category": "C", "count": 43}, + {"category": "D", "count": 91}, + {"category": "E", "count": 81}, + {"category": "F", "count": 53}, + {"category": "G", "count": 19}, + {"category": "H", "count": 87} + ] + } + ], + + "scales": [ + { + "name": "xscale", + "type": "band", + "domain": {"data": "table", "field": "category"}, + "range": "width", + "padding": 0.05, + "round": true + }, + { + "name": "yscale", + "type": "linear", + "domain": {"data": "table", "field": "count"}, + "range": "height", + "nice": true + } + ], + + "axes": [ + { "orient": "bottom", "scale": "xscale" }, + { "orient": "left", "scale": "yscale" } + ], + + "marks": [ + { + "type": "rect", + "from": {"data": "table"}, + "encode": { + "enter": { + "x": {"scale": "xscale", "field": "category"}, + "width": {"scale": "xscale", "band": 1}, + "y": {"scale": "yscale", "field": "count"}, + "y2": {"scale": "yscale", "value": 0} + }, + "update": { + "fill": {"value": "steelblue"} + }, + "hover": { + "fill": {"value": "red"} + } + } + } + ] + } diff --git a/src/plugins/vis_type_vega/server/types.ts b/src/plugins/vis_type_vega/server/types.ts index 9695f6dc23d7..bcf4120577aa 100644 --- a/src/plugins/vis_type_vega/server/types.ts +++ b/src/plugins/vis_type_vega/server/types.ts @@ -29,6 +29,7 @@ */ import { Observable } from 'rxjs'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { HomeServerPluginSetup } from '../../home/server'; import { UsageCollectionSetup } from '../../usage_collection/server'; @@ -45,6 +46,7 @@ export interface VegaSavedObjectAttributes { export interface VisTypeVegaPluginSetupDependencies { usageCollection?: UsageCollectionSetup; home?: HomeServerPluginSetup; + dataSource?: DataSourcePluginSetup; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/src/plugins/vis_type_vega/server/utils.test.ts b/src/plugins/vis_type_vega/server/utils.test.ts new file mode 100644 index 000000000000..73d0f82954cb --- /dev/null +++ b/src/plugins/vis_type_vega/server/utils.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + extractDataSourceNamesInVegaSpec, + extractVegaSpecFromAttributes, + findDataSourceIdbyName, +} from './utils'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'src/core/server'; + +describe('findDataSourceIdbyName()', () => { + const savedObjectsClient = {} as SavedObjectsClientContract; + savedObjectsClient.find = jest.fn().mockImplementation((query: SavedObjectsFindOptions) => { + if (query.search === `"uniqueDataSource"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'some-datasource-id', attributes: { title: 'uniqueDataSource' } }], + }); + } else if (query.search === `"duplicateDataSource"`) { + return Promise.resolve({ + total: 2, + saved_objects: [ + { id: 'some-datasource-id', attributes: { title: 'duplicateDataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'duplicateDataSource' } }, + ], + }); + } else if (query.search === `"DataSource"`) { + return Promise.resolve({ + total: 2, + saved_objects: [ + { id: 'some-datasource-id', attributes: { title: 'DataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'DataSource Copy' } }, + ], + }); + } else { + return Promise.resolve({ + total: 0, + saved_objects: [], + }); + } + }); + + test('If no matching dataSourceName, then throw error', () => { + expect( + findDataSourceIdbyName({ dataSourceName: 'nonexistentDataSource', savedObjectsClient }) + ).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "nonexistentDataSource" but got 0 results' + ); + }); + + test('If duplicate dataSourceNames, then throw error', () => { + expect( + findDataSourceIdbyName({ dataSourceName: 'duplicateDataSource', savedObjectsClient }) + ).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "duplicateDataSource" but got 2 results' + ); + }); + + test('If dataSource is enabled but only one dataSourceName, then return id', async () => { + expect( + await findDataSourceIdbyName({ dataSourceName: 'uniqueDataSource', savedObjectsClient }) + ).toBe('some-datasource-id'); + }); + + test('If dataSource is enabled and the dataSourceName is a prefix of another, ensure the prefix is only returned', async () => { + expect(await findDataSourceIdbyName({ dataSourceName: 'DataSource', savedObjectsClient })).toBe( + 'some-datasource-id' + ); + }); +}); + +describe('extractDataSourceNamesInVegaSpec()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const loadJSONFromFile = (filepath: string) => { + return JSON.parse(readFileSync(join(__dirname, filepath)).toString()); + }; + + // JSON test cases + test('(JSON) Set should be empty when no queries are in the Vega spec', () => { + const noQueryJSON = loadJSONFromFile('/test_utils/vega_spec_without_opensearch_query.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(noQueryJSON))).toMatchObject(new Set()); + }); + + test('(JSON) Set should be empty when one local cluster query is in the Vega spec', () => { + const oneLocalQueryJSON = loadJSONFromFile('/test_utils/vega_spec_with_opensearch_query.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(oneLocalQueryJSON))).toMatchObject( + new Set() + ); + }); + + test('(JSON) Set should have exactly one data_source_name when one data source query is in the Vega spec', () => { + const oneDataSourceQueryJSON = loadJSONFromFile( + '/test_utils/vega_spec_with_opensearch_query_mds.json' + ); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(oneDataSourceQueryJSON))).toMatchObject( + new Set(['example data source']) + ); + }); + + test('(JSON) Set should be empty when many local cluster queries are in the Vega spec', () => { + const manyLocalQueriesJSON = loadJSONFromFile('/test_utils/vega_spec_with_multiple_urls.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(manyLocalQueriesJSON))).toMatchObject( + new Set() + ); + }); + + test('(JSON) Set have multiple data_source_name fields when the Vega spec has a mix of local cluster and data source queries', () => { + const manyDataSourceQueriesJSON = loadJSONFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.json' + ); + expect( + extractDataSourceNamesInVegaSpec(JSON.stringify(manyDataSourceQueriesJSON)) + ).toMatchObject(new Set(['some other datasource name', 'some datasource name'])); + }); + + // HJSON test cases + test('(HJSON) Set should be empty when no queries are in the Vega spec', () => { + const noQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_without_opensearch_query.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(noQueryHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set should be empty when one local cluster query is in the Vega spec', () => { + const oneLocalQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(oneLocalQueryHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set should have exactly one data_source_name when one data source query is in the Vega spec', () => { + const oneDataSourceQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query_mds.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(oneDataSourceQueryHJSON)).toMatchObject( + new Set(['example data source']) + ); + }); + + test('(HJSON) Set should be empty when many local cluster queries are in the Vega spec', () => { + const manyLocalQueriesHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(manyLocalQueriesHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set have multiple data_source_name fields when the Vega spec has a mix of local cluster and data source queries', () => { + const manyDataSourceQueriesHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(manyDataSourceQueriesHJSON)).toMatchObject( + new Set(['some other datasource name', 'some datasource name']) + ); + }); +}); + +describe('extractVegaSpecFromSavedObject()', () => { + test('For a Vega visualization saved object, return its spec', () => { + const spec = 'some-vega-spec'; + const vegaAttributes = { + visState: `{"type": "vega", "params": {"spec": "${spec}"}}`, + }; + + expect(extractVegaSpecFromAttributes(vegaAttributes)).toBe(spec); + }); + + test('For another saved object type, return undefined', () => { + const nonVegaAttributes = { + visState: `{"type": "area", "params": {"spec": "some-spec"}}`, + }; + + expect(extractVegaSpecFromAttributes(nonVegaAttributes)).toBe(undefined); + }); +}); diff --git a/src/plugins/vis_type_vega/server/utils.ts b/src/plugins/vis_type_vega/server/utils.ts new file mode 100644 index 000000000000..f8c83dce531e --- /dev/null +++ b/src/plugins/vis_type_vega/server/utils.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parse } from 'hjson'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; + +export interface FindDataSourceByTitleQueryProps { + dataSourceName: string; + savedObjectsClient: SavedObjectsClientContract; +} + +export const findDataSourceIdbyName = async (props: FindDataSourceByTitleQueryProps) => { + const { dataSourceName } = props; + const dataSources = await dataSourceFindQuery(props); + + // In the case that data_source_name is a prefix of another name, match exact data_source_name + const possibleDataSourceObjects = dataSources.saved_objects.filter( + (obj) => obj.attributes.title === dataSourceName + ); + + if (possibleDataSourceObjects.length !== 1) { + throw new Error( + `Expected exactly 1 result for data_source_name "${dataSourceName}" but got ${possibleDataSourceObjects.length} results` + ); + } + + return possibleDataSourceObjects.pop()?.id; +}; + +export const extractVegaSpecFromAttributes = (attributes: unknown) => { + if (isVegaVisualization(attributes)) { + // @ts-expect-error + const visStateObject = JSON.parse(attributes?.visState); + return visStateObject.params.spec; + } + + return undefined; +}; + +export const extractDataSourceNamesInVegaSpec = (spec: string) => { + const parsedSpec = parse(spec, { keepWsc: true }); + const dataField = parsedSpec.data; + const dataSourceNameSet = new Set(); + + if (dataField instanceof Array) { + dataField.forEach((dataObject) => { + const dataSourceName = getDataSourceNameFromObject(dataObject); + if (!!dataSourceName) { + dataSourceNameSet.add(dataSourceName); + } + }); + } else if (dataField instanceof Object) { + const dataSourceName = getDataSourceNameFromObject(dataField); + if (!!dataSourceName) { + dataSourceNameSet.add(dataSourceName); + } + } else { + throw new Error(`"data" field should be an object or an array of objects`); + } + + return dataSourceNameSet; +}; + +const getDataSourceNameFromObject = (dataObject: any) => { + if ( + dataObject.hasOwnProperty('url') && + dataObject.url.hasOwnProperty('index') && + dataObject.url.hasOwnProperty('data_source_name') + ) { + return dataObject.url.data_source_name; + } + + return undefined; +}; + +const isVegaVisualization = (attributes: unknown) => { + // @ts-expect-error + const visState = attributes?.visState; + if (!!visState) { + const visStateObject = JSON.parse(visState); + return !!visStateObject.type && visStateObject.type === 'vega'; + } + return false; +}; + +const dataSourceFindQuery = async (props: FindDataSourceByTitleQueryProps) => { + const { savedObjectsClient, dataSourceName } = props; + return await savedObjectsClient.find({ + type: 'data-source', + perPage: 10, + search: `"${dataSourceName}"`, + searchFields: ['title'], + fields: ['id', 'title'], + }); +}; diff --git a/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts new file mode 100644 index 000000000000..09af5459cf1d --- /dev/null +++ b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts @@ -0,0 +1,243 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { SavedObjectsClientWrapperOptions, SavedObjectsFindOptions } from 'src/core/server'; +import { savedObjectsClientMock } from '../../../core/server/mocks'; +import { vegaVisualizationClientWrapper } from './vega_visualization_client_wrapper'; + +jest.mock('./services', () => ({ + getDataSourceEnabled: jest + .fn() + .mockReturnValueOnce({ enabled: false }) + .mockReturnValue({ enabled: true }), +})); + +describe('vegaVisualizationClientWrapper()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const getAttributesGivenSpec = (spec: string) => { + return { + title: 'Some Spec', + visState: JSON.stringify({ + title: 'Some Spec', + type: 'vega', + aggs: [], + params: { + spec, + }, + }), + }; + }; + + const client = savedObjectsClientMock.create(); + client.bulkGet = jest + .fn() + .mockImplementation((dataSourceIds: Array<{ id: string; type: string }>) => { + return Promise.resolve({ + saved_objects: dataSourceIds.map((request) => { + if (request.type === 'data-source' && request.id === 'id-a') { + return { + id: 'id-a', + attributes: { + title: 'a-title', + }, + }; + } else if (request.type === 'data-source' && request.id === 'id-b') { + return { + id: 'id-b', + attributes: { + title: 'b-title', + }, + }; + } else if (request.type === 'data-source' && request.id === 'id-z') { + return { + id: 'id-z', + attributes: { + title: 'z-title', + }, + }; + } + + return { + id: request.id, + attributes: undefined, + }; + }), + }); + }); + client.find = jest.fn().mockImplementation((query: SavedObjectsFindOptions) => { + if (query.search === `"c-title"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'id-c', attributes: { title: 'c-title' } }], + }); + } else if (query.search === `"d-title"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'id-d', attributes: { title: 'd-title' } }], + }); + } else { + return Promise.resolve({ + total: 0, + saved_objects: [], + }); + } + }); + const mockedWrapperOptions = {} as SavedObjectsClientWrapperOptions; + mockedWrapperOptions.client = client; + + beforeEach(() => { + client.create.mockClear(); + }); + + test('Should just call create as usual if MDS is disabled', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', {}, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + {}, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should just call create as usual if object type is not visualization type', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('dashboard', {}, { references: [] }); + expect(client.create).toBeCalledWith( + 'dashboard', + {}, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should just call create as usual if object type is not vega type', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + // Avoids whitespacing issues by letting stringify format the string + const visState = JSON.stringify( + JSON.parse('{"type": "area", "params": {"spec": "no-spec-here"}}') + ); + await wrapper.create('visualization', { visState }, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + { visState }, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should not update anything if the spec does not specify any data_source_name', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls.hjson'); + const attributes = getAttributesGivenSpec(spec); + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should not update anything if the references is still up-to-date', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_up_to_date_urls_mds.hjson'); + const attributes = getAttributesGivenSpec(spec); + const references = [ + { + id: 'id-a', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-b', + type: 'data-source', + name: 'dataSource', + }, + ]; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references }) + ); + }); + + test('Should throw an error if the Vega spec has invalid data_source_name field(s)', () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls_mds.hjson'); + const visState = { + title: 'Some Spec', + type: 'vega', + aggs: [], + params: { + spec, + }, + }; + const attributes = { + title: 'Some Spec', + visState: JSON.stringify(visState), + }; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + expect(wrapper.create('visualization', attributes, { references: [] })).rejects.toThrowError( + `Expected exactly 1 result for data_source_name` + ); + }); + + test('Should update only the references section', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_outdated_references_mds.hjson'); + const attributes = getAttributesGivenSpec(spec); + const commonReferences = [ + { + id: 'some-dashboard', + type: 'dashboard', + name: 'someDashboard', + }, + { + id: 'id-a', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-b', + type: 'data-source', + name: 'dataSource', + }, + ]; + const oldReferences = [ + ...commonReferences, + { + id: 'id-z', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'non-existent-id', + type: 'data-source', + name: 'dataSource', + }, + ]; + const newReferences = [ + ...commonReferences, + { + id: 'id-c', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-d', + type: 'data-source', + name: 'dataSource', + }, + ]; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: oldReferences }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references: newReferences }) + ); + }); +}); diff --git a/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts new file mode 100644 index 000000000000..4deada346c38 --- /dev/null +++ b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SavedObjectsClientWrapperFactory, + SavedObjectsClientWrapperOptions, + SavedObjectsCreateOptions, + SavedObjectsErrorHelpers, +} from '../../../core/server'; +import { + extractDataSourceNamesInVegaSpec, + extractVegaSpecFromAttributes, + findDataSourceIdbyName, +} from './utils'; +import { getDataSourceEnabled } from './services'; + +export const VEGA_VISUALIZATION_CLIENT_WRAPPER_ID = 'vega-visualization-client-wrapper'; + +export const vegaVisualizationClientWrapper: SavedObjectsClientWrapperFactory = ( + wrapperOptions: SavedObjectsClientWrapperOptions +) => { + const createForVega = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const vegaSpec = extractVegaSpecFromAttributes(attributes); + if (type !== 'visualization' || vegaSpec === undefined || !getDataSourceEnabled().enabled) { + return await wrapperOptions.client.create(type, attributes, options); + } + const dataSourceNamesSet = extractDataSourceNamesInVegaSpec(vegaSpec); + + const existingDataSourceReferences = options?.references + ?.filter((reference) => reference.type === 'data-source') + .map((dataSourceReference) => { + return { + id: dataSourceReference.id, + type: dataSourceReference.type, + }; + }); + + const existingDataSourceIdToNameMap = new Map(); + if (!!existingDataSourceReferences && existingDataSourceReferences.length > 0) { + (await wrapperOptions.client.bulkGet(existingDataSourceReferences)).saved_objects.forEach( + (object) => { + // @ts-expect-error + if (!!object.attributes && !!object.attributes.title) { + // @ts-expect-error + existingDataSourceIdToNameMap.set(object.id, object.attributes.title); + } + } + ); + } + + // Filters out outdated datasource references + const newReferences = options?.references?.filter((reference) => { + if (reference.type !== 'data-source') { + return true; + } + const dataSourceName = existingDataSourceIdToNameMap.get(reference.id); + if (dataSourceNamesSet.has(dataSourceName)) { + dataSourceNamesSet.delete(dataSourceName); + return true; + } + + return false; + }); + + for await (const dataSourceName of dataSourceNamesSet) { + const dataSourceId = await findDataSourceIdbyName({ + dataSourceName, + savedObjectsClient: wrapperOptions.client, + }); + if (dataSourceId) { + newReferences?.push({ + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }); + } else { + throw SavedObjectsErrorHelpers.createBadRequestError( + `data_source_name "${dataSourceName}" cannot be found in saved objects` + ); + } + } + + return await wrapperOptions.client.create(type, attributes, { + ...options, + references: newReferences, + }); + }; + + return { + ...wrapperOptions.client, + create: createForVega, + bulkCreate: wrapperOptions.client.bulkCreate, + checkConflicts: wrapperOptions.client.checkConflicts, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; +}; diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index e60bb6aea0eb..6ae89c0edad5 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index f34106ab4fed..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..f70c627e02b0 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..01403b9bc33c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render error with callout 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you are trying to access cannot be found. Please return to the homepage and try again. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` render normally 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you are trying to access cannot be found. Please return to the homepage and try again. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/index.ts b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts new file mode 100644 index 000000000000..afb34b10d913 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceFatalError } from './workspace_fatal_error'; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx new file mode 100644 index 000000000000..11b229c9ccac --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceFatalError } from './workspace_fatal_error'; +import { context } from '../../../../opensearch_dashboards_react/public'; +import { coreMock } from '../../../../../core/public/mocks'; + +describe('', () => { + it('render normally', async () => { + const { findByText, container } = render( + + + + ); + await findByText('Something went wrong'); + expect(container).toMatchSnapshot(); + }); + + it('render error with callout', async () => { + const { findByText, container } = render( + + + + ); + await findByText('errorInCallout'); + expect(container).toMatchSnapshot(); + }); + + it('click go back to homepage', async () => { + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + const coreStartMock = coreMock.createStart(); + const { getByText } = render( + + + + + + ); + fireEvent.click(getByText('Go back to homepage')); + await waitFor( + () => { + expect(setHrefSpy).toBeCalledTimes(1); + }, + { + container: document.body, + } + ); + window.location = location; + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx new file mode 100644 index 000000000000..604dec277553 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiCallOut, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { HttpSetup } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +export function WorkspaceFatalError(props: { error?: string }) { + const { + services: { http }, + } = useOpenSearchDashboards(); + const goBackToHome = () => { + window.location.href = (http as HttpSetup).basePath.prepend('/', { + withoutClientBasePath: true, + }); + }; + return ( + + + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + ]} + /> + {props.error ? : null} +
+
+
+ ); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index e54a20552329..1bdbd7ef31ad 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable, Subscriber } from 'rxjs'; +import { waitFor } from '@testing-library/dom'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; describe('Workspace plugin', () => { const getSetupMock = () => ({ @@ -23,12 +26,17 @@ describe('Workspace plugin', () => { expect(WorkspaceClientMock).toBeCalledTimes(1); }); - it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', async () => { const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); const coreStart = coreMock.createStart(); + await workspacePlugin.setup(setupMock); workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); it('#setup when workspace id is in url and enterWorkspace return error', async () => { @@ -41,11 +49,82 @@ describe('Workspace plugin', () => { }, } as any) ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: false, + error: 'error', + }); const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: applicationStartMock, + chrome: chromeStartMock, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, + }, + {}, + {}, + ]) as any; + }); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); - expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a69d597c84b..e3ecdc34bfb9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,10 +4,20 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + AppNavLinkStatus, +} from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; @@ -33,9 +43,60 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); if (workspaceId) { - core.workspaces.currentWorkspaceId$.next(workspaceId); + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result?.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } } + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index dde86c697e3c..17e29fa5cf6f 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -193,6 +193,7 @@ export default function ({ getService, getPageObjects }) { before(async () => { await filterBar.ensureFieldEditorModalIsClosed(); await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultDataRange(); await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.gotoDashboardLandingPage(); diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index 1040b87f6168..6be4b4837da0 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -80,6 +80,7 @@ export default function ({ getService, getPageObjects }) { describe('adding a filter that excludes all data', () => { before(async () => { await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultDataRange(); await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.gotoDashboardLandingPage(); diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index 11196a1b69b9..78f0cdb16184 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -57,6 +57,7 @@ export default function ({ getService, getPageObjects }) { describe('dashboard state', function describeIndexTests() { before(async function () { await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setHistoricalDataRange(); await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.initTests(); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 8a4659630ee1..f3d89138eb77 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -161,9 +161,9 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName); await retry.try(async function () { @@ -280,9 +280,9 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { @@ -377,9 +377,9 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { @@ -477,9 +477,9 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 19:22:00.000'; const toTime = 'Sep 18, 2015 @ 07:00:00.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await retry.try(async function () { diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index cf0b03022ab9..8f03ae20ddf7 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -519,12 +519,15 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider public async switchDiscoverTable(tableType: string) { await retry.try(async () => { - const switchButton = await testSubjects.find('datagridTableButton'); - const buttonText = await switchButton.getVisibleText(); + const optionsButton = await testSubjects.find('discoverOptionsButton'); + await optionsButton.click(); - if (tableType === 'new' && buttonText.includes('Try new Discover')) { + const switchButton = await testSubjects.find('discoverOptionsLegacySwitch'); + const isLegacyChecked = (await switchButton.getAttribute('aria-checked')) === 'true'; + + if (tableType === 'new' && isLegacyChecked) { await switchButton.click(); - } else if (tableType === 'legacy' && buttonText.includes('Use legacy Discover')) { + } else if (tableType === 'legacy' && !isLegacyChecked) { await switchButton.click(); } }); diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts index b745d6e8a417..d215017132ae 100644 --- a/test/plugin_functional/test_suites/doc_views/doc_views.ts +++ b/test/plugin_functional/test_suites/doc_views/doc_views.ts @@ -39,10 +39,10 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide describe('custom doc views with datagrid table', function () { before(async () => { await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.switchDiscoverTable('new'); // TODO: change back to setDefaultRange() once we resolve // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5241 await PageObjects.timePicker.setDefaultRangeForDiscover(); + await PageObjects.discover.switchDiscoverTable('new'); }); it('should show custom doc views', async () => { diff --git a/yarn.lock b/yarn.lock index 2dbf1c7e21da..c9b266d1d1a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9263,9 +9263,9 @@ focus-lock@^0.10.2: tslib "^2.0.3" follow-redirects@^1.15.0, follow-redirects@^1.15.4: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== font-awesome@4.7.0: version "4.7.0"