diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e67e782..274d5b862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: node utils/codeMetrics.js env: @@ -33,10 +33,10 @@ jobs: name: "Compile TypeScript" steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npm run compile - run: npm run compile:strict @@ -45,10 +45,10 @@ jobs: name: "Check Format" steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npm run format:check lint: @@ -56,10 +56,10 @@ jobs: name: "Lint" steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npm run lint unittest: @@ -67,10 +67,10 @@ jobs: name: "Unit Tests" steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npm run test build: @@ -78,10 +78,10 @@ jobs: name: "Build" steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npm run build:contracts - name: Restore Build Cache @@ -173,10 +173,10 @@ jobs: shardTotal: [20] steps: - uses: actions/checkout@v4 - - name: Use Node.js 18.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 22.x - run: npm ci - run: npx playwright install --with-deps - name: "Az CLI login" @@ -265,7 +265,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22.x - name: Install dependencies run: npm ci diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index b045f3bd3..add9f15e1 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -31,9 +31,9 @@ jobs: tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} - - name: Use Node.js 20.x + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 22.x - run: npm ci - run: node utils/cleanupDBs.js \ No newline at end of file diff --git a/README.md b/README.md index 797ab6c2b..8b0a5c0fc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ UI for Azure Cosmos DB. Powers the [Azure Portal](https://portal.azure.com/), ht ## Getting Started +- Install Node.js 22.x. - `npm install` - `npm run build` diff --git a/docs/remove-notebooks-plan.md b/docs/remove-notebooks-plan.md index 6070eaf3e..6ff123c93 100644 --- a/docs/remove-notebooks-plan.md +++ b/docs/remove-notebooks-plan.md @@ -7,14 +7,14 @@ the **Phoenix** compute-container service and the **Juno** service, plus a **Git integration used to pin/browse notebook repositories. This functionality is being retired. The goal is to remove all notebooks/Phoenix/Juno/GitHub-for-notebooks code, dependencies, UI surfaces, telemetry, and configuration, while **preserving the -database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo), which today +database shell terminals** (Mongo / Cassandra / Postgres / VCoreMongo / Cosmos DB NoSQL), which today share the Terminal infrastructure with notebooks. This document is the implementation plan only. No code changes are made here. ## Scope decisions (confirmed) -- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo shells, but migrate +- **Database shells**: Keep the Mongo/Cassandra/Postgres/VCoreMongo/Cosmos DB NoSQL shells, but migrate them to use the **CloudShell** path exclusively. Remove the legacy Phoenix notebook-server shell path. - **GitHub integration**: Remove entirely (it exists only for notebook pinned repos). @@ -108,7 +108,7 @@ This is a continuation of an in-progress removal effort. Reference commits: Each phase is independently buildable and shippable. Within each phase, **all references to removed code are also removed** so the tree compiles. After every phase run: `npm run compile`, `npm run compile:strict`, `npm run lint`, `npm run format:check`, -`npm test`, and a webpack build (`npm run build:ci`); manually verify the four shells +`npm test`, and a webpack build (`npm run build:ci`); manually verify the five shells still open. ### Phase 1 — Decouple database shells to CloudShell-only ✅ COMPLETED @@ -235,7 +235,28 @@ engine deleted there. No separate work remains for this phase. pinned-repo methods. - Remove GitHub-related localization keys from **all** locale files (`en` + non-English). -### Phase 5 — Remove Phoenix and the notebook container/allocation core +### Phase 5 — Remove Phoenix and the notebook container/allocation core ✅ COMPLETED +> **Status:** Implemented on branch `users/jawelton/remove-notebooks-phase5-081426`. +> The automated verification sweep is green: `compile`, `compile:strict`, `lint`, +> `format:check`, `test` (1931 passing, 5 skipped), and `build:ci` (webpack) all pass. +> +> **Implementation notes / deviations:** +> - Removed the Phoenix-only connection status and memory tracker command-bar UI, which +> depended entirely on the deleted `useNotebook` container state. +> - Moved the unrelated `isSynapseLinkUpdating` flag from `useNotebook` into the existing +> `useCommandBar` Zustand store and made static command creation react to it. +> - Finished the deferred Phase 1 cleanup by replacing remaining mixed +> `isShellEnabled`/notebook availability checks with the `enableCloudShell` feature gate. +> The non-Phoenix Mongo fallback remains available when CloudShell is disabled. +> - Removed the remaining direct nteract/runtime dependencies with no surviving importers: +> `@nteract/commutable`, `@nteract/core`, `@nteract/fixtures`, `@nteract/myths`, and +> `rx-jupyter`. Added `@types/uuid` explicitly because surviving CloudShell/UserContext +> code had previously received UUID declarations through the removed transitive graph. +> - Preserved the newer Cosmos DB (NoSQL) Cloud Shell added after this roadmap was written; +> all terminal tabs still instantiate `CloudShellTerminalComponentAdapter`. +> - Residual notebook/Phoenix config, contracts, telemetry, localization, Juno, generated +> ARM clients, and images remain intentionally deferred to Phase 6. + - Delete `src/Phoenix/`, `src/Explorer/Notebook/NotebookContainerClient.ts`, `src/Explorer/Notebook/NotebookManager.tsx`, `src/Explorer/Notebook/useNotebook.ts`, `src/Explorer/Notebook/NotebookContentItem.ts`, `src/Explorer/Notebook/NotebookUtil.ts` @@ -279,7 +300,7 @@ npm run format:check npm test npm run build:ci ``` -Plus manual smoke test: open Mongo, Cassandra, Postgres, and VCoreMongo shells. +Plus manual smoke test: open Mongo, Cassandra, Postgres, VCoreMongo, and Cosmos DB NoSQL shells. ## Notes & considerations diff --git a/package-lock.json b/package-lock.json index 6d61697c4..01ec4ba8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,7 @@ "@fluentui/react": "8.119.0", "@fluentui/react-components": "9.54.2", "@microsoft/applicationinsights-web": "2.6.1", - "@nteract/commutable": "7.5.1", - "@nteract/core": "15.1.9", - "@nteract/fixtures": "2.3.0", "@nteract/markdown": "4.6.0", - "@nteract/myths": "0.1.9", "@octokit/request": "8.4.1", "@octokit/rest": "17.9.2", "@testing-library/jest-dom": "6.4.6", @@ -92,7 +88,6 @@ "react-window": "1.8.10", "react-youtube": "9.0.1", "reflect-metadata": "0.1.13", - "rx-jupyter": "5.5.12", "shell-quote": "1.10.0", "styled-components": "5.0.1", "swr": "0.4.0", @@ -102,7 +97,7 @@ "utility-types": "3.10.0", "uuid": "9.0.0", "web-vitals": "4.2.4", - "ws": "8.20.1", + "ws": "8.21.3", "xterm": "4.19.0", "xterm-addon-fit": "0.5.0", "zustand": "3.5.0" @@ -126,7 +121,7 @@ "@types/hasher": "0.0.31", "@types/jest": "29.5.12", "@types/jquery": "3.5.29", - "@types/node": "18.19.0", + "@types/node": "22.14.1", "@types/post-robot": "10.0.1", "@types/q": "1.5.1", "@types/react": "17.0.44", @@ -139,6 +134,7 @@ "@types/sinon": "2.3.3", "@types/styled-components": "5.1.32", "@types/underscore": "1.7.36", + "@types/uuid": "9.0.8", "@types/youtube-player": "5.5.6", "@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/parser": "6.7.4", @@ -196,7 +192,10 @@ "webpack-bundle-analyzer": "5.2.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.2.5", - "ws": "8.20.1" + "ws": "8.21.3" + }, + "engines": { + "node": "22.x" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -6396,154 +6395,6 @@ "node": ">= 8" } }, - "node_modules/@nteract/actions": { - "version": "7.1.0", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "@nteract/messaging": "^7.0.19", - "@nteract/types": "^7.1.9", - "immutable": "^4.0.0-rc.12", - "rx-jupyter": "^5.5.21" - } - }, - "node_modules/@nteract/actions/node_modules/rx-jupyter": { - "version": "5.5.21", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "@nteract/messaging": "^7.0.19", - "@types/ungap__url-search-params": "^0.1.0", - "@ungap/url-search-params": "^0.2.0", - "js-cookie": "^2.2.0", - "rxjs": "^6.6.0", - "url-join": "^4.0.0" - } - }, - "node_modules/@nteract/commutable": { - "version": "7.5.1", - "license": "BSD-3-Clause", - "dependencies": { - "immutable": "^4.0.0-rc.12", - "uuid": "^8.0.0" - } - }, - "node_modules/@nteract/commutable/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@nteract/core": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@nteract/core/-/core-15.1.9.tgz", - "integrity": "sha512-kQf+7d1Qi27aVOvu+q07pXw8dXFUsU7JJLZ3JeAzSxVizNE/PjbEPn9Ruc+AV/I/FqXZovGp7Q04AGr25hsuyA==", - "dependencies": { - "@nteract/actions": "^7.0.11", - "@nteract/commutable": "^7.4.5", - "@nteract/epics": "^5.0.11", - "@nteract/reducers": "^5.1.9", - "@nteract/selectors": "^3.1.9", - "@nteract/types": "^7.1.9", - "redux-logger": "^3.0.6" - }, - "peerDependencies": { - "immutable": "^4.0.0-rc.12" - } - }, - "node_modules/@nteract/epics": { - "version": "5.1.0", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/actions": "^7.0.11", - "@nteract/commutable": "^7.4.5", - "@nteract/messaging": "^7.0.19", - "@nteract/mythic-configuration": "^1.0.11", - "@nteract/mythic-notifications": "^0.2.11", - "@nteract/selectors": "^3.1.9", - "@nteract/types": "^7.1.9", - "file-saver": "^2.0.0", - "redux": "^4.0.1", - "redux-observable": "^2.0.0-alpha.0", - "rx-jupyter": "^5.5.21", - "rxjs": "^6.3.3" - } - }, - "node_modules/@nteract/epics/node_modules/rx-jupyter": { - "version": "5.5.21", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "@nteract/messaging": "^7.0.19", - "@types/ungap__url-search-params": "^0.1.0", - "@ungap/url-search-params": "^0.2.0", - "js-cookie": "^2.2.0", - "rxjs": "^6.6.0", - "url-join": "^4.0.0" - } - }, - "node_modules/@nteract/fixtures": { - "version": "2.3.0", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.2.6", - "@nteract/reducers": "^3.1.0", - "@nteract/types": "^6.0.0" - } - }, - "node_modules/@nteract/fixtures/node_modules/@nteract/actions": { - "version": "5.0.1", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.2.11", - "@nteract/messaging": "^7.0.6", - "@nteract/types": "^6.0.6", - "immutable": "^4.0.0-rc.12", - "rx-jupyter": "^5.5.8" - } - }, - "node_modules/@nteract/fixtures/node_modules/@nteract/reducers": { - "version": "3.2.1", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/actions": "^5.0.1", - "@nteract/commutable": "^7.2.11", - "@nteract/types": "^6.0.6", - "escape-carriage": "^1.3.0", - "immutable": "^4.0.0-rc.12", - "lodash.has": "^4.5.2", - "redux": "^4.0.1", - "redux-immutable": "^4.0.0", - "uuid": "^7.0.0" - } - }, - "node_modules/@nteract/fixtures/node_modules/@nteract/reducers/node_modules/uuid": { - "version": "7.0.3", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@nteract/fixtures/node_modules/@nteract/types": { - "version": "6.0.7", - "license": "MIT", - "dependencies": { - "@nteract/commutable": "^7.2.12", - "immutable": "^4.0.0-rc.12", - "rxjs": "^6.3.3", - "uuid": "^8.0.0" - } - }, - "node_modules/@nteract/fixtures/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@nteract/markdown": { "version": "4.6.0", "license": "BSD-3-Clause", @@ -6568,184 +6419,6 @@ "react-dom": "^16.13.0 || ^17.0.0" } }, - "node_modules/@nteract/messaging": { - "version": "7.0.20", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/types": "^7.1.9", - "@types/uuid": "^8.0.0", - "lodash.clonedeep": "^4.5.0", - "rxjs": "^6.6.0", - "uuid": "^8.0.0" - } - }, - "node_modules/@nteract/messaging/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@nteract/mythic-configuration": { - "version": "1.0.12", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/myths": "^0.2.11", - "fs-observable": "^4.1.14" - } - }, - "node_modules/@nteract/mythic-configuration/node_modules/@nteract/myths": { - "version": "0.2.13", - "license": "BSD-3-Clause", - "dependencies": { - "redux": "^4.0.0", - "redux-observable": "^2.0.0-alpha.0", - "rxjs": "^6.3.3" - }, - "peerDependencies": { - "immutable": "^4.0.0-rc.12", - "react": "^16.3.2", - "react-dom": "^16.3.2", - "react-redux": "^7.2.0" - } - }, - "node_modules/@nteract/mythic-configuration/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT", - "peer": true - }, - "node_modules/@nteract/mythic-configuration/node_modules/react-redux": { - "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/react-redux": "^7.1.20", - "hoist-non-react-statics": "^3.3.2", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@nteract/mythic-notifications": { - "version": "0.2.11", - "license": "BSD-3-Clause", - "dependencies": { - "@blueprintjs/core": "^3.7.0", - "@nteract/myths": "^0.2.11" - }, - "peerDependencies": { - "immutable": "^4.0.0-rc.12", - "react": "^16.3.2", - "react-dom": "^16.3.2", - "styled-components": ">= 5.0.1" - } - }, - "node_modules/@nteract/mythic-notifications/node_modules/@nteract/myths": { - "version": "0.2.13", - "license": "BSD-3-Clause", - "dependencies": { - "redux": "^4.0.0", - "redux-observable": "^2.0.0-alpha.0", - "rxjs": "^6.3.3" - }, - "peerDependencies": { - "immutable": "^4.0.0-rc.12", - "react": "^16.3.2", - "react-dom": "^16.3.2", - "react-redux": "^7.2.0" - } - }, - "node_modules/@nteract/mythic-notifications/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT", - "peer": true - }, - "node_modules/@nteract/mythic-notifications/node_modules/react-redux": { - "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/react-redux": "^7.1.20", - "hoist-non-react-statics": "^3.3.2", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/@nteract/myths": { - "version": "0.1.9", - "resolved": "https://registry.npmjs.org/@nteract/myths/-/myths-0.1.9.tgz", - "integrity": "sha512-aPLUamd0kTnzy+8usMsGs+kgMfxfx2QNvDuxd5+ko+9DKuyMJx8quNuqphlxaLXNGiJrnfNi5krXaAyNEil3Sw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-redux": "^6.0.0", - "redux": "^4.0.0", - "redux-observable": "^2.0.0-alpha.0", - "rxjs": "^6.3.3" - }, - "peerDependencies": { - "immutable": "^4.0.0-rc.12", - "react": "^16.3.2", - "react-dom": "^16.3.2" - } - }, - "node_modules/@nteract/myths/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/@nteract/myths/node_modules/react-redux": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-6.0.1.tgz", - "integrity": "sha512-T52I52Kxhbqy/6TEfBv85rQSDz6+Y28V/pf52vDWs1YRXG19mcFOGfHnY2HsNFHyhP+ST34Aih98fvt6tqwVcQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.3.1", - "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^16.8.2" - }, - "peerDependencies": { - "react": "^16.4.0-0", - "redux": "^2.0.0 || ^3.0.0 || ^4.0.0-0" - } - }, "node_modules/@nteract/presentational-components": { "version": "3.4.12", "resolved": "https://registry.npmjs.org/@nteract/presentational-components/-/presentational-components-3.4.12.tgz", @@ -6763,71 +6436,6 @@ "styled-components": ">= 5.0.1" } }, - "node_modules/@nteract/reducers": { - "version": "5.1.13", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/actions": "^7.0.11", - "@nteract/commutable": "^7.4.5", - "@nteract/types": "^7.1.9", - "escape-carriage": "^1.3.0", - "immutable": "^4.0.0-rc.12", - "lodash.has": "^4.5.2", - "redux": "^4.0.1", - "redux-immutable": "^4.0.0", - "uuid": "^8.0.0" - } - }, - "node_modules/@nteract/reducers/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@nteract/selectors": { - "version": "3.2.0", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "@nteract/types": "^7.1.9", - "immutable": "^4.0.0-rc.12", - "reselect": "^4.0.0", - "rx-jupyter": "^5.5.21" - } - }, - "node_modules/@nteract/selectors/node_modules/rx-jupyter": { - "version": "5.5.21", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "@nteract/messaging": "^7.0.19", - "@types/ungap__url-search-params": "^0.1.0", - "@ungap/url-search-params": "^0.2.0", - "js-cookie": "^2.2.0", - "rxjs": "^6.6.0", - "url-join": "^4.0.0" - } - }, - "node_modules/@nteract/types": { - "version": "7.1.9", - "license": "MIT", - "dependencies": { - "@nteract/commutable": "^7.4.5", - "immutable": "^4.0.0-rc.12", - "rxjs": "^6.6.0", - "uuid": "^8.0.0" - } - }, - "node_modules/@nteract/types/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@octokit/auth-token": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", @@ -8364,6 +7972,7 @@ }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", + "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*", @@ -8482,11 +8091,12 @@ } }, "node_modules/@types/node": { - "version": "18.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", - "integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", + "version": "22.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", + "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -8555,6 +8165,7 @@ }, "node_modules/@types/react-redux": { "version": "7.1.7", + "dev": true, "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", @@ -8678,16 +8289,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ungap__url-search-params": { - "version": "0.1.2", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "2.0.10", "license": "MIT" }, "node_modules/@types/uuid": { - "version": "8.3.4", + "version": "9.0.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha1-dUW6T8PAA9bHVvZR878WPY8PKbo=", + "dev": true, "license": "MIT" }, "node_modules/@types/ws": { @@ -9001,10 +8611,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "node_modules/@ungap/url-search-params": { - "version": "0.2.2", - "license": "ISC" - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -12093,6 +11699,7 @@ }, "node_modules/debounce": { "version": "1.2.1", + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -12148,10 +11755,6 @@ } } }, - "node_modules/deep-diff": { - "version": "0.3.8", - "license": "MIT" - }, "node_modules/deep-equal": { "version": "1.1.2", "license": "MIT", @@ -13012,10 +12615,6 @@ "node": ">=6" } }, - "node_modules/escape-carriage": { - "version": "1.3.1", - "license": "MIT" - }, "node_modules/escape-html": { "version": "1.0.3", "dev": true, @@ -14021,10 +13620,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-saver": { - "version": "2.0.5", - "license": "MIT" - }, "node_modules/filesize": { "version": "8.0.7", "dev": true, @@ -14033,13 +13628,6 @@ "node": ">= 0.4.0" } }, - "node_modules/filewatcher": { - "version": "3.0.1", - "license": "MIT", - "dependencies": { - "debounce": "^1.0.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -14489,27 +14077,6 @@ "dev": true, "license": "Unlicense" }, - "node_modules/fs-observable": { - "version": "4.1.14", - "license": "BSD-3-Clause", - "dependencies": { - "filewatcher": "^3.0.1", - "mkdirp": "^0.5.1" - }, - "peerDependencies": { - "rxjs": "^6.3.3" - } - }, - "node_modules/fs-observable/node_modules/mkdirp": { - "version": "0.5.6", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "license": "ISC" @@ -14518,6 +14085,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -15424,10 +14992,11 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -20658,10 +20227,6 @@ "jquery": ">=1.8.0 <4.0.0" } }, - "node_modules/js-cookie": { - "version": "2.2.1", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -21089,10 +20654,6 @@ "version": "4.0.3", "license": "MIT" }, - "node_modules/lodash.clonedeep": { - "version": "4.5.0", - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "license": "MIT" @@ -21107,10 +20668,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.has": { - "version": "4.5.2", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -23756,42 +23313,6 @@ "@babel/runtime": "^7.9.2" } }, - "node_modules/redux-immutable": { - "version": "4.0.0", - "license": "BSD-3-Clause", - "peerDependencies": { - "immutable": "^3.8.1 || ^4.0.0-rc.1" - } - }, - "node_modules/redux-logger": { - "version": "3.0.6", - "license": "MIT", - "dependencies": { - "deep-diff": "^0.3.5" - } - }, - "node_modules/redux-observable": { - "version": "2.0.0", - "license": "MIT", - "dependencies": { - "rxjs": "^7.0.0", - "tslib": "~2.1.0" - }, - "peerDependencies": { - "redux": ">=4 <5" - } - }, - "node_modules/redux-observable/node_modules/rxjs": { - "version": "7.8.1", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/redux-observable/node_modules/tslib": { - "version": "2.1.0", - "license": "0BSD" - }, "node_modules/reflect-metadata": { "version": "0.1.13", "license": "Apache-2.0" @@ -24086,10 +23607,6 @@ "dev": true, "license": "MIT" }, - "node_modules/reselect": { - "version": "4.1.8", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.8", "devOptional": true, @@ -24232,29 +23749,6 @@ "version": "1.3.3", "license": "BSD-3-Clause" }, - "node_modules/rx-jupyter": { - "version": "5.5.12", - "license": "BSD-3-Clause", - "dependencies": { - "@nteract/commutable": "^7.3.2", - "@nteract/messaging": "^7.0.10", - "@types/ungap__url-search-params": "^0.1.0", - "@ungap/url-search-params": "^0.2.0", - "js-cookie": "^2.2.0", - "rxjs": "^6.6.0", - "url-join": "^4.0.0" - } - }, - "node_modules/rxjs": { - "version": "6.6.7", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^1.9.0" - }, - "engines": { - "npm": ">=2.0.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.2", "license": "MIT", @@ -25902,9 +25396,10 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" }, "node_modules/unherit": { "version": "1.1.3", @@ -26068,10 +25563,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-join": { - "version": "4.0.1", - "license": "MIT" - }, "node_modules/url-loader": { "version": "4.1.1", "dev": true, @@ -27689,9 +27180,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 1ee5cecf0..201da1576 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "Cosmos Explorer", "main": "index.js", + "engines": { + "node": "22.x" + }, "dependencies": { "@azure/arm-cosmosdb": "16.4.0", "@azure/cosmos": "4.7.0", @@ -14,11 +17,7 @@ "@fluentui/react": "8.119.0", "@fluentui/react-components": "9.54.2", "@microsoft/applicationinsights-web": "2.6.1", - "@nteract/commutable": "7.5.1", - "@nteract/core": "15.1.9", - "@nteract/fixtures": "2.3.0", "@nteract/markdown": "4.6.0", - "@nteract/myths": "0.1.9", "@octokit/request": "8.4.1", "@octokit/rest": "17.9.2", "@testing-library/jest-dom": "6.4.6", @@ -87,7 +86,6 @@ "react-window": "1.8.10", "react-youtube": "9.0.1", "reflect-metadata": "0.1.13", - "rx-jupyter": "5.5.12", "shell-quote": "1.10.0", "styled-components": "5.0.1", "swr": "0.4.0", @@ -97,7 +95,7 @@ "utility-types": "3.10.0", "uuid": "9.0.0", "web-vitals": "4.2.4", - "ws": "8.20.1", + "ws": "8.21.3", "xterm": "4.19.0", "xterm-addon-fit": "0.5.0", "zustand": "3.5.0" @@ -136,7 +134,7 @@ "@types/hasher": "0.0.31", "@types/jest": "29.5.12", "@types/jquery": "3.5.29", - "@types/node": "18.19.0", + "@types/node": "22.14.1", "@types/post-robot": "10.0.1", "@types/q": "1.5.1", "@types/react": "17.0.44", @@ -149,6 +147,7 @@ "@types/sinon": "2.3.3", "@types/styled-components": "5.1.32", "@types/underscore": "1.7.36", + "@types/uuid": "9.0.8", "@types/youtube-player": "5.5.6", "@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/parser": "6.7.4", @@ -206,7 +205,7 @@ "webpack-bundle-analyzer": "5.2.0", "webpack-cli": "5.1.4", "webpack-dev-server": "5.2.5", - "ws": "8.20.1" + "ws": "8.21.3" }, "scripts": { "postinstall": "patch-package && npm run generate:i18n-keys", diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index c4d0e1cd5..5f52c1d05 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -1,14 +1,3 @@ -export class EndpointsRegex { - public static readonly cassandra = [ - "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com", - "HostName=(.*).cassandra.cosmos.azure.com", - ]; - public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; - public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; - public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com"; - public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com"; -} - export class ApiEndpoints { public static runtimeProxy: string = "/api/RuntimeProxy"; public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy"; diff --git a/src/ConfigContext.ts b/src/ConfigContext.ts index 76574a117..a14deb248 100644 --- a/src/ConfigContext.ts +++ b/src/ConfigContext.ts @@ -30,6 +30,13 @@ export interface ConfigContext { allowedCassandraProxyEndpoints: ReadonlyArray; allowedMongoProxyEndpoints: ReadonlyArray; allowedParentFrameOrigins: ReadonlyArray; + SQL_DNS_ZONES: ReadonlyArray; + MONGO_DNS_ZONES: ReadonlyArray; + MONGO_COMPUTE_DNS_ZONES: ReadonlyArray; + CASSANDRA_DNS_ZONES: ReadonlyArray; + TABLE_DNS_ZONES: ReadonlyArray; + DOCUMENT_ENDPOINT_ZONES: ReadonlyArray; + GREMLIN_ENDPOINT_ZONES: ReadonlyArray; gitSha?: string; proxyPath?: string; AAD_ENDPOINT: string; @@ -81,6 +88,32 @@ let configContext: Readonly = { `^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`, `^https:\\/\\/portal\\.sovcloud-azure\\.sg$`, ], // Webpack injects this at build time + SQL_DNS_ZONES: [ + "documents.azure.com", + "sql.cosmosdb.azure.com", + "sql.cosmos.azure.com", + "sqlx.cosmos.azure.com", + "documents-staging.windows-ppe.net", + "sql.cosmosdb.windows-ppe.net", + "sql.cosmos.windows-ppe.net", + "sqlx.cosmos.windows-ppe.net", + ], + MONGO_DNS_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"], + MONGO_COMPUTE_DNS_ZONES: ["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"], + CASSANDRA_DNS_ZONES: [ + "cassandra.cosmosdb.azure.com", + "cassandra.cosmos.azure.com", + "cassandra.cosmosdb.windows-ppe.net", + "cassandra.cosmos.windows-ppe.net", + ], + TABLE_DNS_ZONES: [ + "table.cosmosdb.azure.com", + "table.cosmos.azure.com", + "table.cosmosdb.windows-ppe.net", + "table.cosmos.windows-ppe.net", + ], + DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"], + GREMLIN_ENDPOINT_ZONES: ["gremlin.cosmos.azure.com", "gremlin.cosmos.windows-ppe.net"], gitSha: process.env.GIT_SHA, hostedExplorerURL: "https://cosmos.azure.com/", AAD_ENDPOINT: "https://login.microsoftonline.com/", diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 59c49e5ac..f1e85d6cd 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -28,7 +28,6 @@ import { userContext } from "../UserContext"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { useSidePanel } from "../hooks/useSidePanel"; import Explorer from "./Explorer"; -import { useNotebook } from "./Notebook/useNotebook"; import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane"; import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel"; import StoredProcedure from "./Tree/StoredProcedure"; @@ -120,23 +119,17 @@ export const createCollectionContextMenuButton = ( iconSrc: HostedTerminalIcon, onClick: () => { const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); - if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) { + if (userContext.features.enableCloudShell) { container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { selectedCollection && selectedCollection.onNewMongoShellClick(); } }, - label: - useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell - ? t(Keys.contextMenu.openMongoShell) - : t(Keys.contextMenu.newShell), + label: userContext.features.enableCloudShell ? t(Keys.contextMenu.openMongoShell) : t(Keys.contextMenu.newShell), }); } - if ( - (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) && - userContext.apiType === "Cassandra" - ) { + if (userContext.features.enableCloudShell && userContext.apiType === "Cassandra") { items.push({ iconSrc: HostedTerminalIcon, onClick: () => { diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 41e126a53..dd5d3bce8 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -109,25 +109,15 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], @@ -230,25 +220,15 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], @@ -455,25 +435,15 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], @@ -526,25 +496,15 @@ exports[`SettingsComponent renders 1`] = ` } explorer={ Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], @@ -694,25 +654,15 @@ exports[`SettingsComponent renders 1`] = ` "computedProperties": [Function], "conflictResolutionPolicy": [Function], "container": Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], @@ -765,25 +715,15 @@ exports[`SettingsComponent renders 1`] = ` } explorer={ Explorer { - "_isInitializingNotebooks": false, "databasesRefreshed": Promise {}, "isFixedCollectionWithSharedThroughputSupported": [Function], "isTabsContentExpanded": [Function], "onRefreshDatabasesKeyPress": [Function], "onRefreshResourcesClick": [Function], - "phoenixClient": PhoenixClient { - "armResourceId": undefined, - "retryOptions": { - "maxTimeout": 5000, - "minTimeout": 5000, - "retries": 3, - }, - }, "provideFeedbackEmail": [Function], "queriesClient": QueriesClient { "container": [Circular], }, - "refreshNotebookList": [Function], "resourceTree": ResourceTreeAdapter { "container": [Circular], "parameters": [Function], diff --git a/src/Explorer/Explorer.test.tsx b/src/Explorer/Explorer.test.tsx index 5a4e89b94..dbb7bc118 100644 --- a/src/Explorer/Explorer.test.tsx +++ b/src/Explorer/Explorer.test.tsx @@ -10,6 +10,7 @@ import { Capability, DatabaseAccount } from "../Contracts/DataModels"; import { updateUserContext, userContext } from "../UserContext"; import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; import Explorer from "./Explorer"; +import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter"; const mockUpdate = update as jest.MockedFunction; @@ -26,21 +27,6 @@ jest.mock("./Controls/Dialog", () => ({ }, })); -// Silence useNotebook subscription calls -jest.mock("./Notebook/useNotebook", () => ({ - useNotebook: { - subscribe: jest.fn(), - getState: jest.fn().mockReturnValue( - new Proxy( - {}, - { - get: () => jest.fn().mockResolvedValue(undefined), - }, - ), - ), - }, -})); - describe("Explorer.openEnableSynapseLinkDialog", () => { let explorer: Explorer; @@ -69,6 +55,7 @@ describe("Explorer.openEnableSynapseLinkDialog", () => { beforeEach(() => { jest.clearAllMocks(); mockUpdate.mockResolvedValue(undefined); + useCommandBar.getState().setIsSynapseLinkUpdating(false); explorer = new Explorer(); }); @@ -102,6 +89,19 @@ describe("Explorer.openEnableSynapseLinkDialog", () => { expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true); }); + + it("should track Synapse Link update progress in the command bar store", async () => { + mockUpdate.mockImplementation(async () => { + expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(true); + return {} as Awaited>; + }); + explorer.openEnableSynapseLinkDialog(); + + const dialogProps = mockOpenDialog.mock.calls[0][0]; + await dialogProps.onPrimaryButtonClick(); + + expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(false); + }); }); describe("with targetAccountOverride", () => { diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index bf1c5a33e..bc7df461d 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -1,9 +1,6 @@ import * as msal from "@azure/msal-browser"; -import { Link } from "@fluentui/react/lib/Link"; -import { isPublicInternetAccessAllowed } from "Common/DatabaseAccountUtility"; import { sendMessage } from "Common/MessageHandler"; import { stringifyError } from "Common/stringifyError"; -import { Platform, configContext } from "ConfigContext"; import { MessageTypes } from "Contracts/ExplorerContracts"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { @@ -13,32 +10,27 @@ import { scheduleRefreshFabricToken, } from "Platform/Fabric/FabricUtil"; import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils"; -import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils"; import { featureRegistered } from "Utils/FeatureRegistrationUtils"; import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import * as ko from "knockout"; import React from "react"; import _ from "underscore"; -import shallow from "zustand/shallow"; import { AuthType } from "../AuthType"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; import * as Constants from "../Common/Constants"; -import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants"; import { getErrorMessage, getErrorStack } from "../Common/ErrorHandlingUtils"; import * as Logger from "../Common/Logger"; import { QueriesClient } from "../Common/QueriesClient"; import { readCollection } from "../Common/dataAccess/readCollection"; import { readDatabases } from "../Common/dataAccess/readDatabases"; import * as DataModels from "../Contracts/DataModels"; -import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; import { UploadDetailsRecord } from "../Contracts/ViewModels"; import MetricScenario from "../Metrics/MetricEvents"; import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig"; import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; -import { PhoenixClient } from "../Phoenix/PhoenixClient"; import * as ExplorerSettings from "../Shared/ExplorerSettings"; -import { Action, ActionModifiers } from "../Shared/Telemetry/TelemetryConstants"; +import { Action } from "../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; import { updateUserContext, userContext } from "../UserContext"; import { getCollectionName, getUploadName } from "../Utils/APITypeUtils"; @@ -49,8 +41,6 @@ import { ReactTabKind, useTabs } from "../hooks/useTabs"; import "./ComponentRegisterer"; import { DialogProps, useDialog } from "./Controls/Dialog"; import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter"; -import type NotebookManager from "./Notebook/NotebookManager"; -import { useNotebook } from "./Notebook/useNotebook"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; @@ -78,13 +68,7 @@ export default class Explorer { // Tabs public isTabsContentExpanded: ko.Observable; - // Notebooks - public notebookManager?: NotebookManager; - - private _isInitializingNotebooks: boolean; - private static readonly MaxNbDatabasesToAutoExpand = 5; - public phoenixClient: PhoenixClient; /** * Resolves when the initial refreshAllDatabases (including collection loading) completes. @@ -95,13 +79,6 @@ export default class Explorer { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { dataExplorerArea: Constants.Areas.ResourceTree, }); - this._isInitializingNotebooks = false; - - this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id); - useNotebook.subscribe( - () => this.refreshCommandBarButtons(), - (state) => state.isNotebooksEnabledForAccount, - ); this.queriesClient = new QueriesClient(this); @@ -156,31 +133,8 @@ export default class Explorer { startKey, ); - useNotebook.subscribe( - async () => this.initiateAndRefreshNotebookList(), - (state) => [state.isNotebookEnabled, state.isRefreshed], - shallow, - ); - this.resourceTree = new ResourceTreeAdapter(this); - // Override notebook server parameters from URL parameters - if ( - userContext.features.notebookServerUrl && - validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) && - userContext.features.notebookServerToken - ) { - useNotebook.getState().setNotebookServerInfo({ - notebookServerEndpoint: userContext.features.notebookServerUrl, - authToken: userContext.features.notebookServerToken, - forwardingId: undefined, - }); - } - - if (userContext.features.notebookBasePath) { - useNotebook.getState().setNotebookBasePath(userContext.features.notebookBasePath); - } - if (isFabricMirrored()) { useTabs.getState().closeReactTab(ReactTabKind.Home); } @@ -188,23 +142,6 @@ export default class Explorer { this.refreshExplorer(); } - public async initiateAndRefreshNotebookList(): Promise { - if (!this.notebookManager) { - const NotebookManager = (await import(/* webpackChunkName: "NotebookManager" */ "./Notebook/NotebookManager")) - .default; - this.notebookManager = new NotebookManager(); - this.notebookManager.initialize({ - container: this, - resourceTree: this.resourceTree, - refreshCommandBarButtons: () => this.refreshCommandBarButtons(), - refreshNotebookList: () => this.refreshNotebookList(), - }); - } - - this.refreshCommandBarButtons(); - this.refreshNotebookList(); - } - public openEnableSynapseLinkDialog(targetAccountOverride?: DataModels.AccountOverride): void { const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId; const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup; @@ -227,7 +164,7 @@ export default class Explorer { const clearInProgressMessage = logConsoleProgress( "Enabling Azure Synapse Link for this account. This may take a few minutes before you can enable analytical store for this account.", ); - useNotebook.getState().setIsSynapseLinkUpdating(true); + useCommandBar.getState().setIsSynapseLinkUpdating(true); useDialog.getState().closeDialog(); try { @@ -248,7 +185,7 @@ export default class Explorer { logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`); TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime); } finally { - useNotebook.getState().setIsSynapseLinkUpdating(false); + useCommandBar.getState().setIsSynapseLinkUpdating(false); } }, @@ -431,7 +368,6 @@ export default class Explorer { await (userContext.authType === AuthType.ResourceToken ? this.refreshDatabaseForResourceToken() : this.refreshAllDatabases()); - await this.refreshNotebookList(); } logConsoleInfo("Successfully refreshed databases"); @@ -442,108 +378,6 @@ export default class Explorer { window.open(Constants.Urls.feedbackEmail, "_blank"); }; - public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise { - if (!databaseAccount) { - throw new Error("No database account specified"); - } - - if (this._isInitializingNotebooks) { - return; - } - this._isInitializingNotebooks = true; - this.refreshNotebookList(); - this._isInitializingNotebooks = false; - } - - public async allocateContainer(): Promise { - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - const isAllocating = useNotebook.getState().isAllocating; - if ( - isAllocating === false && - (notebookServerInfo === undefined || - (notebookServerInfo && notebookServerInfo.notebookServerEndpoint === undefined)) - ) { - const connectionStatus: ContainerConnectionInfo = { - status: ConnectionStatusType.Connecting, - }; - - useNotebook.getState().setConnectionInfo(connectionStatus); - - let connectionInfo; - try { - TelemetryProcessor.traceStart(Action.PhoenixConnection, { - dataExplorerArea: Areas.Notebook, - }); - useNotebook.getState().setIsAllocating(true); - const provisionData: IProvisionData = { - cosmosEndpoint: userContext?.databaseAccount?.properties?.documentEndpoint, - poolId: undefined, - }; - connectionInfo = await this.phoenixClient.allocateContainer(provisionData); - if (!connectionInfo?.data?.phoenixServiceUrl) { - throw new Error(`PhoenixServiceUrl is invalid!`); - } - await this.setNotebookInfo(connectionInfo, connectionStatus); - TelemetryProcessor.traceSuccess(Action.PhoenixConnection, { - dataExplorerArea: Areas.Notebook, - }); - } catch (error) { - TelemetryProcessor.traceFailure(Action.PhoenixConnection, { - dataExplorerArea: Areas.Notebook, - status: error.status, - error: getErrorMessage(error), - errorStack: getErrorStack(error), - }); - connectionStatus.status = ConnectionStatusType.Failed; - useNotebook.getState().resetContainerConnection(connectionStatus); - if (error?.status === HttpStatusCodes.Forbidden && error.message) { - useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`); - } else { - useDialog - .getState() - .showOkModalDialog( - "Connection Failed", - "We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.", - ); - } - throw error; - } finally { - useNotebook.getState().setIsAllocating(false); - this.refreshCommandBarButtons(); - this.refreshNotebookList(); - this._isInitializingNotebooks = false; - } - } - } - - public async setNotebookInfo( - connectionInfo: IResponse, - connectionStatus: DataModels.ContainerConnectionInfo, - ): Promise { - const containerData = { - forwardingId: connectionInfo.data.forwardingId, - dbAccountName: userContext.databaseAccount.name, - }; - await this.phoenixClient.initiateContainerHeartBeat(true, containerData); - - connectionStatus.status = ConnectionStatusType.Connected; - useNotebook.getState().setConnectionInfo(connectionStatus); - - const noteBookServerInfo = { - notebookServerEndpoint: - (validateEndpoint(userContext.features.notebookServerUrl, allowedNotebookServerUrls) && - userContext.features.notebookServerUrl) || - connectionInfo.data.phoenixServiceUrl, - authToken: userContext.features.notebookServerToken || connectionInfo.data.authToken, - forwardingId: connectionInfo.data.forwardingId, - }; - useNotebook.getState().setNotebookServerInfo(noteBookServerInfo); - - this.notebookManager?.notebookClient - .getMemoryUsage() - .then((memoryUsageInfo) => useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo)); - } - private getDeltaDatabases( updatedDatabaseList: DataModels.Database[], databases: ViewModels.Database[], @@ -637,11 +471,6 @@ export default class Explorer { } } - private refreshNotebookList = (): Promise => { - // Notebook authoring and listing have been removed. - return Promise.resolve(); - }; - public openNotebookTerminal(kind: ViewModels.TerminalKind): void { this.connectToNotebookTerminal(kind); } @@ -742,24 +571,6 @@ export default class Explorer { .openSidePanel("Input parameters", ); } - public getDownloadModalContent(fileName: string): JSX.Element { - if (useNotebook.getState().isPhoenixNotebooks) { - return ( - <> -

{Notebook.galleryNotebookDownloadContent1}

-
-

- {Notebook.galleryNotebookDownloadContent2} - - {Notebook.learnMore} - -

- - ); - } - return

Download {fileName} from gallery as a copy to your notebooks to run and/or edit the notebook.

; - } - public async refreshExplorer(): Promise { // Start DatabaseLoad scenario before fetching databases if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { @@ -768,8 +579,7 @@ export default class Explorer { // Run independent initialization tasks in parallel: // - Database loading (ARM/SDK calls for databases + collections) - // - Notebook enabled check (Phoenix + Portal backend — no dependency on databases) - // - Feature registration check (ARM call — no dependency on databases or notebooks) + // - Feature registration check (ARM call — no dependency on databases) const databasesTask = userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo" ? (async () => { @@ -786,37 +596,12 @@ export default class Explorer { })() : Promise.resolve(); - const notebooksTask = !isFabricNative() - ? useNotebook.getState().refreshNotebooksEnabledStateForAccount() - : Promise.resolve(); - const featureRegistrationTask = userContext.authType === AuthType.AAD && userContext.apiType === "SQL" && !isFabricNative() ? featureRegistered(userContext.subscriptionId, "ThroughputBucketing") : Promise.resolve(false); - const [, , throughputBucketsEnabled] = await Promise.all([databasesTask, notebooksTask, featureRegistrationTask]); - - // Notebook initialization depends on refreshNotebooksEnabledStateForAccount completing above - // TODO: remove reference to isNotebookEnabled and isNotebooksEnabledForAccount - const isNotebookEnabled = - configContext.platform !== Platform.Fabric && - (userContext.features.notebooksDownBanner || - useNotebook.getState().isPhoenixNotebooks || - useNotebook.getState().isPhoenixFeatures); - useNotebook.getState().setIsNotebookEnabled(isNotebookEnabled); - useNotebook - .getState() - .setIsShellEnabled(useNotebook.getState().isPhoenixFeatures && isPublicInternetAccessAllowed()); - - TelemetryProcessor.trace(Action.NotebookEnabled, ActionModifiers.Mark, { - isNotebookEnabled, - dataExplorerArea: Constants.Areas.Notebook, - }); - - if (useNotebook.getState().isPhoenixNotebooks) { - await this.initNotebooks(userContext.databaseAccount); - } + const [, throughputBucketsEnabled] = await Promise.all([databasesTask, featureRegistrationTask]); if (throughputBucketsEnabled) { updateUserContext({ throughputBucketsEnabled }); diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index 4d2326f3f..17b89d650 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -5,14 +5,12 @@ */ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { makeStyles, useFluent } from "@fluentui/react-components"; -import { useNotebook } from "Explorer/Notebook/useNotebook"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { isFabric } from "Platform/Fabric/FabricUtil"; import { userContext } from "UserContext"; import * as React from "react"; import create, { UseStore } from "zustand"; -import { ConnectionStatusType } from "../../../Common/Constants"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import Explorer from "../../Explorer"; import { useSelectedNode } from "../../useSelectedNode"; @@ -28,6 +26,8 @@ export interface CommandBarStore { setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void; isHidden: boolean; setIsHidden: (isHidden: boolean) => void; + isSynapseLinkUpdating: boolean; + setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void; } export const useCommandBar: UseStore = create((set) => ({ @@ -35,6 +35,8 @@ export const useCommandBar: UseStore = create((set) => ({ setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })), isHidden: false, setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })), + isSynapseLinkUpdating: false, + setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }), })); const useStyles = makeStyles({ @@ -69,26 +71,25 @@ export const CommandBar: React.FC = ({ container }: Props) => { const selectedNodeState = useSelectedNode(); const buttons = useCommandBar((state) => state.contextButtons); const isHidden = useCommandBar((state) => state.isHidden); + const isSynapseLinkUpdating = useCommandBar((state) => state.isSynapseLinkUpdating); // targetDocument is used by referenced components // eslint-disable-next-line @typescript-eslint/no-unused-vars const { targetDocument } = useFluent(); const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR); const styles = useStyles(); - const { connectionInfo, isPhoenixNotebooks, isPhoenixFeatures } = useNotebook((state) => ({ - connectionInfo: state.connectionInfo, - isPhoenixNotebooks: state.isPhoenixNotebooks, - isPhoenixFeatures: state.isPhoenixFeatures, - })); - // Subscribe to the store changes that affect button creation const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled); const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated); // Memoize the expensive button creation const staticButtons = React.useMemo(() => { - return CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState); - }, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated]); + return CommandBarComponentButtonFactory.createStaticCommandBarButtons( + container, + selectedNodeState, + isSynapseLinkUpdating, + ); + }, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated, isSynapseLinkUpdating]); if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { const buttons = @@ -134,11 +135,6 @@ export const CommandBar: React.FC = ({ container }: Props) => { const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)"); uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); - // Add connection status if needed (using the hook values we got at the top level) - if ((isPhoenixNotebooks || isPhoenixFeatures) && connectionInfo?.status !== ConnectionStatusType.Connect) { - uiFabricControlButtons.unshift(CommandBarUtil.createConnectionStatus(container, "connectionStatus")); - } - const rootStyle = { root: { backgroundColor: "var(--colorNeutralBackground1)", diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts index c0d97bb02..0008f99a3 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.test.ts @@ -4,7 +4,6 @@ import { DatabaseAccount } from "../../../Contracts/DataModels"; import { CollectionBase } from "../../../Contracts/ViewModels"; import { updateUserContext } from "../../../UserContext"; import Explorer from "../../Explorer"; -import { useNotebook } from "../../Notebook/useNotebook"; import { useDatabases } from "../../useDatabases"; import { useSelectedNode } from "../../useSelectedNode"; import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory"; @@ -37,6 +36,18 @@ describe("CommandBarComponentButtonFactory tests", () => { expect(enableAzureSynapseLinkBtn).toBeDefined(); }); + it("Button should be disabled while Synapse Link is updating", () => { + const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons( + mockExplorer, + selectedNodeState, + true, + ); + const enableAzureSynapseLinkBtn = buttons.find( + (button) => button.commandButtonLabel === enableAzureSynapseLinkBtnLabel, + ); + expect(enableAzureSynapseLinkBtn.disabled).toBe(true); + }); + // TODO: Now that Tables API supports dataplane RBAC, calling createStaticCommandBarButtons will enable the // Entra ID Login button, which causes this test to fail due to "Invalid hook call.". This seems to be // unsupported in jest and needs to be tested with react-hooks-testing-library. @@ -99,11 +110,6 @@ describe("CommandBarComponentButtonFactory tests", () => { }); }); - afterEach(() => { - useNotebook.getState().setIsNotebookEnabled(false); - useNotebook.getState().setIsNotebooksEnabledForAccount(false); - }); - it("Cassandra Api not available - button should be hidden", () => { updateUserContext({ databaseAccount: { @@ -127,7 +133,7 @@ describe("CommandBarComponentButtonFactory tests", () => { expect(openCassandraShellBtn).toBeUndefined(); }); - it("Notebooks is not enabled and is unavailable - button should be shown and disabled", () => { + it("Cloud Shell is unavailable - button should be hidden", () => { const buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); expect(openCassandraShellBtn).toBeUndefined(); diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx index ada89d807..c174c8f3a 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentButtonFactory.tsx @@ -30,7 +30,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { useDialog } from "../../Controls/Dialog"; import Explorer from "../../Explorer"; -import { useNotebook } from "../../Notebook/useNotebook"; import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane"; import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane"; import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane"; @@ -43,6 +42,7 @@ let counter = 0; export function createStaticCommandBarButtons( container: Explorer, selectedNodeState: SelectedNodeState, + isSynapseLinkUpdating = false, ): CommandButtonComponentProps[] { if (userContext.authType === AuthType.ResourceToken) { return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState); @@ -62,7 +62,7 @@ export function createStaticCommandBarButtons( userContext.apiType !== "Tables" && userContext.apiType !== "Cassandra" ) { - const addSynapseLink = createOpenSynapseLinkDialogButton(container); + const addSynapseLink = createOpenSynapseLinkDialogButton(container, isSynapseLinkUpdating); if (addSynapseLink) { addDivider(); buttons.push(addSynapseLink); @@ -136,14 +136,13 @@ export function createContextCommandBarButtons( const buttons: CommandButtonComponentProps[] = []; if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") { - const label = - useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell"; + const label = userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell"; const newMongoShellBtn: CommandButtonComponentProps = { iconSrc: HostedTerminalIcon, iconAlt: label, onCommandClick: () => { const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); - if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) { + if (userContext.features.enableCloudShell) { container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); } else { selectedCollection && selectedCollection.onNewMongoShellClick(); @@ -157,7 +156,7 @@ export function createContextCommandBarButtons( } if ( - (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) && + userContext.features.enableCloudShell && !selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Cassandra" ) { @@ -252,7 +251,10 @@ function areScriptsSupported(): boolean { ); } -function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonComponentProps { +function createOpenSynapseLinkDialogButton( + container: Explorer, + isSynapseLinkUpdating: boolean, +): CommandButtonComponentProps { if (configContext.platform === Platform.Emulator) { return undefined; } @@ -273,7 +275,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo onCommandClick: () => container.openEnableSynapseLinkDialog(), commandButtonLabel: label, hasPopup: false, - disabled: useNotebook.getState().isSynapseLinkUpdating, + disabled: isSynapseLinkUpdating, ariaLabel: label, }; } @@ -514,14 +516,9 @@ function createOpenTerminalButtonByKind( } }; const label = `Open ${terminalFriendlyName()} Shell`; - const tooltip = - "This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks."; + const tooltip = "Cloud Shell is not available for this account."; const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled(); - const disableButton = - (!useNotebook.getState().isNotebooksEnabledForAccount && - !useNotebook.getState().isNotebookEnabled && - !userContext.features.enableCloudShell) || - isNativeAuthDisabled; + const disableButton = !userContext.features.enableCloudShell || isNativeAuthDisabled; return { iconSrc: HostedTerminalIcon, iconAlt: label, @@ -533,7 +530,7 @@ function createOpenTerminalButtonByKind( }); return; } - if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) { + if (userContext.features.enableCloudShell) { container.openNotebookTerminal(terminalKind); } }, diff --git a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx index 40628db28..b298be795 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarUtil.tsx @@ -17,9 +17,6 @@ import { configContext, Platform } from "../../../ConfigContext"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; -import Explorer from "../../Explorer"; -import { ConnectionStatus } from "./ConnectionStatusComponent"; -import { MemoryTracker } from "./MemoryTrackerComponent"; /** * Convert our NavbarButtonConfig to UI Fabric buttons @@ -258,20 +255,6 @@ export const createDivider = (key: string): ICommandBarItemProps => { }; }; -export const createMemoryTracker = (key: string): ICommandBarItemProps => { - return { - key, - onRender: () => , - }; -}; - -export const createConnectionStatus = (container: Explorer, key: string): ICommandBarItemProps => { - return { - key, - onRender: () => , - }; -}; - export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap { const handlers: KeyboardHandlerMap = {}; diff --git a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less deleted file mode 100644 index 0688e0baa..000000000 --- a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.less +++ /dev/null @@ -1,184 +0,0 @@ -@import "../../../../less/Common/Constants"; - -.connectionStatusContainer { - cursor: default; - align-items: center; - border: 1px; - min-height: 44px; - - > span { - padding-right: 12px; - font-size: 12px; - font-family: @DataExplorerFont; - color: @DefaultFontColor; - } - &:focus{ - outline: 0px; - } -} -.commandReactBtn { - &:hover { - background-color: rgb(238, 247, 255); - color: rgb(32, 31, 30); - cursor: pointer; - } - &:focus{ - outline: 1px dashed #605e5c; - } -} -.connectedReactBtn { - &:hover { - background-color: rgb(238, 247, 255); - color: rgb(32, 31, 30); - cursor: pointer; - } - &:focus{ - outline: 0px; - } -} -.connectIcon{ - margin: 0px 4px; - height: 18px; - width: 18px; - color: rgb(0, 120, 212); -} - .status { - position: relative; - display: block; - margin-right: 8px; - width: 1em; - height: 1em; - font-size: 9px!important; - padding: 0px!important; - border-radius: 0.5em; - } - - .status::before, - .status::after { - position: absolute; - content: ""; - } - - .status::before { - top: 0; - left: 0; - width: 1em; - height: 1em; - background-color: rgba(#fff, 0.1); - border-radius: 100%; - opacity: 1; - transform: translate3d(0, 0, 0) scale(0); - } - - .connected{ - background-color: green; - box-shadow: - 0 0 0 0em rgba(green, 0), - 0em 0.05em 0.1em rgba(#000000, 0.2); - transform: translate3d(0, 0, 0) scale(1); - } - .connecting{ - background-color:#ffbf00; - box-shadow: - 0 0 0 0em rgba(#ffbf00, 0), - 0em 0.05em 0.1em rgba(#000000, 0.2); - transform: translate3d(0, 0, 0) scale(1); - } - .failed{ - background-color:#bd1919; - box-shadow: - 0 0 0 0em rgba(#bd1919, 0), - 0em 0.05em 0.1em rgba(#000000, 0.2); - transform: translate3d(0, 0, 0) scale(1); - } - - .status.connecting.is-animating { - animation: status-outer-connecting 3000ms infinite; - } - .status.failed.is-animating { - animation: status-outer-failed 3000ms infinite; - } - .status.connected.is-animating { - animation: status-outer-connected 3000ms infinite; - } - @keyframes status-outer-connected { - - 0% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #008000, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2); - } - 20% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.6), 0em 0.05em 0.1em rgba(0, 0, 0, 0.5); - } - 40% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.5), 0em 0.05em 0.1em rgba(0, 0, 0, 0.4); - } - 60% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3); - } - 80% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1); - } - 85% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0); - } - } - @keyframes status-outer-failed { - - 0% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #bd1919, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2); - } - 20% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #c52d2d, 0em 0.05em 0.1em rgba(0, 0, 0, 0.5); - } - 40% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #b47b7b, 0em 0.05em 0.1em rgba(0, 0, 0, 0.4); - } - 60% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3); - } - 80% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1); - } - 85% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0); - } - } - @keyframes status-outer-connecting { - - 0% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #ffbf00, 0em 0.05em 0.1em rgba(0, 0, 0, 0.2); - } - 20% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em #f0dfad, 0em 0.05em 0.1em rgba(0, 0, 0, 0.5); - } - 40% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(198, 243, 198, 0.5), 0em 0.05em 0.1em rgba(0, 0, 0, 0.4); - } - 60% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(213, 241, 213, 0.3), 0em 0.05em 0.1em rgba(0, 0, 0, 0.3); - } - 80% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0.5em rgba(0, 128, 0, 0.1), 0em 0.05em 0.1em rgba(0, 0, 0, 0.1); - } - 85% { - transform: translate3d(0, 0, 0) scale(1); - box-shadow: 0 0 0 0em rgba(0, 128, 0, 0), 0em 0.05em 0.1em rgba(0, 0, 0, 0); - } - } \ No newline at end of file diff --git a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx b/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx deleted file mode 100644 index 60499661f..000000000 --- a/src/Explorer/Menus/CommandBar/ConnectionStatusComponent.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { - FocusTrapCallout, - FocusZone, - FocusZoneTabbableElements, - FontWeights, - Icon, - mergeStyleSets, - ProgressIndicator, - Stack, - Text, - TooltipHost, -} from "@fluentui/react"; -import { useId } from "@fluentui/react-hooks"; -import { ActionButton, DefaultButton } from "@fluentui/react/lib/Button"; -import * as React from "react"; -import "../../../../less/hostedexplorer.less"; -import { ConnectionStatusType, ContainerStatusType, Notebook } from "../../../Common/Constants"; -import Explorer from "../../Explorer"; -import { useNotebook } from "../../Notebook/useNotebook"; -import "../CommandBar/ConnectionStatusComponent.less"; -interface Props { - container: Explorer; -} -export const ConnectionStatus: React.FC = ({ container }: Props): JSX.Element => { - const connectionInfo = useNotebook((state) => state.connectionInfo); - const [second, setSecond] = React.useState("00"); - const [minute, setMinute] = React.useState("00"); - const [isActive, setIsActive] = React.useState(false); - const [counter, setCounter] = React.useState(0); - const [statusColor, setStatusColor] = React.useState(""); - const [toolTipContent, setToolTipContent] = React.useState("Connect to temporary workspace."); - const [isBarDismissed, setIsBarDismissed] = React.useState(false); - const buttonId = useId("callout-button"); - const containerInfo = useNotebook((state) => state.containerStatus); - - const styles = mergeStyleSets({ - callout: { - width: 320, - padding: "20px 24px", - }, - title: { - marginBottom: 12, - fontWeight: FontWeights.semilight, - }, - buttons: { - display: "flex", - justifyContent: "flex-end", - marginTop: 20, - }, - }); - - React.useEffect(() => { - let intervalId: NodeJS.Timeout; - - if (isActive) { - intervalId = setInterval(() => { - const secondCounter = counter % 60; - const minuteCounter = Math.floor(counter / 60); - const computedSecond: string = String(secondCounter).length === 1 ? `0${secondCounter}` : `${secondCounter}`; - const computedMinute: string = String(minuteCounter).length === 1 ? `0${minuteCounter}` : `${minuteCounter}`; - - setSecond(computedSecond); - setMinute(computedMinute); - - setCounter((counter) => counter + 1); - }, 1000); - } - return () => clearInterval(intervalId); - }, [isActive, counter]); - - React.useEffect(() => { - if (connectionInfo?.status === ConnectionStatusType.Reconnect) { - setToolTipContent("Click here to Reconnect to temporary workspace."); - } else if (connectionInfo?.status === ConnectionStatusType.Failed) { - setStatusColor("status failed is-animating"); - setToolTipContent("Click here to Reconnect to temporary workspace."); - } - }, [connectionInfo.status]); - - const stopTimer = () => { - setIsActive(false); - setCounter(0); - setSecond("00"); - setMinute("00"); - }; - - const memoryUsageInfo = useNotebook((state) => state.memoryUsageInfo); - const totalGB = memoryUsageInfo ? memoryUsageInfo.totalKB / Notebook.memoryGuageToGB : 0; - const usedGB = totalGB > 0 ? totalGB - memoryUsageInfo.freeKB / Notebook.memoryGuageToGB : 0; - - if ( - connectionInfo && - (connectionInfo.status === ConnectionStatusType.Connect || connectionInfo.status === ConnectionStatusType.Reconnect) - ) { - return ( - container.allocateContainer()}> - - - - {connectionInfo.status} - - - - ); - } - - if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connecting && isActive === false) { - stopTimer(); - setIsActive(true); - setStatusColor("status connecting is-animating"); - setToolTipContent("Connecting to temporary workspace."); - } else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Connected && isActive === true) { - stopTimer(); - setStatusColor("status connected is-animating"); - setToolTipContent("Connected to temporary workspace."); - } else if (connectionInfo && connectionInfo.status === ConnectionStatusType.Failed && isActive === true) { - stopTimer(); - setStatusColor("status failed is-animating"); - setToolTipContent("Click here to Reconnect to temporary workspace."); - } - return ( - <> - - ) => - connectionInfo.status === ConnectionStatusType.Failed ? container.allocateContainer() : e.preventDefault() - } - > - - - - {connectionInfo.status} - - {connectionInfo.status === ConnectionStatusType.Connecting && isActive && ( - - )} - {connectionInfo.status === ConnectionStatusType.Connected && !isActive && ( - 0.8 ? "lowMemory" : ""} - description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"} - percentComplete={totalGB !== 0 ? usedGB / totalGB : 0} - /> - )} - - {!isBarDismissed && - containerInfo.status && - containerInfo.status === ContainerStatusType.Active && - Math.round(containerInfo.durationLeftInMinutes) <= Notebook.remainingTimeForAlert ? ( - setIsBarDismissed(true)} - setInitialFocus - > - - Remaining Time - - - This temporary workspace will get disconnected in {Math.round(containerInfo.durationLeftInMinutes)}{" "} - minutes. To save your work permanently, save your notebooks to a GitHub repository or download the - notebooks to your local machine before the session ends. - - - - setIsBarDismissed(true)}>Dimiss - - - - ) : undefined} - - - - ); -}; diff --git a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less deleted file mode 100644 index 404025a91..000000000 --- a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.less +++ /dev/null @@ -1,24 +0,0 @@ -@import "../../../../less/Common/Constants"; - -.memoryTrackerContainer { - cursor: default; - align-items: center; - margin: 0 9px; - border: 1px; - - > span { - padding-right: 12px; - font-size: 13px; - font-family: @DataExplorerFont; - color: @DefaultFontColor; - } - - > .lowMemory { - .ms-ProgressIndicator-progressBar { - background-color: @SelectionHigh; - } - .ms-ProgressIndicator-itemDescription { - color: @SelectionHigh; - } - } -} \ No newline at end of file diff --git a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx b/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx deleted file mode 100644 index ac0621897..000000000 --- a/src/Explorer/Menus/CommandBar/MemoryTrackerComponent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ProgressIndicator, Spinner, SpinnerSize, Stack } from "@fluentui/react"; -import * as React from "react"; -import { useNotebook } from "../../Notebook/useNotebook"; - -export const MemoryTracker: React.FC = (): JSX.Element => { - const memoryUsageInfo = useNotebook((state) => state.memoryUsageInfo); - if (!memoryUsageInfo) { - return ( - - Memory - - - ); - } - - const totalGB = memoryUsageInfo.totalKB / 1048576; - const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576; - - return ( - - Memory - 0.8 ? "lowMemory" : ""} - description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"} - percentComplete={usedGB / totalGB} - /> - - ); -}; diff --git a/src/Explorer/Notebook/NotebookContainerClient.ts b/src/Explorer/Notebook/NotebookContainerClient.ts deleted file mode 100644 index 76d4ba2b8..000000000 --- a/src/Explorer/Notebook/NotebookContainerClient.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Notebook container related stuff - */ -import { useDialog } from "Explorer/Controls/Dialog"; -import promiseRetry, { AbortError, Options } from "p-retry"; -import { PhoenixClient } from "Phoenix/PhoenixClient"; -import * as Constants from "../../Common/Constants"; -import { ConnectionStatusType, HttpHeaders, HttpStatusCodes, Notebook, PoolIdType } from "../../Common/Constants"; -import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; -import * as Logger from "../../Common/Logger"; -import * as DataModels from "../../Contracts/DataModels"; -import { IPhoenixServiceInfo, IProvisionData, IResponse } from "../../Contracts/DataModels"; -import { userContext } from "../../UserContext"; -import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; -import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; -import { useNotebook } from "./useNotebook"; - -export class NotebookContainerClient { - private clearReconnectionAttemptMessage? = () => {}; - private isResettingWorkspace: boolean; - private phoenixClient: PhoenixClient; - private retryOptions: Options; - private scheduleTimerId: NodeJS.Timeout; - - constructor(private onConnectionLost: () => void) { - this.phoenixClient = new PhoenixClient(userContext?.databaseAccount?.id); - this.retryOptions = { - retries: Notebook.retryAttempts, - maxTimeout: Notebook.retryAttemptDelayMs, - minTimeout: Notebook.retryAttemptDelayMs, - }; - - this.initHeartbeat(Constants.Notebook.heartbeatDelayMs); - } - - private initHeartbeat(delayMs: number): void { - this.scheduleHeartbeat(delayMs); - - useNotebook.subscribe( - () => this.scheduleHeartbeat(delayMs), - (state) => state.notebookServerInfo, - ); - } - - private scheduleHeartbeat(delayMs: number) { - if (this.scheduleTimerId) { - clearInterval(this.scheduleTimerId); - } - - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - if (notebookServerInfo?.notebookServerEndpoint) { - this.scheduleTimerId = setInterval(async () => { - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - if (notebookServerInfo?.notebookServerEndpoint) { - const memoryUsageInfo = await this.getMemoryUsage(); - useNotebook.getState().setMemoryUsageInfo(memoryUsageInfo); - } - }, delayMs); - } - } - - public async getMemoryUsage(): Promise { - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) { - const error = "No server endpoint detected"; - Logger.logError(error, "NotebookContainerClient/getMemoryUsage"); - return Promise.reject(error); - } - - if (this.isResettingWorkspace) { - return undefined; - } - - const { notebookServerEndpoint, authToken } = this.getNotebookServerConfig(); - try { - const runMemoryAsync = async () => { - return await this._getMemoryAsync(notebookServerEndpoint, authToken); - }; - return await promiseRetry(runMemoryAsync, this.retryOptions); - } catch (error) { - Logger.logError(getErrorMessage(error), "NotebookContainerClient/getMemoryUsage"); - if (!this.clearReconnectionAttemptMessage) { - this.clearReconnectionAttemptMessage = logConsoleProgress( - "Connection lost with Notebook server. Attempting to reconnect...", - ); - } - this.onConnectionLost(); - return undefined; - } - } - - private async _getMemoryAsync( - notebookServerEndpoint: string, - authToken: string, - ): Promise { - if (this.shouldExecuteMemoryCall()) { - const response = await fetch(`${notebookServerEndpoint}api/metrics/memory`, { - method: "GET", - headers: { - Authorization: authToken, - "content-type": "application/json", - }, - }); - if (response.ok) { - if (this.clearReconnectionAttemptMessage) { - this.clearReconnectionAttemptMessage(); - this.clearReconnectionAttemptMessage = undefined; - } - const memoryUsageInfo = await response.json(); - if (memoryUsageInfo) { - return { - totalKB: memoryUsageInfo.total, - freeKB: memoryUsageInfo.free, - }; - } - } else if (response.status === HttpStatusCodes.NotFound) { - throw new AbortError(response.statusText); - } - throw new Error(response.statusText); - } else { - return undefined; - } - } - - private shouldExecuteMemoryCall(): boolean { - return ( - useNotebook.getState().containerStatus?.status === Constants.ContainerStatusType.Active && - useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected - ); - } - - public async resetWorkspace(): Promise> { - this.isResettingWorkspace = true; - let response: IResponse; - try { - response = await this._resetWorkspace(); - } catch (error) { - Promise.reject(error); - return response; - } - this.isResettingWorkspace = false; - return response; - } - - private async _resetWorkspace(): Promise> { - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - if (!notebookServerInfo || !notebookServerInfo.notebookServerEndpoint) { - const error = "No server endpoint detected"; - Logger.logError(error, "NotebookContainerClient/resetWorkspace"); - return Promise.reject(error); - } - - try { - if (useNotebook.getState().isPhoenixNotebooks) { - const provisionData: IProvisionData = { - cosmosEndpoint: userContext.databaseAccount.properties.documentEndpoint, - poolId: PoolIdType.DefaultPoolId, - }; - return await this.phoenixClient.resetContainer(provisionData); - } - return null; - } catch (error) { - Logger.logError(getErrorMessage(error), "NotebookContainerClient/resetWorkspace"); - if (error?.status === HttpStatusCodes.Forbidden && error.message) { - useDialog.getState().showOkModalDialog("Connection Failed", `${error.message}`); - } else { - useDialog - .getState() - .showOkModalDialog( - "Connection Failed", - "We are unable to connect to the temporary workspace. Please try again in a few minutes. If the error persists, file a support ticket.", - ); - } - throw error; - } - } - - private getNotebookServerConfig(): { notebookServerEndpoint: string; authToken: string } { - const notebookServerInfo = useNotebook.getState().notebookServerInfo; - const authToken: string = notebookServerInfo.authToken ? `Token ${notebookServerInfo.authToken}` : undefined; - - return { - notebookServerEndpoint: notebookServerInfo.notebookServerEndpoint, - authToken, - }; - } - - private getHeaders(): HeadersInit { - const authorizationHeader = getAuthorizationHeader(); - return { - [authorizationHeader.header]: authorizationHeader.token, - [HttpHeaders.contentType]: "application/json", - }; - } -} diff --git a/src/Explorer/Notebook/NotebookContentItem.ts b/src/Explorer/Notebook/NotebookContentItem.ts deleted file mode 100644 index bb79485b8..000000000 --- a/src/Explorer/Notebook/NotebookContentItem.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface NotebookContentItem { - name: string; - path: string; - type: NotebookContentItemType; - children?: NotebookContentItem[]; - parent?: NotebookContentItem; - timestamp?: number; -} - -export enum NotebookContentItemType { - Notebook, - File, - Directory, -} diff --git a/src/Explorer/Notebook/NotebookManager.tsx b/src/Explorer/Notebook/NotebookManager.tsx deleted file mode 100644 index 175c76061..000000000 --- a/src/Explorer/Notebook/NotebookManager.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Contains all notebook related stuff meant to be dynamically loaded by explorer - */ - -import { JunoClient } from "../../Juno/JunoClient"; -import { userContext } from "../../UserContext"; -import Explorer from "../Explorer"; -import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; -import { NotebookContainerClient } from "./NotebookContainerClient"; - -export interface NotebookManagerOptions { - container: Explorer; - resourceTree: ResourceTreeAdapter; - refreshCommandBarButtons: () => void; - refreshNotebookList: () => void; -} - -export default class NotebookManager { - private params: NotebookManagerOptions; - public junoClient: JunoClient; - - public notebookClient: NotebookContainerClient; - - public initialize(params: NotebookManagerOptions): void { - this.params = params; - this.junoClient = new JunoClient(); - - this.notebookClient = new NotebookContainerClient(() => - this.params.container.initNotebooks(userContext?.databaseAccount), - ); - } -} diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts deleted file mode 100644 index 97ff4514a..000000000 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NotebookUtil } from "./NotebookUtil"; - -const fileName = "file"; -const notebookName = "file.ipynb"; -const folderPath = "folder"; -const filePath = `${folderPath}/${fileName}`; -const notebookPath = `${folderPath}/${notebookName}`; - -describe("NotebookUtil", () => { - describe("isNotebookFile", () => { - it("works for jupyter file paths", () => { - expect(NotebookUtil.isNotebookFile(filePath)).toBeFalsy(); - expect(NotebookUtil.isNotebookFile(notebookPath)).toBeTruthy(); - }); - }); - - describe("getFilePath", () => { - it("works for jupyter file paths", () => { - expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath); - }); - }); - - describe("getParentPath", () => { - it("works for jupyter file paths", () => { - expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath); - }); - }); - - describe("getName", () => { - it("works for jupyter file paths", () => { - expect(NotebookUtil.getName(filePath)).toEqual(fileName); - expect(NotebookUtil.getName(notebookPath)).toEqual(notebookName); - }); - }); - - describe("replaceName", () => { - it("works for jupyter file paths", () => { - expect(NotebookUtil.replaceName(filePath, "newName")).toEqual(filePath.replace(fileName, "newName")); - expect(NotebookUtil.replaceName(notebookPath, "newName")).toEqual(notebookPath.replace(notebookName, "newName")); - }); - }); -}); diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts deleted file mode 100644 index d8e752f3d..000000000 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as StringUtils from "../../Utils/StringUtils"; -import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; - -// Must match rx-jupyter' FileType -export type FileType = "directory" | "file" | "notebook"; - -// Utilities for notebooks -export class NotebookUtil { - /** - * It's a notebook file if the filename ends with .ipynb. - */ - public static isNotebookFile(notebookPath: string): boolean { - const fileName = NotebookUtil.getName(notebookPath); - return !!fileName && StringUtils.endsWith(fileName, ".ipynb"); - } - - /** - * Note: this does not connect the item to a parent in a tree. - * @param name - * @param path - */ - public static createNotebookContentItem(name: string, path: string, type: FileType): NotebookContentItem { - return { - name, - path, - type: NotebookUtil.getType(type), - timestamp: NotebookUtil.getCurrentTimestamp(), - }; - } - - /** - * Convert rx-jupyter type to our type - * @param type - */ - public static getType(type: FileType): NotebookContentItemType { - switch (type) { - case "directory": - return NotebookContentItemType.Directory; - case "notebook": - return NotebookContentItemType.Notebook; - case "file": - return NotebookContentItemType.File; - default: - throw new Error(`Unknown file type: ${type}`); - } - } - - public static getCurrentTimestamp(): number { - return new Date().getTime(); - } - - public static getFilePath(path: string, fileName: string): string { - return `${path}/${fileName}`; - } - - public static getParentPath(filepath: string): undefined | string { - const basename = NotebookUtil.getName(filepath); - if (basename) { - const parentPath = filepath.split(basename).shift(); - if (parentPath) { - return parentPath.replace(/\/$/, ""); // no trailling slash - } - } - - return undefined; - } - - public static getName(path: string): undefined | string { - return path.split("/").pop(); - } - - public static replaceName(path: string, newName: string): string { - const contentName = path.split("/").pop(); - if (!contentName) { - throw new Error(`Failed to extract name from path ${path}`); - } - - const basePath = path.split(contentName).shift(); - return `${basePath}${newName}`; - } -} diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts deleted file mode 100644 index 608358cf7..000000000 --- a/src/Explorer/Notebook/useNotebook.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { cloneDeep } from "lodash"; -import create, { UseStore } from "zustand"; -import { AuthType } from "../../AuthType"; -import * as Constants from "../../Common/Constants"; -import { ConnectionStatusType } from "../../Common/Constants"; -import { getErrorMessage } from "../../Common/ErrorHandlingUtils"; -import * as Logger from "../../Common/Logger"; -import { configContext } from "../../ConfigContext"; -import * as DataModels from "../../Contracts/DataModels"; -import { ContainerConnectionInfo, ContainerInfo } from "../../Contracts/DataModels"; -import { Action } from "../../Shared/Telemetry/TelemetryConstants"; -import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; -import { userContext } from "../../UserContext"; -import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils"; -import { useTabs } from "../../hooks/useTabs"; -import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; - -interface NotebookState { - isNotebookEnabled: boolean; - isNotebooksEnabledForAccount: boolean; - notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo; - sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo; - isSynapseLinkUpdating: boolean; - memoryUsageInfo: DataModels.MemoryUsageInfo; - isShellEnabled: boolean; - notebookBasePath: string; - isInitializingNotebooks: boolean; - myNotebooksContentRoot: NotebookContentItem; - galleryContentRoot: NotebookContentItem; - connectionInfo: ContainerConnectionInfo; - notebookFolderName: string; - isAllocating: boolean; - isRefreshed: boolean; - containerStatus: ContainerInfo; - isPhoenixNotebooks: boolean; - isPhoenixFeatures: boolean; - setIsNotebookEnabled: (isNotebookEnabled: boolean) => void; - setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => void; - setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => void; - setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) => void; - setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void; - setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => void; - setIsShellEnabled: (isShellEnabled: boolean) => void; - setNotebookBasePath: (notebookBasePath: string) => void; - setNotebookFolderName: (notebookFolderName: string) => void; - refreshNotebooksEnabledStateForAccount: () => Promise; - findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem; - insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem) => void; - updateNotebookItem: (item: NotebookContentItem) => void; - deleteNotebookItem: (item: NotebookContentItem) => void; - initializeNotebooksTree: () => Promise; - setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void; - setIsAllocating: (isAllocating: boolean) => void; - resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void; - setIsRefreshed: (isAllocating: boolean) => void; - setContainerStatus: (containerStatus: ContainerInfo) => void; - getPhoenixStatus: () => Promise; - setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => void; - setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => void; -} - -export const useNotebook: UseStore = create((set, get) => ({ - isNotebookEnabled: false, - isNotebooksEnabledForAccount: false, - notebookServerInfo: { - notebookServerEndpoint: undefined, - authToken: undefined, - forwardingId: undefined, - }, - sparkClusterConnectionInfo: { - userName: undefined, - password: undefined, - endpoints: [], - }, - isSynapseLinkUpdating: false, - memoryUsageInfo: undefined, - isShellEnabled: false, - notebookBasePath: Constants.Notebook.defaultBasePath, - isInitializingNotebooks: false, - myNotebooksContentRoot: undefined, - galleryContentRoot: undefined, - connectionInfo: { - status: ConnectionStatusType.Connect, - }, - notebookFolderName: undefined, - isAllocating: false, - isRefreshed: false, - containerStatus: { - status: undefined, - durationLeftInMinutes: undefined, - phoenixServerInfo: undefined, - }, - isPhoenixNotebooks: undefined, - isPhoenixFeatures: undefined, - setIsNotebookEnabled: (isNotebookEnabled: boolean) => set({ isNotebookEnabled }), - setIsNotebooksEnabledForAccount: (isNotebooksEnabledForAccount: boolean) => set({ isNotebooksEnabledForAccount }), - setNotebookServerInfo: (notebookServerInfo: DataModels.NotebookWorkspaceConnectionInfo) => - set({ notebookServerInfo }), - setSparkClusterConnectionInfo: (sparkClusterConnectionInfo: DataModels.SparkClusterConnectionInfo) => - set({ sparkClusterConnectionInfo }), - setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }), - setMemoryUsageInfo: (memoryUsageInfo: DataModels.MemoryUsageInfo) => set({ memoryUsageInfo }), - setIsShellEnabled: (isShellEnabled: boolean) => set({ isShellEnabled }), - setNotebookBasePath: (notebookBasePath: string) => set({ notebookBasePath }), - setNotebookFolderName: (notebookFolderName: string) => set({ notebookFolderName }), - refreshNotebooksEnabledStateForAccount: async (): Promise => { - await get().getPhoenixStatus(); - const { databaseAccount, authType } = userContext; - if ( - authType === AuthType.EncryptedToken || - authType === AuthType.ResourceToken || - authType === AuthType.MasterKey - ) { - set({ isNotebooksEnabledForAccount: false }); - return; - } - - const firstWriteLocation = - userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo" - ? databaseAccount?.location - : databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase(); - const disallowedLocationsUri: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`; - const authorizationHeader = getAuthorizationHeader(); - const startKey = TelemetryProcessor.traceStart(Action.RefreshNotebooksEnabled, { - dataExplorerArea: "Notebook", - }); - try { - const response = await fetch(disallowedLocationsUri, { - method: "POST", - body: JSON.stringify({ - resourceTypes: [Constants.ArmResourceTypes.notebookWorkspaces], - }), - headers: { - [authorizationHeader.header]: authorizationHeader.token, - [Constants.HttpHeaders.contentType]: "application/json", - }, - }); - - if (!response.ok) { - throw new Error("Failed to fetch disallowed locations"); - } - - const disallowedLocations: string[] = await response.json(); - if (!disallowedLocations) { - Logger.logInfo("No disallowed locations found", "Explorer/isNotebooksEnabledForAccount"); - set({ isNotebooksEnabledForAccount: true }); - return; - } - - // firstWriteLocation should not be disallowed - const isAccountInAllowedLocation = firstWriteLocation && disallowedLocations.indexOf(firstWriteLocation) === -1; - set({ isNotebooksEnabledForAccount: isAccountInAllowedLocation }); - TelemetryProcessor.traceSuccess(Action.RefreshNotebooksEnabled, { isAccountInAllowedLocation }, startKey); - } catch (error) { - Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); - set({ isNotebooksEnabledForAccount: false }); - TelemetryProcessor.traceFailure(Action.RefreshNotebooksEnabled, { error: getErrorMessage(error) }, startKey); - } - }, - findItem: (root: NotebookContentItem, item: NotebookContentItem): NotebookContentItem => { - const currentItem = root || get().myNotebooksContentRoot; - - if (currentItem) { - if (currentItem.path === item.path && currentItem.name === item.name) { - return currentItem; - } - - if (currentItem.children) { - for (const childItem of currentItem.children) { - const result = get().findItem(childItem, item); - if (result) { - return result; - } - } - } - } - - return undefined; - }, - insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem): void => { - const root = cloneDeep(get().myNotebooksContentRoot); - const parentItem = get().findItem(root, parent); - item.parent = parentItem; - if (parentItem.children) { - parentItem.children.push(item); - } else { - parentItem.children = [item]; - } - set({ myNotebooksContentRoot: root }); - }, - updateNotebookItem: (item: NotebookContentItem): void => { - const root = cloneDeep(get().myNotebooksContentRoot); - const parentItem = get().findItem(root, item.parent); - parentItem.children = parentItem.children.filter((child) => child.path !== item.path); - parentItem.children.push(item); - item.parent = parentItem; - set({ myNotebooksContentRoot: root }); - }, - deleteNotebookItem: (item: NotebookContentItem): void => { - const root = cloneDeep(get().myNotebooksContentRoot); - const parentItem = get().findItem(root, item.parent); - parentItem.children = parentItem.children.filter((child) => child.path !== item.path); - set({ myNotebooksContentRoot: root }); - }, - initializeNotebooksTree: async (): Promise => { - const notebookFolderName = get().isPhoenixNotebooks ? "Temporary Notebooks" : "My Notebooks"; - set({ notebookFolderName }); - const myNotebooksContentRoot = { - name: get().notebookFolderName, - path: get().notebookBasePath, - type: NotebookContentItemType.Directory, - }; - const galleryContentRoot = { - name: "Gallery", - path: "Gallery", - type: NotebookContentItemType.File, - }; - - set({ - myNotebooksContentRoot, - galleryContentRoot, - }); - }, - setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => set({ connectionInfo }), - setIsAllocating: (isAllocating: boolean) => set({ isAllocating }), - resetContainerConnection: (connectionStatus: ContainerConnectionInfo): void => { - useTabs.getState().closeAllNotebookTabs(true); - useNotebook.getState().setConnectionInfo(connectionStatus); - useNotebook.getState().setNotebookServerInfo(undefined); - useNotebook.getState().setIsAllocating(false); - useNotebook.getState().setContainerStatus({ - status: undefined, - durationLeftInMinutes: undefined, - phoenixServerInfo: undefined, - }); - }, - setIsRefreshed: (isRefreshed: boolean) => set({ isRefreshed }), - setContainerStatus: (containerStatus: ContainerInfo) => set({ containerStatus }), - getPhoenixStatus: async () => { - if (get().isPhoenixNotebooks === undefined || get().isPhoenixFeatures === undefined) { - // getDbAccountAllowedStatus has been deprecated; Phoenix features are no longer available. - set({ isPhoenixNotebooks: false }); - set({ isPhoenixFeatures: false }); - } - }, - setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => set({ isPhoenixNotebooks: isPhoenixNotebooks }), - setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => set({ isPhoenixFeatures: isPhoenixFeatures }), -})); diff --git a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap index 2495d4165..fd58794e0 100644 --- a/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap +++ b/src/Explorer/Panes/AddGlobalSecondaryIndexPanel/__snapshots__/AddGlobalSecondaryIndexPanel.test.tsx.snap @@ -145,25 +145,15 @@ exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = ` = ({ explorer }) => { React.useEffect(() => { subscriptions.push( - { - dispose: useNotebook.subscribe( - () => setState({}), - (state) => state.isNotebookEnabled, - ), - }, { dispose: useSelectedNode.subscribe(() => setState({})) }, { dispose: useCarousel.subscribe( diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx index ded16bf78..6bb014011 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx @@ -35,15 +35,6 @@ import * as ViewModels from "../../../Contracts/ViewModels"; import { updateUserContext } from "../../../UserContext"; import Explorer from "../../Explorer"; -jest.mock("rx-jupyter", () => ({ - sessions: { - create: jest.fn(), - }, - contents: { - JupyterContentProvider: jest.fn().mockImplementation(() => ({})), - }, -})); - jest.mock("Common/dataAccess/queryDocuments", () => ({ queryDocuments: jest.fn(() => ({ // Omit headers, because we can't mock a private field and we don't need to test it diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx index e0bacfa5a..7cee70afe 100644 --- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx +++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2Mongo.test.tsx @@ -19,15 +19,6 @@ import { act } from "react-dom/test-utils"; import * as ViewModels from "../../../Contracts/ViewModels"; import Explorer from "../../Explorer"; -jest.mock("rx-jupyter", () => ({ - sessions: { - create: jest.fn(), - }, - contents: { - JupyterContentProvider: jest.fn().mockImplementation(() => ({})), - }, -})); - jest.requireActual("Explorer/Controls/Editor/EditorReact"); const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__"; diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index f78a95eac..78faf849c 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -5,7 +5,6 @@ import { TriggerDefinition, UserDefinedFunctionDefinition, } from "@azure/cosmos"; -import { useNotebook } from "Explorer/Notebook/useNotebook"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils"; diff --git a/src/Explorer/Tree/Database.test.tsx b/src/Explorer/Tree/Database.test.tsx index 9fbeb56fe..4bd8f087a 100644 --- a/src/Explorer/Tree/Database.test.tsx +++ b/src/Explorer/Tree/Database.test.tsx @@ -6,15 +6,6 @@ import { updateUserContext, userContext } from "../../UserContext"; import Explorer from "../Explorer"; import Database from "./Database"; -jest.mock("rx-jupyter", () => ({ - sessions: { - create: jest.fn(), - }, - contents: { - JupyterContentProvider: jest.fn().mockImplementation(() => ({})), - }, -})); - const createMockContainer = (): Explorer => { const mockContainer = new Explorer(); return mockContainer; diff --git a/src/Explorer/Tree/ResourceTree.tsx b/src/Explorer/Tree/ResourceTree.tsx index dfcf53422..8ce6e2cb0 100644 --- a/src/Explorer/Tree/ResourceTree.tsx +++ b/src/Explorer/Tree/ResourceTree.tsx @@ -18,12 +18,8 @@ import { userContext } from "UserContext"; import { ReactTabKind, useTabs } from "hooks/useTabs"; import * as React from "react"; import { useEffect, useMemo } from "react"; -import shallow from "zustand/shallow"; import { useDatabaseLoadScenario } from "../../Metrics/useMetricPhases"; import Explorer from "../Explorer"; -import { useNotebook } from "../Notebook/useNotebook"; - -export const MyNotebooksTitle = "My Notebooks"; interface ResourceTreeProps { explorer: Explorer; @@ -40,13 +36,6 @@ export const ResourceTree: React.FC = ({ explorer }: Resource const [openItems, setOpenItems] = React.useState([]); const treeStyles = useTreeStyles(); - const { isNotebookEnabled } = useNotebook( - (state) => ({ - isNotebookEnabled: state.isNotebookEnabled, - }), - shallow, - ); - // We intentionally avoid using a state selector here because we want to re-render the tree if the active tab changes. const { refreshActiveTab } = useTabs(); @@ -65,24 +54,8 @@ export const ResourceTree: React.FC = ({ explorer }: Resource const databaseTreeNodes = useMemo(() => { return userContext.authType === AuthType.ResourceToken ? createResourceTokenTreeNodes(resourceTokenCollection) - : createDatabaseTreeNodes( - explorer, - isNotebookEnabled, - databases, - refreshActiveTab, - searchText, - sortOrder, - pinnedDatabaseIds, - ); - }, [ - resourceTokenCollection, - databases, - isNotebookEnabled, - refreshActiveTab, - searchText, - sortOrder, - pinnedDatabaseIds, - ]); + : createDatabaseTreeNodes(explorer, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds); + }, [resourceTokenCollection, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds]); const headerNodes: TreeNode[] = isFabricMirrored() ? [] diff --git a/src/Explorer/Tree/ResourceTreeAdapter.tsx b/src/Explorer/Tree/ResourceTreeAdapter.tsx index e25b76a45..f2f2af369 100644 --- a/src/Explorer/Tree/ResourceTreeAdapter.tsx +++ b/src/Explorer/Tree/ResourceTreeAdapter.tsx @@ -1,34 +1,20 @@ -import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity"; import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil"; import { getItemName } from "Utils/APITypeUtils"; import * as ko from "knockout"; import * as React from "react"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; -import DeleteIcon from "../../../images/delete.svg"; -import CopyIcon from "../../../images/notebook/Notebook-copy.svg"; -import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; -import NotebookIcon from "../../../images/notebook/Notebook-resource.svg"; -import FileIcon from "../../../images/notebook/file-cosmos.svg"; -import RefreshIcon from "../../../images/refresh-cosmos.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; -import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility"; import * as DataModels from "../../Contracts/DataModels"; import * as ViewModels from "../../Contracts/ViewModels"; -import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; -import * as TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { userContext } from "../../UserContext"; import { isServerlessAccount } from "../../Utils/CapabilityUtils"; import { useTabs } from "../../hooks/useTabs"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; -import { useDialog } from "../Controls/Dialog"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent"; import Explorer from "../Explorer"; import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; -import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; -import { NotebookUtil } from "../Notebook/NotebookUtil"; -import { useNotebook } from "../Notebook/useNotebook"; import TabsBase from "../Tabs/TabsBase"; import { useDatabases } from "../useDatabases"; import { useSelectedNode } from "../useSelectedNode"; @@ -37,16 +23,10 @@ import Trigger from "./Trigger"; import UserDefinedFunction from "./UserDefinedFunction"; export class ResourceTreeAdapter implements ReactAdapter { - public static readonly MyNotebooksTitle = "My Notebooks"; - private static readonly DataTitle = "DATA"; - private static readonly NotebooksTitle = "NOTEBOOKS"; - private static readonly PseudoDirPath = "PsuedoDir"; public parameters: ko.Observable; - public myNotebooksContentRoot: NotebookContentItem; - public constructor(private container: Explorer) { this.parameters = ko.observable(Date.now()); @@ -55,56 +35,15 @@ export class ResourceTreeAdapter implements ReactAdapter { () => this.triggerRender(), (state) => state.activeTab, ); - useNotebook.subscribe( - () => this.triggerRender(), - (state) => state.isNotebookEnabled, - ); - useDatabases.subscribe(() => this.triggerRender()); this.triggerRender(); } - private traceMyNotebookTreeInfo() { - const myNotebooksTree = this.myNotebooksContentRoot; - if (myNotebooksTree.children) { - // Count 1st generation children (tree is lazy-loaded) - const nodeCounts = { files: 0, notebooks: 0, directories: 0 }; - myNotebooksTree.children.forEach((treeNode) => { - switch ((treeNode as NotebookContentItem).type) { - case NotebookContentItemType.File: - nodeCounts.files++; - break; - case NotebookContentItemType.Directory: - nodeCounts.directories++; - break; - case NotebookContentItemType.Notebook: - nodeCounts.notebooks++; - break; - default: - break; - } - }); - TelemetryProcessor.trace(Action.RefreshResourceTreeMyNotebooks, ActionModifiers.Mark, { ...nodeCounts }); - } - } - public renderComponent(): JSX.Element { const dataRootNode = this.buildDataTree(); return ; } - public async initialize(): Promise { - const refreshTasks: Promise[] = []; - - this.myNotebooksContentRoot = { - name: useNotebook.getState().notebookFolderName, - path: useNotebook.getState().notebookBasePath, - type: NotebookContentItemType.Directory, - }; - - return Promise.all(refreshTasks); - } - private buildDataTree(): LegacyTreeNode { const databaseTreeNodes: LegacyTreeNode[] = useDatabases .getState() @@ -403,93 +342,6 @@ export class ResourceTreeAdapter implements ReactAdapter { return traverse(schema); } - private buildChildNodes( - item: NotebookContentItem, - onFileClick: (item: NotebookContentItem) => void, - createDirectoryContextMenu: boolean, - createFileContextMenu: boolean, - ): LegacyTreeNode[] { - if (!item || !item.children) { - return []; - } else { - return item.children.map((item) => { - const result = - item.type === NotebookContentItemType.Directory - ? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu) - : this.buildNotebookFileNode(item, onFileClick, createFileContextMenu); - result.timestamp = item.timestamp; - return result; - }); - } - } - - private buildNotebookFileNode( - item: NotebookContentItem, - onFileClick: (item: NotebookContentItem) => void, - createFileContextMenu: boolean, - ): LegacyTreeNode { - return { - label: item.name, - iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, - className: "notebookHeader", - onClick: () => onFileClick(item), - isSelected: () => { - const activeTab = useTabs.getState().activeTab; - return ( - activeTab && - activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && - /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. - NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. - */ - (activeTab as any).notebookPath() === item.path - ); - }, - contextMenu: createFileContextMenu && this.createFileContextMenu(), - data: item, - }; - } - - private createFileContextMenu(): TreeNodeMenuItem[] { - return []; - } - - private createDirectoryContextMenu(): TreeNodeMenuItem[] { - return []; - } - - private buildNotebookDirectoryNode( - item: NotebookContentItem, - onFileClick: (item: NotebookContentItem) => void, - createDirectoryContextMenu: boolean, - createFileContextMenu: boolean, - ): LegacyTreeNode { - return { - label: item.name, - iconSrc: undefined, - className: "notebookHeader", - isAlphaSorted: true, - isLeavesParentsSeparate: true, - onClick: undefined, - isSelected: () => { - const activeTab = useTabs.getState().activeTab; - return ( - activeTab && - activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && - /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. - NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. - */ - (activeTab as any).notebookPath() === item.path - ); - }, - contextMenu: - createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath - ? this.createDirectoryContextMenu() - : undefined, - data: item, - children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu), - }; - } - public triggerRender() { window.requestAnimationFrame(() => this.parameters(Date.now())); } diff --git a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap index ead9bdb15..53c49c24f 100644 --- a/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap +++ b/src/Explorer/Tree/__snapshots__/treeNodeUtil.test.ts.snap @@ -363,7 +363,7 @@ exports[`createDatabaseTreeNodes generates the correct tree structure for the Ca ] `; -exports[`createDatabaseTreeNodes generates the correct tree structure for the Mongo API, with Notebooks and Phoenix features, on Emulator 1`] = ` +exports[`createDatabaseTreeNodes generates the correct tree structure for the Mongo API, on Emulator 1`] = ` [ { "children": [ diff --git a/src/Explorer/Tree/treeNodeUtil.test.ts b/src/Explorer/Tree/treeNodeUtil.test.ts index c64f9276e..f30c3e166 100644 --- a/src/Explorer/Tree/treeNodeUtil.test.ts +++ b/src/Explorer/Tree/treeNodeUtil.test.ts @@ -4,7 +4,6 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import Explorer from "Explorer/Explorer"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; -import { useNotebook } from "Explorer/Notebook/useNotebook"; import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel"; import TabsBase from "Explorer/Tabs/TabsBase"; import StoredProcedure from "Explorer/Tree/StoredProcedure"; @@ -363,7 +362,7 @@ describe("createDatabaseTreeNodes", () => { }, } as never, }); - nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, ""); + nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, ""); }); it("creates expected tree", () => { @@ -371,11 +370,10 @@ describe("createDatabaseTreeNodes", () => { }); }); - it.each<[string, Platform, boolean, Partial, Partial]>([ + it.each<[string, Platform, Partial, Partial]>([ [ "the SQL API, on Fabric read-only (mirrored)", Platform.Fabric, - false, { capabilities: [], enableMultipleWriteLocations: true }, { fabricContext: { @@ -387,7 +385,6 @@ describe("createDatabaseTreeNodes", () => { [ "the SQL API, on Fabric non read-only (native)", Platform.Fabric, - false, { capabilities: [], enableMultipleWriteLocations: true }, { fabricContext: { @@ -399,7 +396,6 @@ describe("createDatabaseTreeNodes", () => { [ "the SQL API, on Portal", Platform.Portal, - false, { capabilities: [], enableMultipleWriteLocations: true }, { fabricContext: undefined, @@ -408,7 +404,6 @@ describe("createDatabaseTreeNodes", () => { [ "the Cassandra API, serverless, on Hosted", Platform.Hosted, - false, { capabilities: [ { name: CapabilityNames.EnableCassandra, description: "" }, @@ -418,45 +413,34 @@ describe("createDatabaseTreeNodes", () => { { fabricContext: undefined }, ], [ - "the Mongo API, with Notebooks and Phoenix features, on Emulator", + "the Mongo API, on Emulator", Platform.Emulator, - true, { capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }], }, { fabricContext: undefined }, ], - ])( - "generates the correct tree structure for %s", - (_, platform, isNotebookEnabled, dbAccountProperties, userContext) => { - useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled }); - updateConfigContext({ platform }); - updateUserContext({ - ...userContext, - databaseAccount: { - properties: { - enableMultipleWriteLocations: true, - ...dbAccountProperties, - }, - } as unknown as DataModels.DatabaseAccount, - }); - const nodes = createDatabaseTreeNodes( - explorer, - isNotebookEnabled, - useDatabases.getState().databases, - refreshActiveTab, - "", - ); - expect(nodes).toMatchSnapshot(); - }, - ); + ])("generates the correct tree structure for %s", (_, platform, dbAccountProperties, userContext) => { + updateConfigContext({ platform }); + updateUserContext({ + ...userContext, + databaseAccount: { + properties: { + enableMultipleWriteLocations: true, + ...dbAccountProperties, + }, + } as unknown as DataModels.DatabaseAccount, + }); + const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, ""); + expect(nodes).toMatchSnapshot(); + }); // The above tests focused on the tree structure. The below tests focus on some core behaviors of the nodes. // They are not exhaustive, because exhaustive tests here require a lot of mocking and can become very brittle. // The goal is to cover some key behaviors like loading child nodes, opening tabs/side panels, etc. it("adds new collections to database as they appear", () => { - const nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, ""); + const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, ""); const giganticDbNode = nodes.find((node) => node.label === giganticDb.id()); expect(giganticDbNode).toBeDefined(); expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]); @@ -488,7 +472,7 @@ describe("createDatabaseTreeNodes", () => { }, } as unknown as DataModels.DatabaseAccount, }); - nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, ""); + nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, ""); standardDbNode = nodes.find((node) => node.label === standardDb.id()); sharedDbNode = nodes.find((node) => node.label === sharedDb.id()); giganticDbNode = nodes.find((node) => node.label === giganticDb.id()); @@ -643,7 +627,7 @@ describe("createDatabaseTreeNodes", () => { setup(); // Rebuild the nodes after changing the user/config context. - nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, ""); + nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, ""); standardDbNode = nodes.find((node) => node.label === standardDb.id()); standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id()); diff --git a/src/Explorer/Tree/treeNodeUtil.tsx b/src/Explorer/Tree/treeNodeUtil.tsx index 224542755..d784080cb 100644 --- a/src/Explorer/Tree/treeNodeUtil.tsx +++ b/src/Explorer/Tree/treeNodeUtil.tsx @@ -137,7 +137,6 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa export const createDatabaseTreeNodes = ( container: Explorer, - isNotebookEnabled: boolean, databases: ViewModels.Database[], refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, searchText = "", @@ -184,9 +183,7 @@ export const createDatabaseTreeNodes = ( database .collections() .forEach((collection: ViewModels.Collection) => - databaseNode.children.push( - buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab), - ), + databaseNode.children.push(buildCollectionNode(database, collection, container, refreshActiveTab)), ); if (database.collectionsContinuationToken) { @@ -254,14 +251,13 @@ export const createDatabaseTreeNodes = ( export const buildCollectionNode = ( database: ViewModels.Database, collection: ViewModels.Collection, - isNotebookEnabled: boolean, container: Explorer, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, ): TreeNode => { let children: TreeNode[]; // Flat Tree for Fabric if (!isFabricMirrored()) { - children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab); + children = buildCollectionNodeChildren(database, collection, container, refreshActiveTab); } const collectionNode: TreeNode = { @@ -310,7 +306,6 @@ export const buildCollectionNode = ( const buildCollectionNodeChildren = ( database: ViewModels.Database, collection: ViewModels.Collection, - isNotebookEnabled: boolean, container: Explorer, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, ): TreeNode[] => { diff --git a/src/Explorer/useSelectedNode.ts b/src/Explorer/useSelectedNode.ts index 4dd34a91e..cbade8ed9 100644 --- a/src/Explorer/useSelectedNode.ts +++ b/src/Explorer/useSelectedNode.ts @@ -1,5 +1,3 @@ -import { ConnectionStatusType } from "Common/Constants"; -import { useNotebook } from "Explorer/Notebook/useNotebook"; import create, { UseStore } from "zustand"; import * as ViewModels from "../Contracts/ViewModels"; import { useTabs } from "../hooks/useTabs"; @@ -13,7 +11,6 @@ export interface SelectedNodeState { collectionId?: string, subnodeKinds?: ViewModels.CollectionTabKind[], ) => boolean; - isConnectedToContainer: () => boolean; } export const useSelectedNode: UseStore = create((set, get) => ({ @@ -61,7 +58,4 @@ export const useSelectedNode: UseStore = create((set, get) => subnodeKinds.includes(selectedSubnodeKind) ); }, - isConnectedToContainer: (): boolean => { - return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected; - }, })); diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index ca28376d6..2561f543a 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -83,9 +83,9 @@ const App: React.FunctionComponent = () => { setEncryptedToken(token); setAuthType(AuthType.ConnectionString); }) - .catch((error) => { + .catch(async (error) => { logError( - `Failed to connect with connection string: ${getErrorMessage(error)}`, + `Failed to connect with connection string: ${await (error as Response).text()}`, "HostedExplorer/connectWithConnectionString", ); }); diff --git a/src/Main.tsx b/src/Main.tsx index 6179711d6..e60a5e863 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -52,8 +52,6 @@ import { ErrorBoundary } from "./Explorer/ErrorBoundary"; import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less"; import "./Explorer/Menus/CommandBar/CommandBarComponent.less"; import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter"; -import "./Explorer/Menus/CommandBar/ConnectionStatusComponent.less"; -import "./Explorer/Menus/CommandBar/MemoryTrackerComponent.less"; import "./Explorer/Menus/NotificationConsole/NotificationConsole.less"; import { NotificationConsole } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import "./Explorer/Panes/PanelComponent.less"; diff --git a/src/Phoenix/PhoenixClient.ts b/src/Phoenix/PhoenixClient.ts deleted file mode 100644 index f1d722e3b..000000000 --- a/src/Phoenix/PhoenixClient.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { configContext } from "ConfigContext"; -import { useDialog } from "Explorer/Controls/Dialog"; -import { Action } from "Shared/Telemetry/TelemetryConstants"; -import { userContext } from "UserContext"; -import { allowedJunoOrigins, validateEndpoint } from "Utils/EndpointUtils"; -import promiseRetry, { AbortError, Options } from "p-retry"; -import { - Areas, - ConnectionStatusType, - ContainerStatusType, - HttpHeaders, - HttpStatusCodes, - Notebook, -} from "../Common/Constants"; -import { getErrorMessage } from "../Common/ErrorHandlingUtils"; -import * as Logger from "../Common/Logger"; -import { - ContainerConnectionInfo, - ContainerInfo, - IContainerData, - IMaxAllocationTimeExceeded, - IPhoenixConnectionInfoResult, - IPhoenixError, - IPhoenixServiceInfo, - IProvisionData, - IResponse, - PhoenixErrorType, -} from "../Contracts/DataModels"; -import { useNotebook } from "../Explorer/Notebook/useNotebook"; -import * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor"; -import { getAuthorizationHeader } from "../Utils/AuthorizationUtils"; - -export class PhoenixClient { - private armResourceId: string; - private containerHealthHandler: NodeJS.Timeout; - private retryOptions: Options = { - retries: Notebook.retryAttempts, - maxTimeout: Notebook.retryAttemptDelayMs, - minTimeout: Notebook.retryAttemptDelayMs, - }; - private abortController: AbortController; - private abortSignal: AbortSignal; - - constructor(armResourceId: string) { - this.armResourceId = armResourceId; - } - - public async allocateContainer(provisionData: IProvisionData): Promise> { - this.initializeCancelEventListener(); - - return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), { - retries: 4, - maxTimeout: 20000, - minTimeout: 20000, - signal: this.abortSignal, - }); - } - - public async resetContainer(provisionData: IProvisionData): Promise> { - return this.executeContainerAssignmentOperation(provisionData, "reset"); - } - - private async executeContainerAssignmentOperation( - provisionData: IProvisionData, - operation: string, - ): Promise> { - let response; - try { - response = await fetch(`${this.getPhoenixControlPlanePathPrefix()}/containerconnections/multicontainer`, { - method: operation === "allocate" ? "POST" : "PATCH", - headers: PhoenixClient.getHeaders(), - body: JSON.stringify(provisionData), - }); - const responseJson = await response?.json(); - if (response.ok) { - const phoenixConnectionInfoResult = responseJson as IPhoenixConnectionInfoResult[]; - if ( - !phoenixConnectionInfoResult || - phoenixConnectionInfoResult.length === 0 || - !phoenixConnectionInfoResult[0] - ) { - throw new Error("Received invalid phoenix connection response."); - } - return { - status: response.status, - data: phoenixConnectionInfoResult[0].phoenixServiceInfo, - }; - } - const phoenixError = responseJson as IPhoenixError; - if (response.status === HttpStatusCodes.Forbidden) { - if (phoenixError.message === "Sequence contains no elements") { - throw Error("Phoenix container allocation failed, please try again later."); - } - throw new AbortError(this.ConvertToForbiddenErrorString(phoenixError)); - } - throw new AbortError(phoenixError.message); - } catch (error) { - error.status = response?.status; - throw error; - } - } - - public async initiateContainerHeartBeat(shouldUseNotebookStates: boolean, containerData: IContainerData) { - if (this.containerHealthHandler) { - clearTimeout(this.containerHealthHandler); - } - await this.getContainerHealth(shouldUseNotebookStates, Notebook.containerStatusHeartbeatDelayMs, containerData); - } - - private scheduleContainerHeartbeat( - shouldUseNotebookStates: boolean, - delayMs: number, - containerData: IContainerData, - ): void { - this.containerHealthHandler = setTimeout(async () => { - await this.getContainerHealth(shouldUseNotebookStates, delayMs, containerData); - }, delayMs); - } - - private async getContainerStatusAsync( - shouldUseNotebookStates: boolean, - containerData: IContainerData, - ): Promise { - try { - const runContainerStatusAsync = async () => { - const response = await window.fetch( - `${this.getPhoenixControlPlanePathPrefix()}/${containerData.forwardingId}`, - { - method: "GET", - headers: PhoenixClient.getHeaders(), - }, - ); - if (response.status === HttpStatusCodes.OK) { - const containerStatus = await response.json(); - return { - durationLeftInMinutes: containerStatus?.durationLeftInMinutes, - phoenixServerInfo: containerStatus?.phoenixServerInfo, - status: ContainerStatusType.Active, - }; - } else if (response.status === HttpStatusCodes.NotFound) { - const error = "Disconnected from compute workspace"; - Logger.logError(error, ""); - const connectionStatus: ContainerConnectionInfo = { - status: ConnectionStatusType.Reconnect, - }; - TelemetryProcessor.traceMark(Action.PhoenixHeartBeat, { - dataExplorerArea: Areas.Notebook, - message: getErrorMessage(error), - }); - shouldUseNotebookStates ? useNotebook.getState().resetContainerConnection(connectionStatus) : undefined; - shouldUseNotebookStates && useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - shouldUseNotebookStates && - useDialog - .getState() - .showOkModalDialog( - "Disconnected", - "Disconnected from temporary workspace. Please click on connect button to connect to temporary workspace.", - ); - throw new AbortError(response.statusText); - } else if (response?.status === HttpStatusCodes.Forbidden) { - const validationMessage = this.ConvertToForbiddenErrorString(await response.json()); - if (validationMessage) { - useDialog.getState().showOkModalDialog("Connection Failed", `${validationMessage}`); - } - throw new AbortError(response.statusText); - } - throw new Error(response.statusText); - }; - return await promiseRetry(runContainerStatusAsync, this.retryOptions); - } catch (error) { - TelemetryProcessor.traceFailure(Action.PhoenixHeartBeat, { - dataExplorerArea: Areas.Notebook, - }); - Logger.logError(getErrorMessage(error), ""); - const connectionStatus: ContainerConnectionInfo = { - status: ConnectionStatusType.Failed, - }; - shouldUseNotebookStates ? useNotebook.getState().resetContainerConnection(connectionStatus) : undefined; - shouldUseNotebookStates && useNotebook.getState().setIsRefreshed(!useNotebook.getState().isRefreshed); - return { - durationLeftInMinutes: undefined, - phoenixServerInfo: undefined, - status: ContainerStatusType.Disconnected, - }; - } - } - - private async getContainerHealth(shouldUseNotebookStates: boolean, delayMs: number, containerData: IContainerData) { - const containerInfo = await this.getContainerStatusAsync(shouldUseNotebookStates, containerData); - shouldUseNotebookStates ? useNotebook.getState().setContainerStatus(containerInfo) : undefined; - - const containerStatus = shouldUseNotebookStates ? useNotebook.getState().containerStatus?.status : undefined; - if (containerStatus === ContainerStatusType.Active) { - this.scheduleContainerHeartbeat(shouldUseNotebookStates, delayMs, containerData); - } - } - - private getPhoenixControlPlanePathPrefix(): string { - if (!this.armResourceId) { - throw new Error("The Phoenix client was not initialized properly: missing ARM resource id"); - } - - const toolsEndpoint = - userContext.features.phoenixEndpoint ?? userContext.features.junoEndpoint ?? configContext.JUNO_ENDPOINT; - - if (!validateEndpoint(toolsEndpoint, allowedJunoOrigins)) { - const error = `${toolsEndpoint} not allowed as tools endpoint`; - console.error(error); - throw new Error(error); - } - - return `${toolsEndpoint}/api/controlplane/toolscontainer/cosmosaccounts${this.armResourceId}`; - } - - private static getHeaders(): HeadersInit { - const authorizationHeader = getAuthorizationHeader(); - return { - [authorizationHeader.header]: authorizationHeader.token, - [HttpHeaders.contentType]: "application/json", - }; - } - - private initializeCancelEventListener(): void { - this.abortController = new AbortController(); - this.abortSignal = this.abortController.signal; - - document.addEventListener("keydown", (event: KeyboardEvent) => { - if (event.ctrlKey && (event.key === "c" || event.key === "z")) { - this.abortController.abort(new AbortError("Request canceled")); - } - }); - } - - public ConvertToForbiddenErrorString(jsonData: IPhoenixError): string { - const errInfo = jsonData; - switch (errInfo?.type) { - case PhoenixErrorType.MaxAllocationTimeExceeded: { - const maxAllocationTimeExceeded = errInfo as IMaxAllocationTimeExceeded; - const allocateAfterTimestamp = new Date(maxAllocationTimeExceeded?.earliestAllocationTimestamp); - allocateAfterTimestamp.setDate(allocateAfterTimestamp.getDate() + 1); - return `${errInfo.message}` + ". Please try again after " + `${allocateAfterTimestamp.toLocaleString()}`; - } - case PhoenixErrorType.MaxDbAccountsPerUserExceeded: - case PhoenixErrorType.MaxUsersPerDbAccountExceeded: - case PhoenixErrorType.AllocationValidationResult: - case PhoenixErrorType.RegionNotServicable: - case PhoenixErrorType.UserMissingPermissionsError: - case PhoenixErrorType.SubscriptionNotAllowed: { - return `${errInfo.message}`; - } - default: { - return undefined; - } - } - } -} diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx index a98a89ccc..bdc562820 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx @@ -1,11 +1,27 @@ jest.mock("../../../hooks/useDirectories"); +jest.mock("../../../Common/PortalBackendClient"); import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { extractFeatures } from "Platform/Hosted/extractFeatures"; import { updateUserContext, userContext } from "UserContext"; import React from "react"; +import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient"; import { ConnectExplorer } from "./ConnectExplorer"; +const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction; +const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction< + typeof isAccountRestrictedForConnectionStringLogin +>; + +// fetchEncryptedToken rejects with the raw Response. +const rejectWithResponse = (status: number, body: string) => + mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response); + +beforeEach(() => { + jest.resetAllMocks(); + mockIsAccountRestricted.mockResolvedValue(false); +}); + it("shows the connect form", () => { const connectionString = "fakeConnectionString"; const login = jest.fn(); @@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i updateUserContext({ features: oldFeatures }); }); + +it("rejects an unrecognized connection string before token exchange", async () => { + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText( + "We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.", + ), + ).toBeInTheDocument(); + expect(mockIsAccountRestricted).not.toHaveBeenCalled(); + expect(mockFetchEncryptedToken).not.toHaveBeenCalled(); +}); + +it("shows that a connection is in progress", async () => { + let finishRestrictionCheck: (restricted: boolean) => void = () => undefined; + mockIsAccountRestricted.mockImplementation( + () => + new Promise((resolve) => { + finishRestrictionCheck = resolve; + }), + ); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + const connectButton = screen.getByDisplayValue("Connecting..."); + expect(connectButton).toBeDisabled(); + expect(connectButton.closest("form")).toHaveAttribute("aria-busy", "true"); + + finishRestrictionCheck(false); + expect(await screen.findByDisplayValue("Connect")).toBeEnabled(); +}); + +it("shows the error when the Portal Backend rejects the connection string", async () => { + // Mongo and Cassandra are the APIs that still exchange the connection string for an encrypted token. + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText( + "Couldn't authenticate with Cosmos DB: Request originated from IP 1.2.3.4 through public internet.", + ), + ).toBeInTheDocument(); +}); + +it("shows a generic error when the Portal Backend fails without a message", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + // A failure with an empty body leaves nothing worth rendering. + rejectWithResponse(500, ""); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText("Failed to connect to the account. Please check the connection string and try again."), + ).toBeInTheDocument(); +}); + +it("offers the firewall help link when the Portal Backend is blocked by the account firewall", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect(await screen.findByText("Allow access from Azure Portal")).toBeInTheDocument(); +}); + +it("does not offer the firewall help link for a failure the firewall did not cause", async () => { + const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255"; + rejectWithResponse(401, "The connection string is invalid."); + + render( + , + ); + fireEvent.click(screen.getByText("Connect to your account with connection string")); + fireEvent.click(screen.getByDisplayValue("Connect")); + + expect( + await screen.findByText("Couldn't authenticate with Cosmos DB: The connection string is invalid."), + ).toBeInTheDocument(); + expect(screen.queryByText("Allow access from Azure Portal")).toBeNull(); +}); diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index 99ce638cd..26c60e1d5 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -1,10 +1,11 @@ +import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components"; import { useBoolean } from "@fluentui/react-hooks"; import { getErrorMessage } from "Common/ErrorHandlingUtils"; import { userContext } from "UserContext"; import * as React from "react"; import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg"; -import ErrorImage from "../../../../images/error.svg"; import { AuthType } from "../../../AuthType"; +import { HttpStatusCodes } from "../../../Common/Constants"; import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient"; import { AccessInputMetadata } from "../../../Contracts/DataModels"; import { parseConnectionString } from "../Helpers/ConnectionStringParser"; @@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent = ({ }: Props) => { const [isFormVisible, { setTrue: showForm }] = useBoolean(false); const [errorMessage, setErrorMessage] = React.useState(""); + const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false); + const [isConnecting, setIsConnecting] = React.useState(false); const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin; return ( @@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent = ({ {isFormVisible && enableConnectionStringLogin ? (
{ event.preventDefault(); + if (isConnecting) { + return; + } + setErrorMessage(""); + setIsBlockedByFirewall(false); + setIsConnecting(true); try { - if (await isAccountRestrictedForConnectionStringLogin(connectionString)) { + if (isResourceTokenConnectionString(connectionString)) { + setAuthType(AuthType.ResourceToken); + return; + } + + const metadata = parseConnectionString(connectionString); + if (!metadata) { setErrorMessage( - "This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.", + "We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.", ); return; } - } catch (error) { - setErrorMessage(getErrorMessage(error)); - return; - } - if (isResourceTokenConnectionString(connectionString)) { - setAuthType(AuthType.ResourceToken); - return; - } + try { + if (await isAccountRestrictedForConnectionStringLogin(connectionString)) { + setErrorMessage( + "This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.", + ); + return; + } + } catch (error) { + setErrorMessage(getErrorMessage(error as Error)); + return; + } - const metadata = parseConnectionString(connectionString); - if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { - // SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so - // we skip the Portal Backend proxy and use the metadata parsed from the connection string. - setAccountMetadata(metadata); - setAuthType(AuthType.ConnectionString); - return; - } + if (isDirectConnectionStringLoginApi(metadata.apiKind)) { + setAccountMetadata(metadata); + setAuthType(AuthType.ConnectionString); + return; + } - const encryptedToken = await fetchEncryptedToken(connectionString); - setEncryptedToken(encryptedToken); - setAuthType(AuthType.ConnectionString); + // Mongo and Cassandra go through the Portal Backend + try { + const encryptedToken = await fetchEncryptedToken(connectionString); + setEncryptedToken(encryptedToken); + setAuthType(AuthType.ConnectionString); + } catch (error) { + const errorDetails = await (error as Response).text(); + + setErrorMessage( + errorDetails + ? `Couldn't authenticate with Cosmos DB: ${errorDetails}` + : "Failed to connect to the account. Please check the connection string and try again.", + ); + // A Forbidden usually means the account firewall dropped the request. The connection + // string is exchanged by the Portal Backend rather than the browser, so the account has + // to allowlist those services. + setIsBlockedByFirewall((error as Response).status === HttpStatusCodes.Forbidden); + } + } finally { + setIsConnecting(false); + } }} >

Connect to your account with connection string

@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent = ({ setConnectionString(event.target.value); }} /> - {errorMessage.length > 0 && ( - - Error notification - {errorMessage} - - )}

+ {errorMessage.length > 0 && ( + + + + {errorMessage} + {isBlockedByFirewall && ( + + Allow access from Azure Portal + + )} + + + + )}

- +

Sign In with Azure Account diff --git a/src/Platform/Hosted/ConnectScreen.less b/src/Platform/Hosted/ConnectScreen.less index d4123f23f..2be2cf128 100644 --- a/src/Platform/Hosted/ConnectScreen.less +++ b/src/Platform/Hosted/ConnectScreen.less @@ -55,41 +55,19 @@ .connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder { font-style: italic; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip { - position: relative; - display: inline-block; - padding-left: 4px; - vertical-align: top; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar { + /* Matches the width of the connection string input so the form stays balanced. */ + width: 308px; + margin: 0px auto 8px auto; + text-align: left; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails { - visibility: visible; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails { + /* Service messages are diagnostics containing unbroken URIs and ids. */ + overflow-wrap: anywhere; } -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails { - bottom: 24px; - width: 165px; - visibility: hidden; - background-color: #393939; - color: #ffffff; - position: absolute; - z-index: 1; - left: -10px; - padding: 6px; -} -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after { - border-width: 10px 10px 0px 10px; - bottom: -8px; - content: ""; - position: absolute; - right: 100%; - border-style: solid; - left: 12px; - width: 0; - height: 0; - border-color: #3b3b3b transparent; -} -.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg { - height: 14px; - width: 14px; +.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink { + display: block; + margin-top: 8px; } .filterbtnstyle { diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts index 823ebb115..f1cb417ba 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts @@ -1,19 +1,75 @@ +import { configContext, updateConfigContext } from "../../../ConfigContext"; import * as DataModels from "../../../Contracts/DataModels"; -import { parseConnectionString } from "./ConnectionStringParser"; +import { + buildEndpointsRegex, + dnsZoneAlternation, + parseConnectionString, + selectEndpointZone, +} from "./ConnectionStringParser"; describe("ConnectionStringParser", () => { const mockAccountName = "Test"; const mockMasterKey = "some-key"; - it("should parse a valid sql account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`, - ); + // The shape of each api's connection string, parameterized by the dns zone the account sits in. What + // these tests are about is the zones, so keeping the shapes here stops them being restated once per zone. + const buildConnectionString = { + sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`, + mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`, + // A cassandra connection string can name the account host under either AccountEndpoint or HostName. + cassandra: (zone: string, hostKey = "AccountEndpoint") => + `${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`, + table: (zone: string) => + `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`, + graph: (zone: string) => + `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`, + }; - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + const connectionStringsByApiKind: Record = { + [DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"), + [DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"), + [DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"), + [DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"), + [DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"), + [DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"), + }; + + it("should parse a connection string for every api kind", () => { + Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => { + const metadata = parseConnectionString(connectionString); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(Number(apiKind)); + }); + }); + + // The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the + // suite rather than fail it. Pin the expected contents so that stays visible in review. + it("should support the expected dns zones", () => { + expect(configContext.SQL_DNS_ZONES).toEqual([ + "documents.azure.com", + "sql.cosmosdb.azure.com", + "sql.cosmos.azure.com", + "sqlx.cosmos.azure.com", + "documents-staging.windows-ppe.net", + "sql.cosmosdb.windows-ppe.net", + "sql.cosmos.windows-ppe.net", + "sqlx.cosmos.windows-ppe.net", + ]); + expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]); + expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]); + expect(configContext.CASSANDRA_DNS_ZONES).toEqual([ + "cassandra.cosmosdb.azure.com", + "cassandra.cosmos.azure.com", + "cassandra.cosmosdb.windows-ppe.net", + "cassandra.cosmos.windows-ppe.net", + ]); + expect(configContext.TABLE_DNS_ZONES).toEqual([ + "table.cosmosdb.azure.com", + "table.cosmos.azure.com", + "table.cosmosdb.windows-ppe.net", + "table.cosmos.windows-ppe.net", + ]); }); it("should keep the document endpoint given by the connection string", () => { @@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => { expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`); }); - it("should parse a valid mongo account connection string", () => { - const metadata = parseConnectionString( - `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`, - ); + it.each(configContext.SQL_DNS_ZONES)( + "should parse a sql account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.sql(dnsZone)); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB); - }); + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`); + expect(metadata.apiEndpoint).toBeUndefined(); + }, + ); - it("should parse a valid compute mongo account connection string", () => { - const metadata = parseConnectionString( - `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`, - ); + it.each(configContext.MONGO_DNS_ZONES)( + "should parse a mongo account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone)); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute); - }); + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB); + }, + ); + + it.each(configContext.MONGO_COMPUTE_DNS_ZONES)( + "should parse a compute mongo account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute); + }, + ); + + it.each( + ["AccountEndpoint", "HostName"].flatMap((hostKey) => + configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]), + ), + )( + "should parse a cassandra account connection string using %s and the %s zone", + (hostKey: string, dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); + }, + ); + + it.each(configContext.TABLE_DNS_ZONES)( + "should parse a table account connection string using the %s zone", + (dnsZone: string) => { + const metadata = parseConnectionString(buildConnectionString.table(dnsZone)); + + expect(metadata.accountName).toBe(mockAccountName); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); + expect(metadata.apiEndpoint).toBeUndefined(); + }, + ); + + // A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than + // giving the document endpoint, so the document endpoint that data plane operations go through is built + // from the account name, under a zone of the same kind as the one that matched. + const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`; + const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`; + const expectedDocumentEndpointByZone: Record = { + "documents.azure.com": publicDocumentEndpoint, + "documents-staging.windows-ppe.net": ppeDocumentEndpoint, + "mongo.cosmos.azure.com": publicDocumentEndpoint, + "mongo.cosmos.windows-ppe.net": ppeDocumentEndpoint, + "cassandra.cosmosdb.azure.com": publicDocumentEndpoint, + "cassandra.cosmos.azure.com": publicDocumentEndpoint, + "cassandra.cosmosdb.windows-ppe.net": ppeDocumentEndpoint, + "cassandra.cosmos.windows-ppe.net": ppeDocumentEndpoint, + "table.cosmosdb.azure.com": publicDocumentEndpoint, + "table.cosmos.azure.com": publicDocumentEndpoint, + "table.cosmosdb.windows-ppe.net": ppeDocumentEndpoint, + "table.cosmos.windows-ppe.net": ppeDocumentEndpoint, + }; + + it.each([ + ...configContext.MONGO_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]), + ...configContext.MONGO_COMPUTE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]), + ...configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.cassandra(dnsZone)]), + ...configContext.TABLE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.table(dnsZone)]), + ])( + "should construct the document endpoint for an account in the %s zone", + (dnsZone: string, connectionString: string) => { + const metadata = parseConnectionString(connectionString); + + expect(metadata.documentEndpoint).toBe(expectedDocumentEndpointByZone[dnsZone]); + }, + ); it("should parse a valid graph account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`, - ); + const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com")); expect(metadata.accountName).toBe(mockAccountName); expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); @@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => { expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`); }); - it("should parse a valid table account connection string", () => { - const metadata = parseConnectionString( - `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`, - ); + it("should construct a PPE gremlin endpoint for a PPE graph account", () => { + const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net")); expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); - // Table data operations go through the document endpoint, which is constructed from the account name. - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); + expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`); + // The constructed endpoint has to match the kind of zone the document endpoint we matched came from. + expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`); }); - it("should parse a valid table account connection string using the cosmos.azure.com zone", () => { - const metadata = parseConnectionString( - `DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`, - ); + it("should reject a connection string when no DNS zone matches the account", () => { + const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES; + updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] }); - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); - expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); - expect(metadata.apiEndpoint).toBeUndefined(); + try { + const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net")); + + // The account key travels to the constructed document endpoint, so a config carrying no PPE zone + // has to fail on a PPE account rather than fall back to a zone the account does not own. + expect(metadata).toBe(undefined); + } finally { + updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones }); + } }); - it("should parse a valid cassandra account connection string", () => { - const metadata = parseConnectionString( - `AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`, - ); - - expect(metadata.accountName).toBe(mockAccountName); - expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); + it.each([ + buildConnectionString.sql("documents.azure.com.attacker.example"), + buildConnectionString.mongo("documents.azure.com.attacker.example"), + buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"), + buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"), + buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"), + ])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => { + // The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that + // appends to an allowed zone must not pass as that zone. + expect(parseConnectionString(connectionString)).toBe(undefined); }); it("should fail to parse an invalid connection string", () => { @@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => { expect(metadata).toBe(undefined); }); + + describe("dnsZoneAlternation", () => { + it("should escape the dots in a zone", () => { + expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)"); + }); + + it("should join multiple zones into a single alternation", () => { + expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)"); + }); + + it("should not let the dots match arbitrary characters", () => { + // An unescaped dot would make the zone list match hosts that only resemble a real zone. + const regex = RegExp(dnsZoneAlternation(["documents.azure.com"])); + + expect(regex.test("documents.azure.com")).toBe(true); + expect(regex.test("documentsXazure.com")).toBe(false); + }); + + it("should capture the zone that matched", () => { + const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"])); + + expect("account.b.test".match(regex)[1]).toBe("b.test"); + }); + + it("should require the zone to run to the end of the host", () => { + const regex = RegExp(dnsZoneAlternation(["documents.azure.com"])); + + expect(regex.test("account.documents.azure.com")).toBe(true); + expect(regex.test("account.documents.azure.com:443/")).toBe(true); + expect(regex.test("account.documents.azure.com/")).toBe(true); + // Without this the zone list stops being an allowlist, since anything can be appended to a zone. + expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false); + }); + }); + + describe("buildEndpointsRegex", () => { + it("should build a pattern for every api matched by dns zone", () => { + expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]); + }); + + it("should build a cassandra pattern for each supported key", () => { + const { cassandra } = buildEndpointsRegex(); + + expect(cassandra).toHaveLength(2); + expect(cassandra[0]).toContain("AccountEndpoint="); + expect(cassandra[1]).toContain("HostName="); + }); + + it("should build each pattern from its own zone list", () => { + // The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in + // review and would let an account of one api be parsed as another. + const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex(); + + expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES)); + expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES)); + expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)); + cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES))); + expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES)); + }); + }); + + describe("selectEndpointZone", () => { + const nonPpeZone = "documents.azure.com"; + const ppeZone = "documents-staging.windows-ppe.net"; + + it("should pick the ppe zone for a ppe account", () => { + expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone); + }); + + it("should pick the non ppe zone for a non ppe account", () => { + expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone); + }); + + it("should not depend on the order of the zones", () => { + expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone); + expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone); + }); + + it("should return undefined when no zone matches the kind of account", () => { + // Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone. + expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined(); + expect(selectEndpointZone([ppeZone], false)).toBeUndefined(); + }); + + it("should return undefined for an empty zone list", () => { + expect(selectEndpointZone([], false)).toBeUndefined(); + }); + }); }); diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 44be7e82a..9730052ea 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -1,44 +1,80 @@ -import * as Constants from "../../../Common/Constants"; +import { configContext } from "../../../ConfigContext"; import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; -// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's -// accessinputmetadata API constructs from the account name when the connection string does not already -// contain the endpoint. -const DocumentEndpointZone = "documents.azure.com"; -const GremlinEndpointZone = "gremlin.cosmos.azure.com"; +const PpeDnsSuffix = "windows-ppe.net"; const DnsPort = "443"; +// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone. +const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`); + +// Picks the DNS zone matching the kind of account the connection string came from, since a PPE +// account's endpoints sit under PPE zones and every other account's do not. +export const selectEndpointZone = (zones: ReadonlyArray, isPpeAccount: boolean): string | undefined => + zones.find((zone) => isPpeZone(zone) === isPpeAccount); + +// Builds an alternation matching any of the given DNS zones, e.g. "(documents\.azure\.com|sql\.cosmos\.azure\.com)". +// The group captures so callers can tell which zone matched, and with it whether the account is a PPE account. +// The zone has to run to the end of the host, otherwise a host that merely starts with an allowed zone +// would pass as that zone and the account key would travel to whatever was appended to it. Zones come +// from config, so every regex metacharacter is escaped rather than just the dots. +export const dnsZoneAlternation = (zones: ReadonlyArray): string => + `(${zones.map((zone) => zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?=[:/\\s]|$)`; + +// The zone lists live in ConfigContext, which is populated asynchronously by initializeConfiguration, +// so these are built per call rather than once at module load. +export const buildEndpointsRegex = () => ({ + sql: `AccountEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.SQL_DNS_ZONES)}`, + mongo: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_DNS_ZONES)}`, + mongoCompute: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)}`, + cassandra: ["AccountEndpoint", "HostName"].map( + (key) => `${key}=([^.]+)\\.${dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)}`, + ), + table: `TableEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.TABLE_DNS_ZONES)}`, +}); + export function parseConnectionString(connectionString: string): AccessInputMetadata { if (connectionString) { try { const accessInput = {} as AccessInputMetadata; const connectionStringParts = connectionString.split(";"); + const endpointsRegex = buildEndpointsRegex(); + // Endpoints we build from the account name have to match the kind of zone the connection string + // actually matched, since PPE accounts and other accounts do not share zones. + let isPpeAccount = false; connectionStringParts.forEach((connectionStringPart: string) => { - if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1]; + if (RegExp(endpointsRegex.sql).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.sql); + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.SQL; // SQL and Gremlin connection strings carry the account's document endpoint, so take it as // given instead of rebuilding it from the account name. accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1); - } else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo); + isPpeAccount = isPpeZone(matches[2]); + } else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.mongo); accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.apiKind = ApiKind.MongoDB; - } else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) { - const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); + isPpeAccount = isPpeZone(matches[3]); + } else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute); accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.apiKind = ApiKind.MongoDBCompute; - } else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) { - Constants.EndpointsRegex.cassandra.forEach((regex) => { - if (RegExp(regex).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(regex)[1]; + isPpeAccount = isPpeZone(matches[3]); + } else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) { + endpointsRegex.cassandra.forEach((regex) => { + const matches: string[] = connectionStringPart.match(regex); + if (matches) { + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.Cassandra; + isPpeAccount = isPpeZone(matches[2]); } }); - } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { - accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; + } else if (RegExp(endpointsRegex.table).test(connectionStringPart)) { + const matches: string[] = connectionStringPart.match(endpointsRegex.table); + accessInput.accountName = matches[1]; accessInput.apiKind = ApiKind.Table; + isPpeAccount = isPpeZone(matches[2]); } else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) { accessInput.apiKind = ApiKind.Graph; } @@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta return undefined; } - // Table connection strings only carry the table endpoint, so the document endpoint that data plane - // operations go through has to be derived from the account name. Gremlin accounts additionally - // need the Gremlin endpoint, which is never part of the connection string. + // A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather + // than giving the document endpoint, so the document endpoint that data plane operations go through + // has to be built from the account name. SQL and Gremlin strings carry it and take it as given. + // Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string. if (accessInput.accountName) { - if (accessInput.apiKind === ApiKind.Table) { - accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`; + if ( + accessInput.apiKind === ApiKind.Table || + accessInput.apiKind === ApiKind.MongoDB || + accessInput.apiKind === ApiKind.MongoDBCompute || + accessInput.apiKind === ApiKind.Cassandra + ) { + const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount); + if (!documentEndpointZone) { + return undefined; + } + accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`; } else if (accessInput.apiKind === ApiKind.Graph) { - accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`; + const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount); + if (!gremlinEndpointZone) { + return undefined; + } + accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`; } } diff --git a/src/Utils/NotebookConfigurationUtils.ts b/src/Utils/NotebookConfigurationUtils.ts deleted file mode 100644 index 31bfccfdd..000000000 --- a/src/Utils/NotebookConfigurationUtils.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { getErrorMessage } from "../Common/ErrorHandlingUtils"; -import * as Logger from "../Common/Logger"; -import * as DataModels from "../Contracts/DataModels"; - -interface KernelConnectionMetadata { - name: string; - configurationEndpoints: DataModels.NotebookConfigurationEndpoints; - notebookConnectionInfo: DataModels.NotebookWorkspaceConnectionInfo; -} - -export const _configureServiceEndpoints = async (kernelMetadata: KernelConnectionMetadata): Promise => { - if (!kernelMetadata) { - // should never get into this state - Logger.logWarning("kernel metadata is null or undefined", "NotebookConfigurationUtils/configureServiceEndpoints"); - return; - } - - const notebookConnectionInfo = kernelMetadata.notebookConnectionInfo; - const configurationEndpoints = kernelMetadata.configurationEndpoints; - if (notebookConnectionInfo && configurationEndpoints) { - try { - const headers: HeadersInit = { "Content-Type": "application/json" }; - if (notebookConnectionInfo.authToken) { - headers["Authorization"] = `token ${notebookConnectionInfo.authToken}`; - } - const response = await fetch(`${notebookConnectionInfo.notebookServerEndpoint}/api/configureEndpoints`, { - method: "POST", - headers, - body: JSON.stringify(configurationEndpoints), - }); - if (!response.ok) { - const responseMessage = await response.json(); - Logger.logError( - getErrorMessage(responseMessage), - "NotebookConfigurationUtils/configureServiceEndpoints", - response.status, - ); - } - } catch (error) { - Logger.logError(getErrorMessage(error), "NotebookConfigurationUtils/configureServiceEndpoints"); - } - } -}; - -export const configureServiceEndpoints = async ( - notebookPath: string, - notebookConnectionInfo: DataModels.NotebookWorkspaceConnectionInfo, - kernelName: string, - clusterConnectionInfo: DataModels.SparkClusterConnectionInfo, -): Promise => { - if (!notebookPath || !notebookConnectionInfo || !kernelName) { - Logger.logError( - "Invalid or missing notebook connection info/path", - "NotebookConfigurationUtils/configureServiceEndpoints", - ); - return Promise.reject("Invalid or missing notebook connection info"); - } - - if (!clusterConnectionInfo || !clusterConnectionInfo.endpoints || clusterConnectionInfo.endpoints.length === 0) { - Logger.logError( - "Invalid or missing cluster connection info/endpoints", - "NotebookConfigurationUtils/configureServiceEndpoints", - ); - return Promise.reject("Invalid or missing cluster connection info"); - } - - const notebookEndpointInfo: DataModels.NotebookConfigurationEndpointInfo[] = clusterConnectionInfo.endpoints.map( - (clusterEndpoint) => ({ - type: clusterEndpoint.kind.toLowerCase(), - endpoint: clusterEndpoint && clusterEndpoint.endpoint, - username: clusterConnectionInfo.userName, - password: clusterConnectionInfo.password, - token: "", // TODO. This was arcadiaToken() when our synapse/spark integration comes back - }), - ); - const configurationEndpoints: DataModels.NotebookConfigurationEndpoints = { - path: notebookPath, - endpoints: notebookEndpointInfo, - }; - const kernelMetadata: KernelConnectionMetadata = { - configurationEndpoints, - notebookConnectionInfo, - name: kernelName, - }; - - return await _configureServiceEndpoints(kernelMetadata); -}; diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index 41bec9797..8b8c63a3a 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -83,12 +83,6 @@ export function useKnockoutExplorer(platform: Platform): Explorer { useEffect(() => { const effect = async () => { if (platform) { - //Updating phoenix feature flags for MPAC based of config context - if (configContext.isPhoenixEnabled === true) { - userContext.features.phoenixNotebooks = true; - userContext.features.phoenixFeatures = true; - } - let explorer: Explorer; try { if (platform === Platform.Hosted) { @@ -1010,15 +1004,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) { if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) { userContext.features.partitionKeyDefault2 = true; } - if (inputs.flights.indexOf(Flights.PhoenixNotebooks) !== -1) { - userContext.features.phoenixNotebooks = true; - } - if (inputs.flights.indexOf(Flights.PhoenixFeatures) !== -1) { - userContext.features.phoenixFeatures = true; - } - if (inputs.flights.indexOf(Flights.NotebooksDownBanner) !== -1) { - userContext.features.notebooksDownBanner = true; - } } // Handle initial theme from portal diff --git a/src/hooks/useNotebookSnapshotStore.ts b/src/hooks/useNotebookSnapshotStore.ts deleted file mode 100644 index 841f6b5d1..000000000 --- a/src/hooks/useNotebookSnapshotStore.ts +++ /dev/null @@ -1,15 +0,0 @@ -import create, { UseStore } from "zustand"; - -export interface NotebookSnapshotHooks { - snapshot?: string; - error?: string; - setSnapshot: (imageSrc: string) => void; - setError: (error: string) => void; -} - -export const useNotebookSnapshotStore: UseStore = create((set) => ({ - snapshot: undefined, - error: undefined, - setSnapshot: (imageSrc: string) => set((state) => ({ ...state, snapshot: imageSrc })), - setError: (error: string) => set((state) => ({ ...state, error })), -})); diff --git a/src/hooks/useTabs.ts b/src/hooks/useTabs.ts index f276e1498..4444f5c8c 100644 --- a/src/hooks/useTabs.ts +++ b/src/hooks/useTabs.ts @@ -9,7 +9,6 @@ import { } from "Shared/AppStatePersistenceUtility"; import create, { UseStore } from "zustand"; import * as ViewModels from "../Contracts/ViewModels"; -import { CollectionTabKind } from "../Contracts/ViewModels"; import TabsBase from "../Explorer/Tabs/TabsBase"; export interface TabsState { @@ -27,7 +26,6 @@ export interface TabsState { refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void; closeTabsByComparator: (comparator: (tab: TabsBase) => boolean) => void; closeTab: (tab: TabsBase) => void; - closeAllNotebookTabs: (hardClose: boolean) => void; openAndActivateReactTab: (tabKind: ReactTabKind) => void; closeReactTab: (tabKind: ReactTabKind) => void; setIsTabExecuting: (state: boolean) => void; @@ -132,33 +130,6 @@ export const useTabs: UseStore = create((set, get) => ({ get().persistTabsState(); }, - closeAllNotebookTabs: (): void => { - const isNotebook = (tabKind: CollectionTabKind): boolean => { - if ( - tabKind === CollectionTabKind.Notebook || - tabKind === CollectionTabKind.NotebookV2 || - tabKind === CollectionTabKind.SchemaAnalyzer || - tabKind === CollectionTabKind.Terminal - ) { - return true; - } - return false; - }; - - const tabList = get().openedTabs; - if (tabList && tabList.length > 0) { - tabList.forEach((tab: TabsBase) => { - const tabKind: CollectionTabKind = tab.tabKind; - if (tabKind && isNotebook(tabKind)) { - tab.onCloseTabButtonClick(); - } - }); - - if (get().openedTabs.length === 0 && !isFabricMirrored()) { - set({ activeTab: undefined, activeReactTab: undefined }); - } - } - }, openAndActivateReactTab: (tabKind: ReactTabKind) => { if (get().openedReactTabs.indexOf(tabKind) === -1) { set((state) => ({ diff --git a/src/setupTests.ts b/src/setupTests.ts index 720a385d8..9d2e6dfb3 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow"; import { TextDecoder, TextEncoder } from "util"; import i18n from "./i18n"; import enResources from "./Localization/en/Resources.json"; + +jest.mock("web-vitals"); + configure({ adapter: new Adapter() }); initializeIcons(); diff --git a/test/cassandra/connectionStringLogin.spec.ts b/test/cassandra/connectionStringLogin.spec.ts new file mode 100644 index 000000000..f89254f15 --- /dev/null +++ b/test/cassandra/connectionStringLogin.spec.ts @@ -0,0 +1,170 @@ +import { Page, expect, test } from "@playwright/test"; + +import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; +import { + CommandBarButton, + DataExplorer, + ONE_MINUTE_MS, + TestAccount, + generateUniqueName, + getAccountName, + getAzureCLICredentials, + resourceGroupName, + subscriptionId, +} from "../fx"; + +const keyspaceId = generateUniqueName("keyspace"); +const tableId = "testtable"; + +async function loginWithConnectionString(page: Page, connectionString: string): Promise { + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString); + await page.getByRole("button", { name: "Connect" }).click(); +} + +// Builds a Cassandra connection string for an account that the caller controls the name and key of, so +// the tests can point at a wrong key or an account that was never provisioned. The key is embedded raw, +// the way Azure hands it out; URL encoding it would turn the base64 padding into %3D and fail to decode. +function buildCassandraConnectionString(accountName: string, accountKey: string): string { + return `HostName=${accountName}.cassandra.cosmos.azure.com;Username=${accountName};Password=${accountKey};Port=10350`; +} + +// Valid base64 that decodes to 64 zero bytes, matching the length of a real Cosmos account key so the +// request is rejected for being the wrong key rather than for being unparsable. +const wrongAccountKey = "A".repeat(86) + "=="; + +test.describe("Cassandra account using connection string login", () => { + let armClient: CosmosDBManagementClient = null!; + let accountName: string = null!; + // Cassandra exchanges the connection string for an encrypted token at the Portal Backend rather than + // signing data-plane requests client-side, so every failure below surfaces as a backend rejection. + let connectionString: string = null!; + + test.beforeAll("Seed Test Keyspace", async () => { + const credentials = getAzureCLICredentials(); + armClient = new CosmosDBManagementClient(credentials, subscriptionId); + accountName = getAccountName(TestAccount.Cassandra); + + const { connectionStrings = [] } = await armClient.databaseAccounts.listConnectionStrings( + resourceGroupName, + accountName, + ); + + const cassandraConnectionString = connectionStrings.find((cs) => cs.type === "Cassandra")?.connectionString; + if (!cassandraConnectionString) { + throw new Error(`Account ${accountName} did not return a Cassandra connection string`); + } + connectionString = cassandraConnectionString; + + await armClient.cassandraResources.beginCreateUpdateCassandraKeyspaceAndWait( + resourceGroupName, + accountName, + keyspaceId, + { resource: { id: keyspaceId } }, + ); + await armClient.cassandraResources.beginCreateUpdateCassandraTableAndWait( + resourceGroupName, + accountName, + keyspaceId, + tableId, + { + resource: { + id: tableId, + schema: { + columns: [ + { name: "id", type: "text" }, + { name: "value", type: "text" }, + ], + partitionKeys: [{ name: "id" }], + }, + }, + }, + ); + }); + + test.afterAll("Delete Test Keyspace", async () => { + await armClient?.cassandraResources.beginDeleteCassandraKeyspaceAndWait(resourceGroupName, accountName, keyspaceId); + }); + + test("creates a row after connection string login", async ({ page }) => { + await loginWithConnectionString(page, connectionString); + + const explorer = await DataExplorer.waitForExplorer(page); + const tableNode = await explorer.waitForContainerNode(keyspaceId, tableId); + await expect(tableNode.element).toBeAttached(); + await tableNode.expand(); + + const rowsNode = await explorer.waitForNode(`${keyspaceId}/${tableId}/Rows`); + await rowsNode.element.click(); + + const resultsGrid = explorer.frame.getByRole("table", { name: `Results for container ${tableId}`, exact: true }); + await expect(resultsGrid.getByRole("columnheader", { name: "id", exact: true })).toBeAttached({ + timeout: ONE_MINUTE_MS, + }); + + const addRowButton = await explorer.waitForCommandBarButton(CommandBarButton.AddRow, ONE_MINUTE_MS); + await addRowButton.click(); + + const rowId = `${Date.now()}-connstring`; + const panel = explorer.panel("Add Table Row"); + await panel.waitFor(); + await panel.getByLabel("Edit Property id Value").fill(rowId); + await panel.getByLabel("Edit Property value Value").fill("connection string login"); + await panel.getByTestId("Panel/OkButton").click(); + + await expect(explorer.tab("tab0").getByText(rowId, { exact: true }).nth(0)).toBeVisible({ + timeout: ONE_MINUTE_MS, + }); + }); + + test("exchanges the connection string for an encrypted token", async ({ page }) => { + // The counterpart of the SQL test that asserts the Portal Backend is never called: Cassandra has no + // client-side signing path, so the token endpoint has to be hit for login to work at all. + const tokenCalls: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/connectionstring/token/generatetoken")) { + tokenCalls.push(request.url()); + } + }); + + await loginWithConnectionString(page, connectionString); + + await DataExplorer.waitForExplorer(page); + expect(tokenCalls.length).toBeGreaterThan(0); + }); + + test("shows an error when the account key is wrong", async ({ page }) => { + // Unlike SQL, the Portal Backend validates the key against the account before issuing a token, so + // login stops at the connect form. + await loginWithConnectionString(page, buildCassandraConnectionString(accountName, wrongAccountKey)); + + await expect(page.locator("#connectExplorer")).toBeVisible(); + await expect(page.locator(".errorDetails")).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(page.locator(".errorDetails")).not.toBeEmpty(); + }); + + test("shows an error when the connection string is malformed", async ({ page }) => { + await loginWithConnectionString(page, "this-is-not-a-connection-string"); + + await expect(page.locator("#connectExplorer")).toBeVisible(); + await expect(page.locator(".errorDetails")).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(page.locator(".errorDetails")).not.toBeEmpty(); + }); + + test("opens Data Explorer when the account does not exist", async ({ page }) => { + // The UTC timestamp keeps the account name unique to this run, so it cannot collide with a real + // account that someone provisioned in the meantime. The token is issued without checking that the + // account exists, so login succeeds and only the requests made from inside the explorer fail. + const missingAccountName = `de-test-missing-${new Date().toISOString().replace(/[^0-9]/g, "")}`; + await loginWithConnectionString(page, buildCassandraConnectionString(missingAccountName, wrongAccountKey)); + + await DataExplorer.waitForExplorer(page); + + // The connect form is replaced by the explorer rather than staying up with a login error. + await expect(page.locator("#connectExplorer")).toHaveCount(0); + await expect(page.locator(".errorDetails")).toHaveCount(0); + }); +}); diff --git a/test/fx.ts b/test/fx.ts index a3cad859a..f42be798a 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -49,6 +49,7 @@ export enum TestAccount { SQLReadOnly = "SQLReadOnly", SQLContainerCopyOnly = "SQLContainerCopyOnly", SQLConnectionString = "SQLConnectionString", + SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled", TableConnectionString = "TableConnectionString", GremlinConnectionString = "GremlinConnectionString", } @@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string { return `${accountNamePrefix}-de-test-sql-containercopy`; case TestAccount.SQLConnectionString: return `${accountNamePrefix}-de-test-sql-connstring-1`; + case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled: + return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`; case TestAccount.TableConnectionString: return `${accountNamePrefix}-de-test-table-connstring-1`; case TestAccount.GremlinConnectionString: @@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes break; case TestAccount.SQLConnectionString: + case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled: case TestAccount.TableConnectionString: case TestAccount.GremlinConnectionString: // Connection string (account key) login navigates directly to hostedExplorer.html and doesn't @@ -470,6 +474,7 @@ export enum CommandBarButton { ExecuteQuery = "Execute Query", UploadItem = "Upload Item", NewDocument = "New Document", + AddRow = "Add Row", View = "View", } diff --git a/test/mongo/connectionStringLogin.spec.ts b/test/mongo/connectionStringLogin.spec.ts new file mode 100644 index 000000000..a49b29734 --- /dev/null +++ b/test/mongo/connectionStringLogin.spec.ts @@ -0,0 +1,167 @@ +import { Page, expect, test } from "@playwright/test"; + +import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; +import { + CommandBarButton, + DataExplorer, + ONE_MINUTE_MS, + TestAccount, + generateUniqueName, + getAccountName, + getAzureCLICredentials, + resourceGroupName, + subscriptionId, +} from "../fx"; + +const databaseId = generateUniqueName("db"); +const collectionId = "testcollection"; + +async function loginWithConnectionString(page: Page, connectionString: string): Promise { + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString); + await page.getByRole("button", { name: "Connect" }).click(); +} + +// Builds a Mongo connection string for an account that the caller controls the name and key of, so the +// tests can point at a wrong key or an account that was never provisioned. The key is embedded raw, the +// way Azure hands it out; URL encoding it would turn the base64 padding into %3D and fail to decode. +function buildMongoConnectionString(accountName: string, accountKey: string): string { + return `mongodb://${accountName}:${accountKey}@${accountName}.mongo.cosmos.azure.com:10255/?ssl=true&replicaSet=globaldb&retrywrites=false&appName=@${accountName}@`; +} + +// Valid base64 that decodes to 64 zero bytes, matching the length of a real Cosmos account key so the +// request is rejected for being the wrong key rather than for being unparsable. +const wrongAccountKey = "A".repeat(86) + "=="; + +test.describe("Mongo account using connection string login", () => { + let armClient: CosmosDBManagementClient = null!; + let accountName: string = null!; + // Mongo exchanges the connection string for an encrypted token at the Portal Backend rather than + // signing data-plane requests client-side, so every failure below surfaces as a backend rejection. + let connectionString: string = null!; + + test.beforeAll("Seed Test Database", async () => { + const credentials = getAzureCLICredentials(); + armClient = new CosmosDBManagementClient(credentials, subscriptionId); + accountName = getAccountName(TestAccount.Mongo); + + const { connectionStrings = [] } = await armClient.databaseAccounts.listConnectionStrings( + resourceGroupName, + accountName, + ); + const mongoConnectionString = connectionStrings.find((cs) => cs.type === "MongoDB")?.connectionString; + if (!mongoConnectionString) { + throw new Error(`Account ${accountName} did not return a MongoDB connection string`); + } + connectionString = mongoConnectionString; + + await armClient.mongoDBResources.beginCreateUpdateMongoDBDatabaseAndWait( + resourceGroupName, + accountName, + databaseId, + { + resource: { id: databaseId }, + }, + ); + await armClient.mongoDBResources.beginCreateUpdateMongoDBCollectionAndWait( + resourceGroupName, + accountName, + databaseId, + collectionId, + { resource: { id: collectionId, shardKey: { pk: "Hash" } } }, + ); + }); + + test.afterAll("Delete Test Database", async () => { + await armClient?.mongoDBResources.beginDeleteMongoDBDatabaseAndWait(resourceGroupName, accountName, databaseId); + }); + + test("creates a document after connection string login", async ({ page }) => { + await loginWithConnectionString(page, connectionString); + + const explorer = await DataExplorer.waitForExplorer(page); + + const collectionNode = await explorer.waitForContainerNode(databaseId, collectionId); + await expect(collectionNode.element).toBeAttached(); + await collectionNode.expand(); + + // Open the Documents node to load the tab and exercise the data plane, which for Mongo runs through + // the Portal Backend proxy using the encrypted token issued at login. + const documentsNode = await explorer.waitForContainerDocumentsNode(databaseId, collectionId); + await documentsNode.element.click(); + + const documentsTab = explorer.documentsTab("tab0"); + await documentsTab.documentsFilter.waitFor(); + await documentsTab.documentsListPane.waitFor(); + + // The results editor only mounts once a document is selected, so on this freshly created empty + // collection it cannot be waited on until New Document is clicked. + const documentId = `${Date.now()}-connstring`; + const newDocumentButton = await explorer.waitForCommandBarButton(CommandBarButton.NewDocument, ONE_MINUTE_MS); + await expect(newDocumentButton).toBeEnabled(); + await newDocumentButton.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: ONE_MINUTE_MS }); + + // pk matches the shard key the collection was created with. + await documentsTab.resultsEditor.setText(JSON.stringify({ _id: documentId, pk: documentId })); + + const saveButton = await explorer.waitForCommandBarButton(CommandBarButton.Save, ONE_MINUTE_MS); + await saveButton.click(); + await expect(saveButton).toBeHidden({ timeout: ONE_MINUTE_MS }); + + await expect(documentsTab.documentsListPane.getByText(documentId, { exact: true }).nth(0)).toBeVisible({ + timeout: ONE_MINUTE_MS, + }); + }); + + test("exchanges the connection string for an encrypted token", async ({ page }) => { + // The counterpart of the SQL test that asserts the Portal Backend is never called: Mongo has no + // client-side signing path, so the token endpoint has to be hit for login to work at all. + const tokenCalls: string[] = []; + page.on("request", (request) => { + if (request.url().includes("/api/connectionstring/token/generatetoken")) { + tokenCalls.push(request.url()); + } + }); + + await loginWithConnectionString(page, connectionString); + + await DataExplorer.waitForExplorer(page); + expect(tokenCalls.length).toBeGreaterThan(0); + }); + + test("shows an error when the account key is wrong", async ({ page }) => { + // Unlike SQL, the Portal Backend validates the key against the account before issuing a token, so + // login stops at the connect form. + await loginWithConnectionString(page, buildMongoConnectionString(accountName, wrongAccountKey)); + + await expect(page.locator("#connectExplorer")).toBeVisible(); + await expect(page.locator(".errorDetails")).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(page.locator(".errorDetails")).not.toBeEmpty(); + }); + + test("shows an error when the connection string is malformed", async ({ page }) => { + await loginWithConnectionString(page, "this-is-not-a-connection-string"); + + await expect(page.locator("#connectExplorer")).toBeVisible(); + await expect(page.locator(".errorDetails")).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(page.locator(".errorDetails")).not.toBeEmpty(); + }); + + test("opens Data Explorer when the account does not exist", async ({ page }) => { + // The UTC timestamp keeps the account name unique to this run, so it cannot collide with a real + // account that someone provisioned in the meantime. The token is issued without checking that the + // account exists, so login succeeds and only the requests made from inside Data Explorer fail. + const missingAccountName = `de-test-missing-${new Date().toISOString().replace(/[^0-9]/g, "")}`; + await loginWithConnectionString(page, buildMongoConnectionString(missingAccountName, wrongAccountKey)); + + await DataExplorer.waitForExplorer(page); + + // The connect form is replaced by the explorer rather than staying up with a login error. + await expect(page.locator("#connectExplorer")).toHaveCount(0); + await expect(page.locator(".errorDetails")).toHaveCount(0); + }); +}); diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts index 8359e6dee..88293bd64 100644 --- a/test/sql/connectionStringLogin.spec.ts +++ b/test/sql/connectionStringLogin.spec.ts @@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => { await expect(page.locator("#connectExplorer")).toHaveCount(0); await expect(page.locator(".errorDetails")).toHaveCount(0); }); + + test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => { + // An account that refuses this client's IP. + const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId); + const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled); + const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName); + const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName); + + await loginWithConnectionString( + page, + `AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`, + ); + + const explorer = await DataExplorer.waitForExplorer(page); + + // Login is a client-side parse of the connection string, so nothing checks whether the account will + // accept requests from this IP before letting the user in. + await expect(page.locator("#connectExplorer")).toHaveCount(0); + await expect(page.locator(".errorDetails")).toHaveCount(0); + + // The rejection surfaces once the tree tries to read the data plane, and only in the console. + const consoleMessages = await explorer.getNotificationConsoleMessages(); + await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS }); + + // The tree is left with the static Home node and no database or container beneath it. + await expect(explorer.treeNode("Home").element).toBeAttached(); + await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1); + }); }); diff --git a/tsconfig.strict.json b/tsconfig.strict.json index 947b073e5..5e663f7bf 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -44,7 +44,6 @@ "./src/Explorer/Graph/GraphExplorerComponent/GraphData.ts", "./src/Explorer/LazyMonaco.ts", "./src/Explorer/Menus/NotificationConsole/ConsoleData.tsx", - "./src/Explorer/Notebook/NotebookContentItem.ts", "./src/Explorer/OpenFullScreen.test.tsx", "./src/Explorer/OpenFullScreen.tsx", "./src/Explorer/Panes/PanelContainerComponent.test.tsx", @@ -107,7 +106,6 @@ "./src/hooks/useConfig.ts", "./src/hooks/useDirectories.tsx", "./src/hooks/useGraphPhoto.tsx", - "./src/hooks/useNotebookSnapshotStore.ts", "./src/hooks/useNotificationConsole.ts", "./src/hooks/useObservable.ts", "./src/hooks/useSidePanel.ts",