Merge remote-tracking branch 'origin/users/aisayas/sql-connection-string-login-zones' into users/aisayas/sql-connectionstring-login

This commit is contained in:
Asier Isayas
2026-08-27 13:06:03 -04:00
56 changed files with 1182 additions and 2791 deletions
+15 -15
View File
@@ -20,10 +20,10 @@ jobs:
if: github.ref == 'refs/heads/master' if: github.ref == 'refs/heads/master'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: node utils/codeMetrics.js - run: node utils/codeMetrics.js
env: env:
@@ -33,10 +33,10 @@ jobs:
name: "Compile TypeScript" name: "Compile TypeScript"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npm run compile - run: npm run compile
- run: npm run compile:strict - run: npm run compile:strict
@@ -45,10 +45,10 @@ jobs:
name: "Check Format" name: "Check Format"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npm run format:check - run: npm run format:check
lint: lint:
@@ -56,10 +56,10 @@ jobs:
name: "Lint" name: "Lint"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npm run lint - run: npm run lint
unittest: unittest:
@@ -67,10 +67,10 @@ jobs:
name: "Unit Tests" name: "Unit Tests"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npm run test - run: npm run test
build: build:
@@ -78,10 +78,10 @@ jobs:
name: "Build" name: "Build"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npm run build:contracts - run: npm run build:contracts
- name: Restore Build Cache - name: Restore Build Cache
@@ -173,10 +173,10 @@ jobs:
shardTotal: [20] shardTotal: [20]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js 18.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 18.x node-version: 22.x
- run: npm ci - run: npm ci
- run: npx playwright install --with-deps - run: npx playwright install --with-deps
- name: "Az CLI login" - name: "Az CLI login"
@@ -265,7 +265,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 18 node-version: 22.x
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
+2 -2
View File
@@ -31,9 +31,9 @@ jobs:
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }} tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }} subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
- name: Use Node.js 20.x - name: Use Node.js 22.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 22.x
- run: npm ci - run: npm ci
- run: node utils/cleanupDBs.js - run: node utils/cleanupDBs.js
+1
View File
@@ -6,6 +6,7 @@ UI for Azure Cosmos DB. Powers the [Azure Portal](https://portal.azure.com/), ht
## Getting Started ## Getting Started
- Install Node.js 22.x.
- `npm install` - `npm install`
- `npm run build` - `npm run build`
+26 -5
View File
@@ -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 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, retired. The goal is to remove all notebooks/Phoenix/Juno/GitHub-for-notebooks code,
dependencies, UI surfaces, telemetry, and configuration, while **preserving the 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. share the Terminal infrastructure with notebooks.
This document is the implementation plan only. No code changes are made here. This document is the implementation plan only. No code changes are made here.
## Scope decisions (confirmed) ## 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 them to use the **CloudShell** path exclusively. Remove the legacy Phoenix
notebook-server shell path. notebook-server shell path.
- **GitHub integration**: Remove entirely (it exists only for notebook pinned repos). - **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 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 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`, 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. still open.
### Phase 1 — Decouple database shells to CloudShell-only ✅ COMPLETED ### 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. pinned-repo methods.
- Remove GitHub-related localization keys from **all** locale files (`en` + non-English). - 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`, - Delete `src/Phoenix/`, `src/Explorer/Notebook/NotebookContainerClient.ts`,
`src/Explorer/Notebook/NotebookManager.tsx`, `src/Explorer/Notebook/useNotebook.ts`, `src/Explorer/Notebook/NotebookManager.tsx`, `src/Explorer/Notebook/useNotebook.ts`,
`src/Explorer/Notebook/NotebookContentItem.ts`, `src/Explorer/Notebook/NotebookUtil.ts` `src/Explorer/Notebook/NotebookContentItem.ts`, `src/Explorer/Notebook/NotebookUtil.ts`
@@ -279,7 +300,7 @@ npm run format:check
npm test npm test
npm run build:ci 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 ## Notes & considerations
+31 -540
View File
@@ -19,11 +19,7 @@
"@fluentui/react": "8.119.0", "@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2", "@fluentui/react-components": "9.54.2",
"@microsoft/applicationinsights-web": "2.6.1", "@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/markdown": "4.6.0",
"@nteract/myths": "0.1.9",
"@octokit/request": "8.4.1", "@octokit/request": "8.4.1",
"@octokit/rest": "17.9.2", "@octokit/rest": "17.9.2",
"@testing-library/jest-dom": "6.4.6", "@testing-library/jest-dom": "6.4.6",
@@ -92,7 +88,6 @@
"react-window": "1.8.10", "react-window": "1.8.10",
"react-youtube": "9.0.1", "react-youtube": "9.0.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rx-jupyter": "5.5.12",
"shell-quote": "1.10.0", "shell-quote": "1.10.0",
"styled-components": "5.0.1", "styled-components": "5.0.1",
"swr": "0.4.0", "swr": "0.4.0",
@@ -102,7 +97,7 @@
"utility-types": "3.10.0", "utility-types": "3.10.0",
"uuid": "9.0.0", "uuid": "9.0.0",
"web-vitals": "4.2.4", "web-vitals": "4.2.4",
"ws": "8.20.1", "ws": "8.21.3",
"xterm": "4.19.0", "xterm": "4.19.0",
"xterm-addon-fit": "0.5.0", "xterm-addon-fit": "0.5.0",
"zustand": "3.5.0" "zustand": "3.5.0"
@@ -126,7 +121,7 @@
"@types/hasher": "0.0.31", "@types/hasher": "0.0.31",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/jquery": "3.5.29", "@types/jquery": "3.5.29",
"@types/node": "18.19.0", "@types/node": "22.14.1",
"@types/post-robot": "10.0.1", "@types/post-robot": "10.0.1",
"@types/q": "1.5.1", "@types/q": "1.5.1",
"@types/react": "17.0.44", "@types/react": "17.0.44",
@@ -139,6 +134,7 @@
"@types/sinon": "2.3.3", "@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32", "@types/styled-components": "5.1.32",
"@types/underscore": "1.7.36", "@types/underscore": "1.7.36",
"@types/uuid": "9.0.8",
"@types/youtube-player": "5.5.6", "@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4", "@typescript-eslint/parser": "6.7.4",
@@ -196,7 +192,10 @@
"webpack-bundle-analyzer": "5.2.0", "webpack-bundle-analyzer": "5.2.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.5", "webpack-dev-server": "5.2.5",
"ws": "8.20.1" "ws": "8.21.3"
},
"engines": {
"node": "22.x"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@@ -6396,154 +6395,6 @@
"node": ">= 8" "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": { "node_modules/@nteract/markdown": {
"version": "4.6.0", "version": "4.6.0",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@@ -6568,184 +6419,6 @@
"react-dom": "^16.13.0 || ^17.0.0" "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": { "node_modules/@nteract/presentational-components": {
"version": "3.4.12", "version": "3.4.12",
"resolved": "https://registry.npmjs.org/@nteract/presentational-components/-/presentational-components-3.4.12.tgz", "resolved": "https://registry.npmjs.org/@nteract/presentational-components/-/presentational-components-3.4.12.tgz",
@@ -6763,71 +6436,6 @@
"styled-components": ">= 5.0.1" "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": { "node_modules/@octokit/auth-token": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", "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": { "node_modules/@types/hoist-non-react-statics": {
"version": "3.3.5", "version": "3.3.5",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/react": "*", "@types/react": "*",
@@ -8482,11 +8091,12 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "18.19.0", "version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-667KNhaD7U29mT5wf+TZUnrzPrlL2GNQ5N0BMjO2oNULhBxX0/FKCkm6JMu0Jh7Z+1LwUlR21ekd7KhIboNFNw==", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~5.26.4" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-fetch": { "node_modules/@types/node-fetch": {
@@ -8555,6 +8165,7 @@
}, },
"node_modules/@types/react-redux": { "node_modules/@types/react-redux": {
"version": "7.1.7", "version": "7.1.7",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/hoist-non-react-statics": "^3.3.0", "@types/hoist-non-react-statics": "^3.3.0",
@@ -8678,16 +8289,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ungap__url-search-params": {
"version": "0.1.2",
"license": "MIT"
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "2.0.10", "version": "2.0.10",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": { "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" "license": "MIT"
}, },
"node_modules/@types/ws": { "node_modules/@types/ws": {
@@ -9001,10 +8611,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}, },
"node_modules/@ungap/url-search-params": {
"version": "0.2.2",
"license": "ISC"
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -12093,6 +11699,7 @@
}, },
"node_modules/debounce": { "node_modules/debounce": {
"version": "1.2.1", "version": "1.2.1",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -12148,10 +11755,6 @@
} }
} }
}, },
"node_modules/deep-diff": {
"version": "0.3.8",
"license": "MIT"
},
"node_modules/deep-equal": { "node_modules/deep-equal": {
"version": "1.1.2", "version": "1.1.2",
"license": "MIT", "license": "MIT",
@@ -13012,10 +12615,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-carriage": {
"version": "1.3.1",
"license": "MIT"
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"dev": true, "dev": true,
@@ -14021,10 +13620,6 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/file-saver": {
"version": "2.0.5",
"license": "MIT"
},
"node_modules/filesize": { "node_modules/filesize": {
"version": "8.0.7", "version": "8.0.7",
"dev": true, "dev": true,
@@ -14033,13 +13628,6 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/filewatcher": {
"version": "3.0.1",
"license": "MIT",
"dependencies": {
"debounce": "^1.0.0"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -14489,27 +14077,6 @@
"dev": true, "dev": true,
"license": "Unlicense" "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": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"
@@ -14518,6 +14085,7 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@@ -15424,10 +14992,11 @@
} }
}, },
"node_modules/http-proxy-middleware": { "node_modules/http-proxy-middleware": {
"version": "2.0.9", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/http-proxy": "^1.17.8", "@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
@@ -20658,10 +20227,6 @@
"jquery": ">=1.8.0 <4.0.0" "jquery": ">=1.8.0 <4.0.0"
} }
}, },
"node_modules/js-cookie": {
"version": "2.2.1",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"license": "MIT" "license": "MIT"
@@ -21089,10 +20654,6 @@
"version": "4.0.3", "version": "4.0.3",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"license": "MIT"
},
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"license": "MIT" "license": "MIT"
@@ -21107,10 +20668,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.has": {
"version": "4.5.2",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -23756,42 +23313,6 @@
"@babel/runtime": "^7.9.2" "@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": { "node_modules/reflect-metadata": {
"version": "0.1.13", "version": "0.1.13",
"license": "Apache-2.0" "license": "Apache-2.0"
@@ -24086,10 +23607,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/reselect": {
"version": "4.1.8",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"devOptional": true, "devOptional": true,
@@ -24232,29 +23749,6 @@
"version": "1.3.3", "version": "1.3.3",
"license": "BSD-3-Clause" "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": { "node_modules/safe-array-concat": {
"version": "1.1.2", "version": "1.1.2",
"license": "MIT", "license": "MIT",
@@ -25902,9 +25396,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "5.26.5", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
}, },
"node_modules/unherit": { "node_modules/unherit": {
"version": "1.1.3", "version": "1.1.3",
@@ -26068,10 +25563,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-join": {
"version": "4.0.1",
"license": "MIT"
},
"node_modules/url-loader": { "node_modules/url-loader": {
"version": "4.1.1", "version": "4.1.1",
"dev": true, "dev": true,
@@ -27689,9 +27180,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.20.1", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+7 -8
View File
@@ -3,6 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Cosmos Explorer", "description": "Cosmos Explorer",
"main": "index.js", "main": "index.js",
"engines": {
"node": "22.x"
},
"dependencies": { "dependencies": {
"@azure/arm-cosmosdb": "16.4.0", "@azure/arm-cosmosdb": "16.4.0",
"@azure/cosmos": "4.7.0", "@azure/cosmos": "4.7.0",
@@ -14,11 +17,7 @@
"@fluentui/react": "8.119.0", "@fluentui/react": "8.119.0",
"@fluentui/react-components": "9.54.2", "@fluentui/react-components": "9.54.2",
"@microsoft/applicationinsights-web": "2.6.1", "@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/markdown": "4.6.0",
"@nteract/myths": "0.1.9",
"@octokit/request": "8.4.1", "@octokit/request": "8.4.1",
"@octokit/rest": "17.9.2", "@octokit/rest": "17.9.2",
"@testing-library/jest-dom": "6.4.6", "@testing-library/jest-dom": "6.4.6",
@@ -87,7 +86,6 @@
"react-window": "1.8.10", "react-window": "1.8.10",
"react-youtube": "9.0.1", "react-youtube": "9.0.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rx-jupyter": "5.5.12",
"shell-quote": "1.10.0", "shell-quote": "1.10.0",
"styled-components": "5.0.1", "styled-components": "5.0.1",
"swr": "0.4.0", "swr": "0.4.0",
@@ -97,7 +95,7 @@
"utility-types": "3.10.0", "utility-types": "3.10.0",
"uuid": "9.0.0", "uuid": "9.0.0",
"web-vitals": "4.2.4", "web-vitals": "4.2.4",
"ws": "8.20.1", "ws": "8.21.3",
"xterm": "4.19.0", "xterm": "4.19.0",
"xterm-addon-fit": "0.5.0", "xterm-addon-fit": "0.5.0",
"zustand": "3.5.0" "zustand": "3.5.0"
@@ -136,7 +134,7 @@
"@types/hasher": "0.0.31", "@types/hasher": "0.0.31",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/jquery": "3.5.29", "@types/jquery": "3.5.29",
"@types/node": "18.19.0", "@types/node": "22.14.1",
"@types/post-robot": "10.0.1", "@types/post-robot": "10.0.1",
"@types/q": "1.5.1", "@types/q": "1.5.1",
"@types/react": "17.0.44", "@types/react": "17.0.44",
@@ -149,6 +147,7 @@
"@types/sinon": "2.3.3", "@types/sinon": "2.3.3",
"@types/styled-components": "5.1.32", "@types/styled-components": "5.1.32",
"@types/underscore": "1.7.36", "@types/underscore": "1.7.36",
"@types/uuid": "9.0.8",
"@types/youtube-player": "5.5.6", "@types/youtube-player": "5.5.6",
"@typescript-eslint/eslint-plugin": "6.7.4", "@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4", "@typescript-eslint/parser": "6.7.4",
@@ -206,7 +205,7 @@
"webpack-bundle-analyzer": "5.2.0", "webpack-bundle-analyzer": "5.2.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.5", "webpack-dev-server": "5.2.5",
"ws": "8.20.1" "ws": "8.21.3"
}, },
"scripts": { "scripts": {
"postinstall": "patch-package && npm run generate:i18n-keys", "postinstall": "patch-package && npm run generate:i18n-keys",
-11
View File
@@ -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 { export class ApiEndpoints {
public static runtimeProxy: string = "/api/RuntimeProxy"; public static runtimeProxy: string = "/api/RuntimeProxy";
public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy"; public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy";
+33
View File
@@ -30,6 +30,13 @@ export interface ConfigContext {
allowedCassandraProxyEndpoints: ReadonlyArray<string>; allowedCassandraProxyEndpoints: ReadonlyArray<string>;
allowedMongoProxyEndpoints: ReadonlyArray<string>; allowedMongoProxyEndpoints: ReadonlyArray<string>;
allowedParentFrameOrigins: ReadonlyArray<string>; allowedParentFrameOrigins: ReadonlyArray<string>;
SQL_DNS_ZONES: ReadonlyArray<string>;
MONGO_DNS_ZONES: ReadonlyArray<string>;
MONGO_COMPUTE_DNS_ZONES: ReadonlyArray<string>;
CASSANDRA_DNS_ZONES: ReadonlyArray<string>;
TABLE_DNS_ZONES: ReadonlyArray<string>;
DOCUMENT_ENDPOINT_ZONES: ReadonlyArray<string>;
GREMLIN_ENDPOINT_ZONES: ReadonlyArray<string>;
gitSha?: string; gitSha?: string;
proxyPath?: string; proxyPath?: string;
AAD_ENDPOINT: string; AAD_ENDPOINT: string;
@@ -81,6 +88,32 @@ let configContext: Readonly<ConfigContext> = {
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`, `^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`, `^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
], // Webpack injects this at build time ], // 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, gitSha: process.env.GIT_SHA,
hostedExplorerURL: "https://cosmos.azure.com/", hostedExplorerURL: "https://cosmos.azure.com/",
AAD_ENDPOINT: "https://login.microsoftonline.com/", AAD_ENDPOINT: "https://login.microsoftonline.com/",
+3 -10
View File
@@ -28,7 +28,6 @@ import { userContext } from "../UserContext";
import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils"; import { getCollectionName, getDatabaseName } from "../Utils/APITypeUtils";
import { useSidePanel } from "../hooks/useSidePanel"; import { useSidePanel } from "../hooks/useSidePanel";
import Explorer from "./Explorer"; import Explorer from "./Explorer";
import { useNotebook } from "./Notebook/useNotebook";
import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane"; import { DeleteCollectionConfirmationPane } from "./Panes/DeleteCollectionConfirmationPane/DeleteCollectionConfirmationPane";
import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel"; import { DeleteDatabaseConfirmationPanel } from "./Panes/DeleteDatabaseConfirmationPanel";
import StoredProcedure from "./Tree/StoredProcedure"; import StoredProcedure from "./Tree/StoredProcedure";
@@ -120,23 +119,17 @@ export const createCollectionContextMenuButton = (
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
onClick: () => { onClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection(); const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) { if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else { } else {
selectedCollection && selectedCollection.onNewMongoShellClick(); selectedCollection && selectedCollection.onNewMongoShellClick();
} }
}, },
label: label: userContext.features.enableCloudShell ? t(Keys.contextMenu.openMongoShell) : t(Keys.contextMenu.newShell),
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell
? t(Keys.contextMenu.openMongoShell)
: t(Keys.contextMenu.newShell),
}); });
} }
if ( if (userContext.features.enableCloudShell && userContext.apiType === "Cassandra") {
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) &&
userContext.apiType === "Cassandra"
) {
items.push({ items.push({
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
onClick: () => { onClick: () => {
@@ -109,25 +109,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function], "computedProperties": [Function],
"conflictResolutionPolicy": [Function], "conflictResolutionPolicy": [Function],
"container": Explorer { "container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -230,25 +220,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function], "computedProperties": [Function],
"conflictResolutionPolicy": [Function], "conflictResolutionPolicy": [Function],
"container": Explorer { "container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -455,25 +435,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function], "computedProperties": [Function],
"conflictResolutionPolicy": [Function], "conflictResolutionPolicy": [Function],
"container": Explorer { "container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -526,25 +496,15 @@ exports[`SettingsComponent renders 1`] = `
} }
explorer={ explorer={
Explorer { Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -694,25 +654,15 @@ exports[`SettingsComponent renders 1`] = `
"computedProperties": [Function], "computedProperties": [Function],
"conflictResolutionPolicy": [Function], "conflictResolutionPolicy": [Function],
"container": Explorer { "container": Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -765,25 +715,15 @@ exports[`SettingsComponent renders 1`] = `
} }
explorer={ explorer={
Explorer { Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
+15 -15
View File
@@ -10,6 +10,7 @@ import { Capability, DatabaseAccount } from "../Contracts/DataModels";
import { updateUserContext, userContext } from "../UserContext"; import { updateUserContext, userContext } from "../UserContext";
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts"; import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import Explorer from "./Explorer"; import Explorer from "./Explorer";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
const mockUpdate = update as jest.MockedFunction<typeof update>; const mockUpdate = update as jest.MockedFunction<typeof update>;
@@ -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", () => { describe("Explorer.openEnableSynapseLinkDialog", () => {
let explorer: Explorer; let explorer: Explorer;
@@ -69,6 +55,7 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockUpdate.mockResolvedValue(undefined); mockUpdate.mockResolvedValue(undefined);
useCommandBar.getState().setIsSynapseLinkUpdating(false);
explorer = new Explorer(); explorer = new Explorer();
}); });
@@ -102,6 +89,19 @@ describe("Explorer.openEnableSynapseLinkDialog", () => {
expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true); 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<ReturnType<typeof update>>;
});
explorer.openEnableSynapseLinkDialog();
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(useCommandBar.getState().isSynapseLinkUpdating).toBe(false);
});
}); });
describe("with targetAccountOverride", () => { describe("with targetAccountOverride", () => {
+5 -220
View File
@@ -1,9 +1,6 @@
import * as msal from "@azure/msal-browser"; 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 { sendMessage } from "Common/MessageHandler";
import { stringifyError } from "Common/stringifyError"; import { stringifyError } from "Common/stringifyError";
import { Platform, configContext } from "ConfigContext";
import { MessageTypes } from "Contracts/ExplorerContracts"; import { MessageTypes } from "Contracts/ExplorerContracts";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { import {
@@ -13,32 +10,27 @@ import {
scheduleRefreshFabricToken, scheduleRefreshFabricToken,
} from "Platform/Fabric/FabricUtil"; } from "Platform/Fabric/FabricUtil";
import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils"; import { acquireMsalTokenForAccount } from "Utils/AuthorizationUtils";
import { allowedNotebookServerUrls, validateEndpoint } from "Utils/EndpointUtils";
import { featureRegistered } from "Utils/FeatureRegistrationUtils"; import { featureRegistered } from "Utils/FeatureRegistrationUtils";
import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts"; import { update } from "Utils/arm/generatedClients/cosmos/databaseAccounts";
import * as ko from "knockout"; import * as ko from "knockout";
import React from "react"; import React from "react";
import _ from "underscore"; import _ from "underscore";
import shallow from "zustand/shallow";
import { AuthType } from "../AuthType"; import { AuthType } from "../AuthType";
import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer"; import { BindingHandlersRegisterer } from "../Bindings/BindingHandlersRegisterer";
import * as Constants from "../Common/Constants"; import * as Constants from "../Common/Constants";
import { Areas, ConnectionStatusType, HttpStatusCodes, Notebook } from "../Common/Constants";
import { getErrorMessage, getErrorStack } from "../Common/ErrorHandlingUtils"; import { getErrorMessage, getErrorStack } from "../Common/ErrorHandlingUtils";
import * as Logger from "../Common/Logger"; import * as Logger from "../Common/Logger";
import { QueriesClient } from "../Common/QueriesClient"; import { QueriesClient } from "../Common/QueriesClient";
import { readCollection } from "../Common/dataAccess/readCollection"; import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases"; import { readDatabases } from "../Common/dataAccess/readDatabases";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import { ContainerConnectionInfo, IPhoenixServiceInfo, IProvisionData, IResponse } from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import { UploadDetailsRecord } from "../Contracts/ViewModels"; import { UploadDetailsRecord } from "../Contracts/ViewModels";
import MetricScenario from "../Metrics/MetricEvents"; import MetricScenario from "../Metrics/MetricEvents";
import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig"; import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig";
import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; import { scenarioMonitor } from "../Metrics/ScenarioMonitor";
import { PhoenixClient } from "../Phoenix/PhoenixClient";
import * as ExplorerSettings from "../Shared/ExplorerSettings"; 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 * as TelemetryProcessor from "../Shared/Telemetry/TelemetryProcessor";
import { updateUserContext, userContext } from "../UserContext"; import { updateUserContext, userContext } from "../UserContext";
import { getCollectionName, getUploadName } from "../Utils/APITypeUtils"; import { getCollectionName, getUploadName } from "../Utils/APITypeUtils";
@@ -49,8 +41,6 @@ import { ReactTabKind, useTabs } from "../hooks/useTabs";
import "./ComponentRegisterer"; import "./ComponentRegisterer";
import { DialogProps, useDialog } from "./Controls/Dialog"; import { DialogProps, useDialog } from "./Controls/Dialog";
import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "./Menus/CommandBar/CommandBarComponentAdapter";
import type NotebookManager from "./Notebook/NotebookManager";
import { useNotebook } from "./Notebook/useNotebook";
import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel"; import { AddCollectionPanel } from "./Panes/AddCollectionPanel/AddCollectionPanel";
import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane"; import { CassandraAddCollectionPane } from "./Panes/CassandraAddCollectionPane/CassandraAddCollectionPane";
import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane"; import { ExecuteSprocParamsPane } from "./Panes/ExecuteSprocParamsPane/ExecuteSprocParamsPane";
@@ -78,13 +68,7 @@ export default class Explorer {
// Tabs // Tabs
public isTabsContentExpanded: ko.Observable<boolean>; public isTabsContentExpanded: ko.Observable<boolean>;
// Notebooks
public notebookManager?: NotebookManager;
private _isInitializingNotebooks: boolean;
private static readonly MaxNbDatabasesToAutoExpand = 5; private static readonly MaxNbDatabasesToAutoExpand = 5;
public phoenixClient: PhoenixClient;
/** /**
* Resolves when the initial refreshAllDatabases (including collection loading) completes. * Resolves when the initial refreshAllDatabases (including collection loading) completes.
@@ -95,13 +79,6 @@ export default class Explorer {
const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, { const startKey: number = TelemetryProcessor.traceStart(Action.InitializeDataExplorer, {
dataExplorerArea: Constants.Areas.ResourceTree, 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); this.queriesClient = new QueriesClient(this);
@@ -156,31 +133,8 @@ export default class Explorer {
startKey, startKey,
); );
useNotebook.subscribe(
async () => this.initiateAndRefreshNotebookList(),
(state) => [state.isNotebookEnabled, state.isRefreshed],
shallow,
);
this.resourceTree = new ResourceTreeAdapter(this); 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()) { if (isFabricMirrored()) {
useTabs.getState().closeReactTab(ReactTabKind.Home); useTabs.getState().closeReactTab(ReactTabKind.Home);
} }
@@ -188,23 +142,6 @@ export default class Explorer {
this.refreshExplorer(); this.refreshExplorer();
} }
public async initiateAndRefreshNotebookList(): Promise<void> {
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 { public openEnableSynapseLinkDialog(targetAccountOverride?: DataModels.AccountOverride): void {
const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId; const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId;
const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup; const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup;
@@ -227,7 +164,7 @@ export default class Explorer {
const clearInProgressMessage = logConsoleProgress( 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.", "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(); useDialog.getState().closeDialog();
try { try {
@@ -248,7 +185,7 @@ export default class Explorer {
logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`); logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`);
TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime); TelemetryProcessor.traceFailure(Action.EnableAzureSynapseLink, {}, startTime);
} finally { } finally {
useNotebook.getState().setIsSynapseLinkUpdating(false); useCommandBar.getState().setIsSynapseLinkUpdating(false);
} }
}, },
@@ -431,7 +368,6 @@ export default class Explorer {
await (userContext.authType === AuthType.ResourceToken await (userContext.authType === AuthType.ResourceToken
? this.refreshDatabaseForResourceToken() ? this.refreshDatabaseForResourceToken()
: this.refreshAllDatabases()); : this.refreshAllDatabases());
await this.refreshNotebookList();
} }
logConsoleInfo("Successfully refreshed databases"); logConsoleInfo("Successfully refreshed databases");
@@ -442,108 +378,6 @@ export default class Explorer {
window.open(Constants.Urls.feedbackEmail, "_blank"); window.open(Constants.Urls.feedbackEmail, "_blank");
}; };
public async initNotebooks(databaseAccount: DataModels.DatabaseAccount): Promise<void> {
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<void> {
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<IPhoenixServiceInfo>,
connectionStatus: DataModels.ContainerConnectionInfo,
): Promise<void> {
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( private getDeltaDatabases(
updatedDatabaseList: DataModels.Database[], updatedDatabaseList: DataModels.Database[],
databases: ViewModels.Database[], databases: ViewModels.Database[],
@@ -637,11 +471,6 @@ export default class Explorer {
} }
} }
private refreshNotebookList = (): Promise<void> => {
// Notebook authoring and listing have been removed.
return Promise.resolve();
};
public openNotebookTerminal(kind: ViewModels.TerminalKind): void { public openNotebookTerminal(kind: ViewModels.TerminalKind): void {
this.connectToNotebookTerminal(kind); this.connectToNotebookTerminal(kind);
} }
@@ -742,24 +571,6 @@ export default class Explorer {
.openSidePanel("Input parameters", <ExecuteSprocParamsPane storedProcedure={storedProcedure} />); .openSidePanel("Input parameters", <ExecuteSprocParamsPane storedProcedure={storedProcedure} />);
} }
public getDownloadModalContent(fileName: string): JSX.Element {
if (useNotebook.getState().isPhoenixNotebooks) {
return (
<>
<p>{Notebook.galleryNotebookDownloadContent1}</p>
<br />
<p>
{Notebook.galleryNotebookDownloadContent2}
<Link href={Notebook.cosmosNotebookGitDocumentationUrl} target="_blank">
{Notebook.learnMore}
</Link>
</p>
</>
);
}
return <p> Download {fileName} from gallery as a copy to your notebooks to run and/or edit the notebook. </p>;
}
public async refreshExplorer(): Promise<void> { public async refreshExplorer(): Promise<void> {
// Start DatabaseLoad scenario before fetching databases // Start DatabaseLoad scenario before fetching databases
if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") {
@@ -768,8 +579,7 @@ export default class Explorer {
// Run independent initialization tasks in parallel: // Run independent initialization tasks in parallel:
// - Database loading (ARM/SDK calls for databases + collections) // - 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)
// - Feature registration check (ARM call — no dependency on databases or notebooks)
const databasesTask = const databasesTask =
userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo" userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo"
? (async () => { ? (async () => {
@@ -786,37 +596,12 @@ export default class Explorer {
})() })()
: Promise.resolve(); : Promise.resolve();
const notebooksTask = !isFabricNative()
? useNotebook.getState().refreshNotebooksEnabledStateForAccount()
: Promise.resolve();
const featureRegistrationTask = const featureRegistrationTask =
userContext.authType === AuthType.AAD && userContext.apiType === "SQL" && !isFabricNative() userContext.authType === AuthType.AAD && userContext.apiType === "SQL" && !isFabricNative()
? featureRegistered(userContext.subscriptionId, "ThroughputBucketing") ? featureRegistered(userContext.subscriptionId, "ThroughputBucketing")
: Promise.resolve(false); : Promise.resolve(false);
const [, , throughputBucketsEnabled] = await Promise.all([databasesTask, notebooksTask, featureRegistrationTask]); const [, throughputBucketsEnabled] = await Promise.all([databasesTask, 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);
}
if (throughputBucketsEnabled) { if (throughputBucketsEnabled) {
updateUserContext({ throughputBucketsEnabled }); updateUserContext({ throughputBucketsEnabled });
@@ -5,14 +5,12 @@
*/ */
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
import { makeStyles, useFluent } from "@fluentui/react-components"; import { makeStyles, useFluent } from "@fluentui/react-components";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane"; import { useDataPlaneRbac } from "Explorer/Panes/SettingsPane/SettingsPane";
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
import { isFabric } from "Platform/Fabric/FabricUtil"; import { isFabric } from "Platform/Fabric/FabricUtil";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import * as React from "react"; import * as React from "react";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import { ConnectionStatusType } from "../../../Common/Constants";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
@@ -28,6 +26,8 @@ export interface CommandBarStore {
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void; setContextButtons: (contextButtons: CommandButtonComponentProps[]) => void;
isHidden: boolean; isHidden: boolean;
setIsHidden: (isHidden: boolean) => void; setIsHidden: (isHidden: boolean) => void;
isSynapseLinkUpdating: boolean;
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => void;
} }
export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({ export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
@@ -35,6 +35,8 @@ export const useCommandBar: UseStore<CommandBarStore> = create((set) => ({
setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })), setContextButtons: (contextButtons: CommandButtonComponentProps[]) => set((state) => ({ ...state, contextButtons })),
isHidden: false, isHidden: false,
setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })), setIsHidden: (isHidden: boolean) => set((state) => ({ ...state, isHidden })),
isSynapseLinkUpdating: false,
setIsSynapseLinkUpdating: (isSynapseLinkUpdating: boolean) => set({ isSynapseLinkUpdating }),
})); }));
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -69,26 +71,25 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const selectedNodeState = useSelectedNode(); const selectedNodeState = useSelectedNode();
const buttons = useCommandBar((state) => state.contextButtons); const buttons = useCommandBar((state) => state.contextButtons);
const isHidden = useCommandBar((state) => state.isHidden); const isHidden = useCommandBar((state) => state.isHidden);
const isSynapseLinkUpdating = useCommandBar((state) => state.isSynapseLinkUpdating);
// targetDocument is used by referenced components // targetDocument is used by referenced components
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { targetDocument } = useFluent(); const { targetDocument } = useFluent();
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR); const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
const styles = useStyles(); 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 // Subscribe to the store changes that affect button creation
const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled); const dataPlaneRbacEnabled = useDataPlaneRbac((state) => state.dataPlaneRbacEnabled);
const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated); const aadTokenUpdated = useDataPlaneRbac((state) => state.aadTokenUpdated);
// Memoize the expensive button creation // Memoize the expensive button creation
const staticButtons = React.useMemo(() => { const staticButtons = React.useMemo(() => {
return CommandBarComponentButtonFactory.createStaticCommandBarButtons(container, selectedNodeState); return CommandBarComponentButtonFactory.createStaticCommandBarButtons(
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated]); container,
selectedNodeState,
isSynapseLinkUpdating,
);
}, [container, selectedNodeState, dataPlaneRbacEnabled, aadTokenUpdated, isSynapseLinkUpdating]);
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
const buttons = const buttons =
@@ -134,11 +135,6 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)"); const uiFabricControlButtons = CommandBarUtil.convertButton(controlButtons, "var(--colorNeutralBackground1)");
uiFabricControlButtons.forEach((btn: ICommandBarItemProps) => (btn.iconOnly = true)); 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 = { const rootStyle = {
root: { root: {
backgroundColor: "var(--colorNeutralBackground1)", backgroundColor: "var(--colorNeutralBackground1)",
@@ -4,7 +4,6 @@ import { DatabaseAccount } from "../../../Contracts/DataModels";
import { CollectionBase } from "../../../Contracts/ViewModels"; import { CollectionBase } from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext"; import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { useDatabases } from "../../useDatabases"; import { useDatabases } from "../../useDatabases";
import { useSelectedNode } from "../../useSelectedNode"; import { useSelectedNode } from "../../useSelectedNode";
import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory"; import * as CommandBarComponentButtonFactory from "./CommandBarComponentButtonFactory";
@@ -37,6 +36,18 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(enableAzureSynapseLinkBtn).toBeDefined(); 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 // 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 // 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. // 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", () => { it("Cassandra Api not available - button should be hidden", () => {
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
@@ -127,7 +133,7 @@ describe("CommandBarComponentButtonFactory tests", () => {
expect(openCassandraShellBtn).toBeUndefined(); 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 buttons = CommandBarComponentButtonFactory.createStaticCommandBarButtons(mockExplorer, selectedNodeState);
const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel); const openCassandraShellBtn = buttons.find((button) => button.commandButtonLabel === openCassandraShellBtnLabel);
expect(openCassandraShellBtn).toBeUndefined(); expect(openCassandraShellBtn).toBeUndefined();
@@ -30,7 +30,6 @@ import { useSidePanel } from "../../../hooks/useSidePanel";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent";
import { useDialog } from "../../Controls/Dialog"; import { useDialog } from "../../Controls/Dialog";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { useNotebook } from "../../Notebook/useNotebook";
import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane"; import { BrowseQueriesPane } from "../../Panes/BrowseQueriesPane/BrowseQueriesPane";
import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane"; import { LoadQueryPane } from "../../Panes/LoadQueryPane/LoadQueryPane";
import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane"; import { SettingsPane, useDataPlaneRbac } from "../../Panes/SettingsPane/SettingsPane";
@@ -43,6 +42,7 @@ let counter = 0;
export function createStaticCommandBarButtons( export function createStaticCommandBarButtons(
container: Explorer, container: Explorer,
selectedNodeState: SelectedNodeState, selectedNodeState: SelectedNodeState,
isSynapseLinkUpdating = false,
): CommandButtonComponentProps[] { ): CommandButtonComponentProps[] {
if (userContext.authType === AuthType.ResourceToken) { if (userContext.authType === AuthType.ResourceToken) {
return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState); return createStaticCommandBarButtonsForResourceToken(container, selectedNodeState);
@@ -62,7 +62,7 @@ export function createStaticCommandBarButtons(
userContext.apiType !== "Tables" && userContext.apiType !== "Tables" &&
userContext.apiType !== "Cassandra" userContext.apiType !== "Cassandra"
) { ) {
const addSynapseLink = createOpenSynapseLinkDialogButton(container); const addSynapseLink = createOpenSynapseLinkDialogButton(container, isSynapseLinkUpdating);
if (addSynapseLink) { if (addSynapseLink) {
addDivider(); addDivider();
buttons.push(addSynapseLink); buttons.push(addSynapseLink);
@@ -136,14 +136,13 @@ export function createContextCommandBarButtons(
const buttons: CommandButtonComponentProps[] = []; const buttons: CommandButtonComponentProps[] = [];
if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") { if (!selectedNodeState.isDatabaseNodeOrNoneSelected() && userContext.apiType === "Mongo") {
const label = const label = userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell ? "Open Mongo Shell" : "New Shell";
const newMongoShellBtn: CommandButtonComponentProps = { const newMongoShellBtn: CommandButtonComponentProps = {
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
iconAlt: label, iconAlt: label,
onCommandClick: () => { onCommandClick: () => {
const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection(); const selectedCollection: ViewModels.Collection = selectedNodeState.findSelectedCollection();
if (useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) { if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(ViewModels.TerminalKind.Mongo); container.openNotebookTerminal(ViewModels.TerminalKind.Mongo);
} else { } else {
selectedCollection && selectedCollection.onNewMongoShellClick(); selectedCollection && selectedCollection.onNewMongoShellClick();
@@ -157,7 +156,7 @@ export function createContextCommandBarButtons(
} }
if ( if (
(useNotebook.getState().isShellEnabled || userContext.features.enableCloudShell) && userContext.features.enableCloudShell &&
!selectedNodeState.isDatabaseNodeOrNoneSelected() && !selectedNodeState.isDatabaseNodeOrNoneSelected() &&
userContext.apiType === "Cassandra" 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) { if (configContext.platform === Platform.Emulator) {
return undefined; return undefined;
} }
@@ -273,7 +275,7 @@ function createOpenSynapseLinkDialogButton(container: Explorer): CommandButtonCo
onCommandClick: () => container.openEnableSynapseLinkDialog(), onCommandClick: () => container.openEnableSynapseLinkDialog(),
commandButtonLabel: label, commandButtonLabel: label,
hasPopup: false, hasPopup: false,
disabled: useNotebook.getState().isSynapseLinkUpdating, disabled: isSynapseLinkUpdating,
ariaLabel: label, ariaLabel: label,
}; };
} }
@@ -514,14 +516,9 @@ function createOpenTerminalButtonByKind(
} }
}; };
const label = `Open ${terminalFriendlyName()} Shell`; const label = `Open ${terminalFriendlyName()} Shell`;
const tooltip = const tooltip = "Cloud Shell is not available for this account.";
"This feature is not yet available in your account's region. View supported regions here: https://aka.ms/cosmos-enable-notebooks.";
const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled(); const isNativeAuthDisabled = terminalKind === ViewModels.TerminalKind.VCoreMongo && isVCoreMongoNativeAuthDisabled();
const disableButton = const disableButton = !userContext.features.enableCloudShell || isNativeAuthDisabled;
(!useNotebook.getState().isNotebooksEnabledForAccount &&
!useNotebook.getState().isNotebookEnabled &&
!userContext.features.enableCloudShell) ||
isNativeAuthDisabled;
return { return {
iconSrc: HostedTerminalIcon, iconSrc: HostedTerminalIcon,
iconAlt: label, iconAlt: label,
@@ -533,7 +530,7 @@ function createOpenTerminalButtonByKind(
}); });
return; return;
} }
if (useNotebook.getState().isNotebookEnabled || userContext.features.enableCloudShell) { if (userContext.features.enableCloudShell) {
container.openNotebookTerminal(terminalKind); container.openNotebookTerminal(terminalKind);
} }
}, },
@@ -17,9 +17,6 @@ import { configContext, Platform } from "../../../ConfigContext";
import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants"; import { Action, ActionModifiers } from "../../../Shared/Telemetry/TelemetryConstants";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor"; import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import { CommandButtonComponentProps } from "../../Controls/CommandButton/CommandButtonComponent"; 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 * 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: () => <MemoryTracker />,
};
};
export const createConnectionStatus = (container: Explorer, key: string): ICommandBarItemProps => {
return {
key,
onRender: () => <ConnectionStatus container={container} />,
};
};
export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap { export function createKeyboardHandlers(allButtons: CommandButtonComponentProps[]): KeyboardHandlerMap {
const handlers: KeyboardHandlerMap = {}; const handlers: KeyboardHandlerMap = {};
@@ -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);
}
}
@@ -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<Props> = ({ 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<boolean>(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 (
<ActionButton className="commandReactBtn" onClick={() => container.allocateContainer()}>
<TooltipHost content={toolTipContent}>
<Stack className="connectionStatusContainer" horizontal>
<Icon iconName="ConnectVirtualMachine" className="connectIcon" />
<span>{connectionInfo.status}</span>
</Stack>
</TooltipHost>
</ActionButton>
);
}
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 (
<>
<TooltipHost
content={
containerInfo?.status === ContainerStatusType.Active
? `Connected to temporary workspace. This temporary workspace will get disconnected in ${Math.round(
containerInfo.durationLeftInMinutes,
)} minutes.`
: toolTipContent
}
>
<ActionButton
id={buttonId}
className={connectionInfo.status === ConnectionStatusType.Failed ? "commandReactBtn" : "connectedReactBtn"}
onClick={(e: React.MouseEvent<HTMLSpanElement>) =>
connectionInfo.status === ConnectionStatusType.Failed ? container.allocateContainer() : e.preventDefault()
}
>
<Stack className="connectionStatusContainer" horizontal>
<i className={statusColor}></i>
<span className={connectionInfo.status === ConnectionStatusType.Failed ? "connectionStatusFailed" : ""}>
{connectionInfo.status}
</span>
{connectionInfo.status === ConnectionStatusType.Connecting && isActive && (
<ProgressIndicator description={minute + ":" + second} />
)}
{connectionInfo.status === ConnectionStatusType.Connected && !isActive && (
<ProgressIndicator
className={totalGB !== 0 && usedGB / totalGB > 0.8 ? "lowMemory" : ""}
description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
percentComplete={totalGB !== 0 ? usedGB / totalGB : 0}
/>
)}
</Stack>
{!isBarDismissed &&
containerInfo.status &&
containerInfo.status === ContainerStatusType.Active &&
Math.round(containerInfo.durationLeftInMinutes) <= Notebook.remainingTimeForAlert ? (
<FocusTrapCallout
role="alertdialog"
className={styles.callout}
gapSpace={0}
target={`#${buttonId}`}
onDismiss={() => setIsBarDismissed(true)}
setInitialFocus
>
<Text block variant="xLarge" className={styles.title}>
Remaining Time
</Text>
<Text block variant="small">
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.
</Text>
<FocusZone handleTabKey={FocusZoneTabbableElements.all} isCircularNavigation>
<Stack className={styles.buttons} gap={8} horizontal>
<DefaultButton onClick={() => setIsBarDismissed(true)}>Dimiss</DefaultButton>
</Stack>
</FocusZone>
</FocusTrapCallout>
) : undefined}
</ActionButton>
</TooltipHost>
</>
);
};
@@ -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;
}
}
}
@@ -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 (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<Spinner size={SpinnerSize.medium} />
</Stack>
);
}
const totalGB = memoryUsageInfo.totalKB / 1048576;
const usedGB = totalGB - memoryUsageInfo.freeKB / 1048576;
return (
<Stack className="memoryTrackerContainer" horizontal>
<span>Memory</span>
<ProgressIndicator
className={usedGB / totalGB > 0.8 ? "lowMemory" : ""}
description={usedGB.toFixed(1) + " of " + totalGB.toFixed(1) + " GB"}
percentComplete={usedGB / totalGB}
/>
</Stack>
);
};
@@ -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<DataModels.MemoryUsageInfo> {
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<DataModels.MemoryUsageInfo> {
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<IResponse<IPhoenixServiceInfo>> {
this.isResettingWorkspace = true;
let response: IResponse<IPhoenixServiceInfo>;
try {
response = await this._resetWorkspace();
} catch (error) {
Promise.reject(error);
return response;
}
this.isResettingWorkspace = false;
return response;
}
private async _resetWorkspace(): Promise<IResponse<IPhoenixServiceInfo>> {
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",
};
}
}
@@ -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,
}
-32
View File
@@ -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),
);
}
}
@@ -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"));
});
});
});
-81
View File
@@ -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}`;
}
}
-248
View File
@@ -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<void>;
findItem: (root: NotebookContentItem, item: NotebookContentItem) => NotebookContentItem;
insertNotebookItem: (parent: NotebookContentItem, item: NotebookContentItem) => void;
updateNotebookItem: (item: NotebookContentItem) => void;
deleteNotebookItem: (item: NotebookContentItem) => void;
initializeNotebooksTree: () => Promise<void>;
setConnectionInfo: (connectionInfo: ContainerConnectionInfo) => void;
setIsAllocating: (isAllocating: boolean) => void;
resetContainerConnection: (connectionStatus: ContainerConnectionInfo) => void;
setIsRefreshed: (isAllocating: boolean) => void;
setContainerStatus: (containerStatus: ContainerInfo) => void;
getPhoenixStatus: () => Promise<void>;
setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => void;
setIsPhoenixFeatures: (isPhoenixFeatures: boolean) => void;
}
export const useNotebook: UseStore<NotebookState> = 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<void> => {
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<void> => {
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 }),
}));
@@ -145,25 +145,15 @@ exports[`AddGlobalSecondaryIndexPanel render default panel 1`] = `
<AnalyticalStoreComponent <AnalyticalStoreComponent
explorer={ explorer={
Explorer { Explorer {
"_isInitializingNotebooks": false,
"databasesRefreshed": Promise {}, "databasesRefreshed": Promise {},
"isFixedCollectionWithSharedThroughputSupported": [Function], "isFixedCollectionWithSharedThroughputSupported": [Function],
"isTabsContentExpanded": [Function], "isTabsContentExpanded": [Function],
"onRefreshDatabasesKeyPress": [Function], "onRefreshDatabasesKeyPress": [Function],
"onRefreshResourcesClick": [Function], "onRefreshResourcesClick": [Function],
"phoenixClient": PhoenixClient {
"armResourceId": undefined,
"retryOptions": {
"maxTimeout": 5000,
"minTimeout": 5000,
"retries": 3,
},
},
"provideFeedbackEmail": [Function], "provideFeedbackEmail": [Function],
"queriesClient": QueriesClient { "queriesClient": QueriesClient {
"container": [Circular], "container": [Circular],
}, },
"refreshNotebookList": [Function],
"resourceTree": ResourceTreeAdapter { "resourceTree": ResourceTreeAdapter {
"container": [Circular], "container": [Circular],
"parameters": [Function], "parameters": [Function],
@@ -43,7 +43,6 @@ import { getCollectionName } from "../../Utils/APITypeUtils";
import { useDialog } from "../Controls/Dialog"; import { useDialog } from "../Controls/Dialog";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import { useNotebook } from "../Notebook/useNotebook";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
@@ -188,12 +187,6 @@ export const SplashScreen: React.FC<SplashScreenProps> = ({ explorer }) => {
React.useEffect(() => { React.useEffect(() => {
subscriptions.push( subscriptions.push(
{
dispose: useNotebook.subscribe(
() => setState({}),
(state) => state.isNotebookEnabled,
),
},
{ dispose: useSelectedNode.subscribe(() => setState({})) }, { dispose: useSelectedNode.subscribe(() => setState({})) },
{ {
dispose: useCarousel.subscribe( dispose: useCarousel.subscribe(
@@ -35,15 +35,6 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { updateUserContext } from "../../../UserContext"; import { updateUserContext } from "../../../UserContext";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
jest.mock("Common/dataAccess/queryDocuments", () => ({ jest.mock("Common/dataAccess/queryDocuments", () => ({
queryDocuments: jest.fn(() => ({ queryDocuments: jest.fn(() => ({
// Omit headers, because we can't mock a private field and we don't need to test it // Omit headers, because we can't mock a private field and we don't need to test it
@@ -19,15 +19,6 @@ import { act } from "react-dom/test-utils";
import * as ViewModels from "../../../Contracts/ViewModels"; import * as ViewModels from "../../../Contracts/ViewModels";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
jest.requireActual("Explorer/Controls/Editor/EditorReact"); jest.requireActual("Explorer/Controls/Editor/EditorReact");
const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__"; const PROPERTY_VALUE = "__SOME_PROPERTY_VALUE__";
-1
View File
@@ -5,7 +5,6 @@ import {
TriggerDefinition, TriggerDefinition,
UserDefinedFunctionDefinition, UserDefinedFunctionDefinition,
} from "@azure/cosmos"; } from "@azure/cosmos";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2"; import { DocumentsTabV2 } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { isFabricMirrored } from "Platform/Fabric/FabricUtil"; import { isFabricMirrored } from "Platform/Fabric/FabricUtil";
import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils"; import { useDataplaneRbacAuthorization } from "Utils/AuthorizationUtils";
-9
View File
@@ -6,15 +6,6 @@ import { updateUserContext, userContext } from "../../UserContext";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import Database from "./Database"; import Database from "./Database";
jest.mock("rx-jupyter", () => ({
sessions: {
create: jest.fn(),
},
contents: {
JupyterContentProvider: jest.fn().mockImplementation(() => ({})),
},
}));
const createMockContainer = (): Explorer => { const createMockContainer = (): Explorer => {
const mockContainer = new Explorer(); const mockContainer = new Explorer();
return mockContainer; return mockContainer;
+2 -29
View File
@@ -18,12 +18,8 @@ import { userContext } from "UserContext";
import { ReactTabKind, useTabs } from "hooks/useTabs"; import { ReactTabKind, useTabs } from "hooks/useTabs";
import * as React from "react"; import * as React from "react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import shallow from "zustand/shallow";
import { useDatabaseLoadScenario } from "../../Metrics/useMetricPhases"; import { useDatabaseLoadScenario } from "../../Metrics/useMetricPhases";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useNotebook } from "../Notebook/useNotebook";
export const MyNotebooksTitle = "My Notebooks";
interface ResourceTreeProps { interface ResourceTreeProps {
explorer: Explorer; explorer: Explorer;
@@ -40,13 +36,6 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
const [openItems, setOpenItems] = React.useState<TreeItemValue[]>([]); const [openItems, setOpenItems] = React.useState<TreeItemValue[]>([]);
const treeStyles = useTreeStyles(); 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. // We intentionally avoid using a state selector here because we want to re-render the tree if the active tab changes.
const { refreshActiveTab } = useTabs(); const { refreshActiveTab } = useTabs();
@@ -65,24 +54,8 @@ export const ResourceTree: React.FC<ResourceTreeProps> = ({ explorer }: Resource
const databaseTreeNodes = useMemo(() => { const databaseTreeNodes = useMemo(() => {
return userContext.authType === AuthType.ResourceToken return userContext.authType === AuthType.ResourceToken
? createResourceTokenTreeNodes(resourceTokenCollection) ? createResourceTokenTreeNodes(resourceTokenCollection)
: createDatabaseTreeNodes( : createDatabaseTreeNodes(explorer, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds);
explorer, }, [resourceTokenCollection, databases, refreshActiveTab, searchText, sortOrder, pinnedDatabaseIds]);
isNotebookEnabled,
databases,
refreshActiveTab,
searchText,
sortOrder,
pinnedDatabaseIds,
);
}, [
resourceTokenCollection,
databases,
isNotebookEnabled,
refreshActiveTab,
searchText,
sortOrder,
pinnedDatabaseIds,
]);
const headerNodes: TreeNode[] = isFabricMirrored() const headerNodes: TreeNode[] = isFabricMirrored()
? [] ? []
-148
View File
@@ -1,34 +1,20 @@
import { TreeNodeMenuItem } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity"; import { collectionWasOpened } from "Explorer/MostRecentActivity/MostRecentActivity";
import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil"; import { shouldShowScriptNodes } from "Explorer/Tree/treeNodeUtil";
import { getItemName } from "Utils/APITypeUtils"; import { getItemName } from "Utils/APITypeUtils";
import * as ko from "knockout"; import * as ko from "knockout";
import * as React from "react"; import * as React from "react";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; 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 CollectionIcon from "../../../images/tree-collection.svg";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { isPublicInternetAccessAllowed } from "../../Common/DatabaseAccountUtility";
import * as DataModels from "../../Contracts/DataModels"; import * as DataModels from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels"; 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 { userContext } from "../../UserContext";
import { isServerlessAccount } from "../../Utils/CapabilityUtils"; import { isServerlessAccount } from "../../Utils/CapabilityUtils";
import { useTabs } from "../../hooks/useTabs"; import { useTabs } from "../../hooks/useTabs";
import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory"; import * as ResourceTreeContextMenuButtonFactory from "../ContextMenuButtonFactory";
import { useDialog } from "../Controls/Dialog";
import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent"; import { LegacyTreeComponent, LegacyTreeNode } from "../Controls/TreeComponent/LegacyTreeComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { useCommandBar } from "../Menus/CommandBar/CommandBarComponentAdapter"; 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 TabsBase from "../Tabs/TabsBase";
import { useDatabases } from "../useDatabases"; import { useDatabases } from "../useDatabases";
import { useSelectedNode } from "../useSelectedNode"; import { useSelectedNode } from "../useSelectedNode";
@@ -37,16 +23,10 @@ import Trigger from "./Trigger";
import UserDefinedFunction from "./UserDefinedFunction"; import UserDefinedFunction from "./UserDefinedFunction";
export class ResourceTreeAdapter implements ReactAdapter { export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
private static readonly DataTitle = "DATA"; private static readonly DataTitle = "DATA";
private static readonly NotebooksTitle = "NOTEBOOKS";
private static readonly PseudoDirPath = "PsuedoDir";
public parameters: ko.Observable<number>; public parameters: ko.Observable<number>;
public myNotebooksContentRoot: NotebookContentItem;
public constructor(private container: Explorer) { public constructor(private container: Explorer) {
this.parameters = ko.observable(Date.now()); this.parameters = ko.observable(Date.now());
@@ -55,56 +35,15 @@ export class ResourceTreeAdapter implements ReactAdapter {
() => this.triggerRender(), () => this.triggerRender(),
(state) => state.activeTab, (state) => state.activeTab,
); );
useNotebook.subscribe(
() => this.triggerRender(),
(state) => state.isNotebookEnabled,
);
useDatabases.subscribe(() => this.triggerRender()); useDatabases.subscribe(() => this.triggerRender());
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 { public renderComponent(): JSX.Element {
const dataRootNode = this.buildDataTree(); const dataRootNode = this.buildDataTree();
return <LegacyTreeComponent className="dataResourceTree" rootNode={dataRootNode} />; return <LegacyTreeComponent className="dataResourceTree" rootNode={dataRootNode} />;
} }
public async initialize(): Promise<void[]> {
const refreshTasks: Promise<void>[] = [];
this.myNotebooksContentRoot = {
name: useNotebook.getState().notebookFolderName,
path: useNotebook.getState().notebookBasePath,
type: NotebookContentItemType.Directory,
};
return Promise.all(refreshTasks);
}
private buildDataTree(): LegacyTreeNode { private buildDataTree(): LegacyTreeNode {
const databaseTreeNodes: LegacyTreeNode[] = useDatabases const databaseTreeNodes: LegacyTreeNode[] = useDatabases
.getState() .getState()
@@ -403,93 +342,6 @@ export class ResourceTreeAdapter implements ReactAdapter {
return traverse(schema); 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() { public triggerRender() {
window.requestAnimationFrame(() => this.parameters(Date.now())); window.requestAnimationFrame(() => this.parameters(Date.now()));
} }
@@ -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": [ "children": [
+20 -36
View File
@@ -4,7 +4,6 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract";
import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent"; import { TreeNode } from "Explorer/Controls/TreeComponent/TreeNodeComponent";
import Explorer from "Explorer/Explorer"; import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter"; import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel"; import { DeleteDatabaseConfirmationPanel } from "Explorer/Panes/DeleteDatabaseConfirmationPanel";
import TabsBase from "Explorer/Tabs/TabsBase"; import TabsBase from "Explorer/Tabs/TabsBase";
import StoredProcedure from "Explorer/Tree/StoredProcedure"; import StoredProcedure from "Explorer/Tree/StoredProcedure";
@@ -363,7 +362,7 @@ describe("createDatabaseTreeNodes", () => {
}, },
} as never, } as never,
}); });
nodes = createDatabaseTreeNodes(explorer, false, useDatabases.getState().databases, refreshActiveTab, ""); nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
}); });
it("creates expected tree", () => { it("creates expected tree", () => {
@@ -371,11 +370,10 @@ describe("createDatabaseTreeNodes", () => {
}); });
}); });
it.each<[string, Platform, boolean, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([ it.each<[string, Platform, Partial<DataModels.DatabaseAccountExtendedProperties>, Partial<UserContext>]>([
[ [
"the SQL API, on Fabric read-only (mirrored)", "the SQL API, on Fabric read-only (mirrored)",
Platform.Fabric, Platform.Fabric,
false,
{ capabilities: [], enableMultipleWriteLocations: true }, { capabilities: [], enableMultipleWriteLocations: true },
{ {
fabricContext: { fabricContext: {
@@ -387,7 +385,6 @@ describe("createDatabaseTreeNodes", () => {
[ [
"the SQL API, on Fabric non read-only (native)", "the SQL API, on Fabric non read-only (native)",
Platform.Fabric, Platform.Fabric,
false,
{ capabilities: [], enableMultipleWriteLocations: true }, { capabilities: [], enableMultipleWriteLocations: true },
{ {
fabricContext: { fabricContext: {
@@ -399,7 +396,6 @@ describe("createDatabaseTreeNodes", () => {
[ [
"the SQL API, on Portal", "the SQL API, on Portal",
Platform.Portal, Platform.Portal,
false,
{ capabilities: [], enableMultipleWriteLocations: true }, { capabilities: [], enableMultipleWriteLocations: true },
{ {
fabricContext: undefined, fabricContext: undefined,
@@ -408,7 +404,6 @@ describe("createDatabaseTreeNodes", () => {
[ [
"the Cassandra API, serverless, on Hosted", "the Cassandra API, serverless, on Hosted",
Platform.Hosted, Platform.Hosted,
false,
{ {
capabilities: [ capabilities: [
{ name: CapabilityNames.EnableCassandra, description: "" }, { name: CapabilityNames.EnableCassandra, description: "" },
@@ -418,45 +413,34 @@ describe("createDatabaseTreeNodes", () => {
{ fabricContext: undefined }, { fabricContext: undefined },
], ],
[ [
"the Mongo API, with Notebooks and Phoenix features, on Emulator", "the Mongo API, on Emulator",
Platform.Emulator, Platform.Emulator,
true,
{ {
capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }], capabilities: [{ name: CapabilityNames.EnableMongo, description: "" }],
}, },
{ fabricContext: undefined }, { fabricContext: undefined },
], ],
])( ])("generates the correct tree structure for %s", (_, platform, dbAccountProperties, userContext) => {
"generates the correct tree structure for %s", updateConfigContext({ platform });
(_, platform, isNotebookEnabled, dbAccountProperties, userContext) => { updateUserContext({
useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled }); ...userContext,
updateConfigContext({ platform }); databaseAccount: {
updateUserContext({ properties: {
...userContext, enableMultipleWriteLocations: true,
databaseAccount: { ...dbAccountProperties,
properties: { },
enableMultipleWriteLocations: true, } as unknown as DataModels.DatabaseAccount,
...dbAccountProperties, });
}, const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
} as unknown as DataModels.DatabaseAccount, expect(nodes).toMatchSnapshot();
}); });
const nodes = createDatabaseTreeNodes(
explorer,
isNotebookEnabled,
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. // 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. // 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. // 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", () => { 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()); const giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
expect(giganticDbNode).toBeDefined(); expect(giganticDbNode).toBeDefined();
expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]); expect(giganticDbNode.children.map((node) => node.label)).toStrictEqual(["schemaCollection", "load more"]);
@@ -488,7 +472,7 @@ describe("createDatabaseTreeNodes", () => {
}, },
} as unknown as DataModels.DatabaseAccount, } 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()); standardDbNode = nodes.find((node) => node.label === standardDb.id());
sharedDbNode = nodes.find((node) => node.label === sharedDb.id()); sharedDbNode = nodes.find((node) => node.label === sharedDb.id());
giganticDbNode = nodes.find((node) => node.label === giganticDb.id()); giganticDbNode = nodes.find((node) => node.label === giganticDb.id());
@@ -643,7 +627,7 @@ describe("createDatabaseTreeNodes", () => {
setup(); setup();
// Rebuild the nodes after changing the user/config context. // 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()); standardDbNode = nodes.find((node) => node.label === standardDb.id());
standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id()); standardCollectionNode = standardDbNode.children.find((node) => node.label === standardCollection.id());
+2 -7
View File
@@ -137,7 +137,6 @@ export const createResourceTokenTreeNodes = (collection: ViewModels.CollectionBa
export const createDatabaseTreeNodes = ( export const createDatabaseTreeNodes = (
container: Explorer, container: Explorer,
isNotebookEnabled: boolean,
databases: ViewModels.Database[], databases: ViewModels.Database[],
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
searchText = "", searchText = "",
@@ -184,9 +183,7 @@ export const createDatabaseTreeNodes = (
database database
.collections() .collections()
.forEach((collection: ViewModels.Collection) => .forEach((collection: ViewModels.Collection) =>
databaseNode.children.push( databaseNode.children.push(buildCollectionNode(database, collection, container, refreshActiveTab)),
buildCollectionNode(database, collection, isNotebookEnabled, container, refreshActiveTab),
),
); );
if (database.collectionsContinuationToken) { if (database.collectionsContinuationToken) {
@@ -254,14 +251,13 @@ export const createDatabaseTreeNodes = (
export const buildCollectionNode = ( export const buildCollectionNode = (
database: ViewModels.Database, database: ViewModels.Database,
collection: ViewModels.Collection, collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode => { ): TreeNode => {
let children: TreeNode[]; let children: TreeNode[];
// Flat Tree for Fabric // Flat Tree for Fabric
if (!isFabricMirrored()) { if (!isFabricMirrored()) {
children = buildCollectionNodeChildren(database, collection, isNotebookEnabled, container, refreshActiveTab); children = buildCollectionNodeChildren(database, collection, container, refreshActiveTab);
} }
const collectionNode: TreeNode = { const collectionNode: TreeNode = {
@@ -310,7 +306,6 @@ export const buildCollectionNode = (
const buildCollectionNodeChildren = ( const buildCollectionNodeChildren = (
database: ViewModels.Database, database: ViewModels.Database,
collection: ViewModels.Collection, collection: ViewModels.Collection,
isNotebookEnabled: boolean,
container: Explorer, container: Explorer,
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void, refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void,
): TreeNode[] => { ): TreeNode[] => {
-6
View File
@@ -1,5 +1,3 @@
import { ConnectionStatusType } from "Common/Constants";
import { useNotebook } from "Explorer/Notebook/useNotebook";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import { useTabs } from "../hooks/useTabs"; import { useTabs } from "../hooks/useTabs";
@@ -13,7 +11,6 @@ export interface SelectedNodeState {
collectionId?: string, collectionId?: string,
subnodeKinds?: ViewModels.CollectionTabKind[], subnodeKinds?: ViewModels.CollectionTabKind[],
) => boolean; ) => boolean;
isConnectedToContainer: () => boolean;
} }
export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) => ({ export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) => ({
@@ -61,7 +58,4 @@ export const useSelectedNode: UseStore<SelectedNodeState> = create((set, get) =>
subnodeKinds.includes(selectedSubnodeKind) subnodeKinds.includes(selectedSubnodeKind)
); );
}, },
isConnectedToContainer: (): boolean => {
return useNotebook.getState().connectionInfo?.status === ConnectionStatusType.Connected;
},
})); }));
+2 -2
View File
@@ -83,9 +83,9 @@ const App: React.FunctionComponent = () => {
setEncryptedToken(token); setEncryptedToken(token);
setAuthType(AuthType.ConnectionString); setAuthType(AuthType.ConnectionString);
}) })
.catch((error) => { .catch(async (error) => {
logError( logError(
`Failed to connect with connection string: ${getErrorMessage(error)}`, `Failed to connect with connection string: ${await (error as Response).text()}`,
"HostedExplorer/connectWithConnectionString", "HostedExplorer/connectWithConnectionString",
); );
}); });
-2
View File
@@ -52,8 +52,6 @@ import { ErrorBoundary } from "./Explorer/ErrorBoundary";
import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less"; import "./Explorer/Graph/GraphExplorerComponent/graphExplorer.less";
import "./Explorer/Menus/CommandBar/CommandBarComponent.less"; import "./Explorer/Menus/CommandBar/CommandBarComponent.less";
import { CommandBar } from "./Explorer/Menus/CommandBar/CommandBarComponentAdapter"; 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 "./Explorer/Menus/NotificationConsole/NotificationConsole.less";
import { NotificationConsole } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent"; import { NotificationConsole } from "./Explorer/Menus/NotificationConsole/NotificationConsoleComponent";
import "./Explorer/Panes/PanelComponent.less"; import "./Explorer/Panes/PanelComponent.less";
-256
View File
@@ -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<IResponse<IPhoenixServiceInfo>> {
this.initializeCancelEventListener();
return promiseRetry(() => this.executeContainerAssignmentOperation(provisionData, "allocate"), {
retries: 4,
maxTimeout: 20000,
minTimeout: 20000,
signal: this.abortSignal,
});
}
public async resetContainer(provisionData: IProvisionData): Promise<IResponse<IPhoenixServiceInfo>> {
return this.executeContainerAssignmentOperation(provisionData, "reset");
}
private async executeContainerAssignmentOperation(
provisionData: IProvisionData,
operation: string,
): Promise<IResponse<IPhoenixServiceInfo>> {
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<ContainerInfo> {
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;
}
}
}
}
@@ -1,11 +1,27 @@
jest.mock("../../../hooks/useDirectories"); jest.mock("../../../hooks/useDirectories");
jest.mock("../../../Common/PortalBackendClient");
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { extractFeatures } from "Platform/Hosted/extractFeatures"; import { extractFeatures } from "Platform/Hosted/extractFeatures";
import { updateUserContext, userContext } from "UserContext"; import { updateUserContext, userContext } from "UserContext";
import React from "react"; import React from "react";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { ConnectExplorer } from "./ConnectExplorer"; import { ConnectExplorer } from "./ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
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", () => { it("shows the connect form", () => {
const connectionString = "fakeConnectionString"; const connectionString = "fakeConnectionString";
const login = jest.fn(); const login = jest.fn();
@@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
updateUserContext({ features: oldFeatures }); updateUserContext({ features: oldFeatures });
}); });
it("rejects an unrecognized connection string before token exchange", async () => {
render(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "not-a-valid-connection-string",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=some-key;",
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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(
<ConnectExplorer
{...{
login: jest.fn(),
setEncryptedToken: jest.fn(),
setAuthType: jest.fn(),
connectionString: mongoConnectionString,
setConnectionString: jest.fn(),
setAccountMetadata: jest.fn(),
}}
/>,
);
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();
});
@@ -1,10 +1,11 @@
import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components";
import { useBoolean } from "@fluentui/react-hooks"; import { useBoolean } from "@fluentui/react-hooks";
import { getErrorMessage } from "Common/ErrorHandlingUtils"; import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { userContext } from "UserContext"; import { userContext } from "UserContext";
import * as React from "react"; import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg"; import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType"; import { AuthType } from "../../../AuthType";
import { HttpStatusCodes } from "../../../Common/Constants";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient"; import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { AccessInputMetadata } from "../../../Contracts/DataModels"; import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { parseConnectionString } from "../Helpers/ConnectionStringParser"; import { parseConnectionString } from "../Helpers/ConnectionStringParser";
@@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
}: Props) => { }: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false); const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState("");
const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin; const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
return ( return (
@@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
{isFormVisible && enableConnectionStringLogin ? ( {isFormVisible && enableConnectionStringLogin ? (
<form <form
id="connectWithConnectionString" id="connectWithConnectionString"
aria-busy={isConnecting}
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
if (isConnecting) {
return;
}
setErrorMessage(""); setErrorMessage("");
setIsBlockedByFirewall(false);
setIsConnecting(true);
try { try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) { if (isResourceTokenConnectionString(connectionString)) {
setAuthType(AuthType.ResourceToken);
return;
}
const metadata = parseConnectionString(connectionString);
if (!metadata) {
setErrorMessage( 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; return;
} }
} catch (error) {
setErrorMessage(getErrorMessage(error));
return;
}
if (isResourceTokenConnectionString(connectionString)) { try {
setAuthType(AuthType.ResourceToken); if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
return; 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 (isDirectConnectionStringLoginApi(metadata.apiKind)) {
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) { setAccountMetadata(metadata);
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so setAuthType(AuthType.ConnectionString);
// we skip the Portal Backend proxy and use the metadata parsed from the connection string. return;
setAccountMetadata(metadata); }
setAuthType(AuthType.ConnectionString);
return;
}
const encryptedToken = await fetchEncryptedToken(connectionString); // Mongo and Cassandra go through the Portal Backend
setEncryptedToken(encryptedToken); try {
setAuthType(AuthType.ConnectionString); 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);
}
}} }}
> >
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p> <p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
setConnectionString(event.target.value); setConnectionString(event.target.value);
}} }}
/> />
{errorMessage.length > 0 && (
<span className="errorDetailsInfoTooltip">
<img className="errorImg" src={ErrorImage} alt="Error notification" />
<span className="errorDetails">{errorMessage}</span>
</span>
)}
</p> </p>
{errorMessage.length > 0 && (
<FluentProvider theme={webLightTheme} className="connectErrorMessageBar">
<MessageBar intent="error" layout="multiline">
<MessageBarBody>
<span className="errorDetails">{errorMessage}</span>
{isBlockedByFirewall && (
<Link
className="errorHelpLink"
href="https://learn.microsoft.com/azure/cosmos-db/how-to-configure-firewall#allow-requests-from-the-azure-portal"
target="_blank"
rel="noreferrer"
>
Allow access from Azure Portal
</Link>
)}
</MessageBarBody>
</MessageBar>
</FluentProvider>
)}
<p className="connectExplorerContent"> <p className="connectExplorerContent">
<input className="filterbtnstyle" type="submit" value="Connect" /> <input
className="filterbtnstyle"
type="submit"
value={isConnecting ? "Connecting..." : "Connect"}
disabled={isConnecting}
/>
</p> </p>
<p className="switchConnectTypeText" onClick={login}> <p className="switchConnectTypeText" onClick={login}>
Sign In with Azure Account Sign In with Azure Account
+11 -33
View File
@@ -55,41 +55,19 @@
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder { .connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
font-style: italic; font-style: italic;
} }
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip { .connectExplorerContainer .connectExplorer .connectErrorMessageBar {
position: relative; /* Matches the width of the connection string input so the form stays balanced. */
display: inline-block; width: 308px;
padding-left: 4px; margin: 0px auto 8px auto;
vertical-align: top; text-align: left;
} }
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails { .connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails {
visibility: visible; /* Service messages are diagnostics containing unbroken URIs and ids. */
overflow-wrap: anywhere;
} }
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails { .connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink {
bottom: 24px; display: block;
width: 165px; margin-top: 8px;
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;
} }
.filterbtnstyle { .filterbtnstyle {
@@ -1,19 +1,75 @@
import { configContext, updateConfigContext } from "../../../ConfigContext";
import * as DataModels from "../../../Contracts/DataModels"; import * as DataModels from "../../../Contracts/DataModels";
import { parseConnectionString } from "./ConnectionStringParser"; import {
buildEndpointsRegex,
dnsZoneAlternation,
parseConnectionString,
selectEndpointZone,
} from "./ConnectionStringParser";
describe("ConnectionStringParser", () => { describe("ConnectionStringParser", () => {
const mockAccountName = "Test"; const mockAccountName = "Test";
const mockMasterKey = "some-key"; const mockMasterKey = "some-key";
it("should parse a valid sql account connection string", () => { // The shape of each api's connection string, parameterized by the dns zone the account sits in. What
const metadata = parseConnectionString( // these tests are about is the zones, so keeping the shapes here stops them being restated once per zone.
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`, 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); const connectionStringsByApiKind: Record<DataModels.ApiKind, string> = {
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL); [DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"),
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); [DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"),
expect(metadata.apiEndpoint).toBeUndefined(); [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", () => { 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/`); expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
}); });
it("should parse a valid mongo account connection string", () => { it.each(configContext.SQL_DNS_ZONES)(
const metadata = parseConnectionString( "should parse a sql account connection string using the %s zone",
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`, (dnsZone: string) => {
); const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB); 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", () => { it.each(configContext.MONGO_DNS_ZONES)(
const metadata = parseConnectionString( "should parse a mongo account connection string using the %s zone",
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`, (dnsZone: string) => {
); const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute); 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<string, string> = {
"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", () => { it("should parse a valid graph account connection string", () => {
const metadata = parseConnectionString( const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com"));
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
);
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph); expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
@@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => {
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`); expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
}); });
it("should parse a valid table account connection string", () => { it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
const metadata = parseConnectionString( const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
);
expect(metadata.accountName).toBe(mockAccountName); expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
// Table data operations go through the document endpoint, which is constructed from the account name. expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`); // The constructed endpoint has to match the kind of zone the document endpoint we matched came from.
expect(metadata.apiEndpoint).toBeUndefined(); 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", () => { it("should reject a connection string when no DNS zone matches the account", () => {
const metadata = parseConnectionString( const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES;
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`, updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
);
expect(metadata.accountName).toBe(mockAccountName); try {
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table); const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net"));
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined(); // 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", () => { it.each([
const metadata = parseConnectionString( buildConnectionString.sql("documents.azure.com.attacker.example"),
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`, buildConnectionString.mongo("documents.azure.com.attacker.example"),
); buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"),
buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"),
expect(metadata.accountName).toBe(mockAccountName); buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"),
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra); ])("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", () => { it("should fail to parse an invalid connection string", () => {
@@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => {
expect(metadata).toBe(undefined); 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();
});
});
}); });
@@ -1,44 +1,80 @@
import * as Constants from "../../../Common/Constants"; import { configContext } from "../../../ConfigContext";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels"; import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's const PpeDnsSuffix = "windows-ppe.net";
// 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 DnsPort = "443"; 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<string>, 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>): 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 { export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) { if (connectionString) {
try { try {
const accessInput = {} as AccessInputMetadata; const accessInput = {} as AccessInputMetadata;
const connectionStringParts = connectionString.split(";"); 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) => { connectionStringParts.forEach((connectionStringPart: string) => {
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) { if (RegExp(endpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1]; const matches: string[] = connectionStringPart.match(endpointsRegex.sql);
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.SQL; accessInput.apiKind = ApiKind.SQL;
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as // SQL and Gremlin connection strings carry the account's document endpoint, so take it as
// given instead of rebuilding it from the account name. // given instead of rebuilding it from the account name.
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1); accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) { isPpeAccount = isPpeZone(matches[2]);
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo); } else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDB; accessInput.apiKind = ApiKind.MongoDB;
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) { isPpeAccount = isPpeZone(matches[3]);
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); } else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute);
accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = ApiKind.MongoDBCompute; accessInput.apiKind = ApiKind.MongoDBCompute;
} else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) { isPpeAccount = isPpeZone(matches[3]);
Constants.EndpointsRegex.cassandra.forEach((regex) => { } else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
if (RegExp(regex).test(connectionStringPart)) { endpointsRegex.cassandra.forEach((regex) => {
accessInput.accountName = connectionStringPart.match(regex)[1]; const matches: string[] = connectionStringPart.match(regex);
if (matches) {
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Cassandra; accessInput.apiKind = ApiKind.Cassandra;
isPpeAccount = isPpeZone(matches[2]);
} }
}); });
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { } else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; const matches: string[] = connectionStringPart.match(endpointsRegex.table);
accessInput.accountName = matches[1];
accessInput.apiKind = ApiKind.Table; accessInput.apiKind = ApiKind.Table;
isPpeAccount = isPpeZone(matches[2]);
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) { } else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
accessInput.apiKind = ApiKind.Graph; accessInput.apiKind = ApiKind.Graph;
} }
@@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined; return undefined;
} }
// Table connection strings only carry the table endpoint, so the document endpoint that data plane // A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather
// operations go through has to be derived from the account name. Gremlin accounts additionally // than giving the document endpoint, so the document endpoint that data plane operations go through
// need the Gremlin endpoint, which is never part of the connection string. // 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.accountName) {
if (accessInput.apiKind === ApiKind.Table) { if (
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`; 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) { } 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}`;
} }
} }
-87
View File
@@ -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<void> => {
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<void> => {
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);
};
-15
View File
@@ -83,12 +83,6 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
useEffect(() => { useEffect(() => {
const effect = async () => { const effect = async () => {
if (platform) { 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; let explorer: Explorer;
try { try {
if (platform === Platform.Hosted) { if (platform === Platform.Hosted) {
@@ -1010,15 +1004,6 @@ function updateContextsFromPortalMessage(inputs: DataExplorerInputsFrame) {
if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) { if (inputs.flights.indexOf(Flights.PKPartitionKeyTest) !== -1) {
userContext.features.partitionKeyDefault2 = true; 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 // Handle initial theme from portal
-15
View File
@@ -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<NotebookSnapshotHooks> = create((set) => ({
snapshot: undefined,
error: undefined,
setSnapshot: (imageSrc: string) => set((state) => ({ ...state, snapshot: imageSrc })),
setError: (error: string) => set((state) => ({ ...state, error })),
}));
-29
View File
@@ -9,7 +9,6 @@ import {
} from "Shared/AppStatePersistenceUtility"; } from "Shared/AppStatePersistenceUtility";
import create, { UseStore } from "zustand"; import create, { UseStore } from "zustand";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
import { CollectionTabKind } from "../Contracts/ViewModels";
import TabsBase from "../Explorer/Tabs/TabsBase"; import TabsBase from "../Explorer/Tabs/TabsBase";
export interface TabsState { export interface TabsState {
@@ -27,7 +26,6 @@ export interface TabsState {
refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void; refreshActiveTab: (comparator: (tab: TabsBase) => boolean) => void;
closeTabsByComparator: (comparator: (tab: TabsBase) => boolean) => void; closeTabsByComparator: (comparator: (tab: TabsBase) => boolean) => void;
closeTab: (tab: TabsBase) => void; closeTab: (tab: TabsBase) => void;
closeAllNotebookTabs: (hardClose: boolean) => void;
openAndActivateReactTab: (tabKind: ReactTabKind) => void; openAndActivateReactTab: (tabKind: ReactTabKind) => void;
closeReactTab: (tabKind: ReactTabKind) => void; closeReactTab: (tabKind: ReactTabKind) => void;
setIsTabExecuting: (state: boolean) => void; setIsTabExecuting: (state: boolean) => void;
@@ -132,33 +130,6 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
get().persistTabsState(); 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) => { openAndActivateReactTab: (tabKind: ReactTabKind) => {
if (get().openedReactTabs.indexOf(tabKind) === -1) { if (get().openedReactTabs.indexOf(tabKind) === -1) {
set((state) => ({ set((state) => ({
+3
View File
@@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow";
import { TextDecoder, TextEncoder } from "util"; import { TextDecoder, TextEncoder } from "util";
import i18n from "./i18n"; import i18n from "./i18n";
import enResources from "./Localization/en/Resources.json"; import enResources from "./Localization/en/Resources.json";
jest.mock("web-vitals");
configure({ adapter: new Adapter() }); configure({ adapter: new Adapter() });
initializeIcons(); initializeIcons();
@@ -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<void> {
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);
});
});
+5
View File
@@ -49,6 +49,7 @@ export enum TestAccount {
SQLReadOnly = "SQLReadOnly", SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly", SQLContainerCopyOnly = "SQLContainerCopyOnly",
SQLConnectionString = "SQLConnectionString", SQLConnectionString = "SQLConnectionString",
SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled",
TableConnectionString = "TableConnectionString", TableConnectionString = "TableConnectionString",
GremlinConnectionString = "GremlinConnectionString", GremlinConnectionString = "GremlinConnectionString",
} }
@@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string {
return `${accountNamePrefix}-de-test-sql-containercopy`; return `${accountNamePrefix}-de-test-sql-containercopy`;
case TestAccount.SQLConnectionString: case TestAccount.SQLConnectionString:
return `${accountNamePrefix}-de-test-sql-connstring-1`; return `${accountNamePrefix}-de-test-sql-connstring-1`;
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`;
case TestAccount.TableConnectionString: case TestAccount.TableConnectionString:
return `${accountNamePrefix}-de-test-table-connstring-1`; return `${accountNamePrefix}-de-test-table-connstring-1`;
case TestAccount.GremlinConnectionString: case TestAccount.GremlinConnectionString:
@@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
break; break;
case TestAccount.SQLConnectionString: case TestAccount.SQLConnectionString:
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
case TestAccount.TableConnectionString: case TestAccount.TableConnectionString:
case TestAccount.GremlinConnectionString: case TestAccount.GremlinConnectionString:
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't // Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
@@ -470,6 +474,7 @@ export enum CommandBarButton {
ExecuteQuery = "Execute Query", ExecuteQuery = "Execute Query",
UploadItem = "Upload Item", UploadItem = "Upload Item",
NewDocument = "New Document", NewDocument = "New Document",
AddRow = "Add Row",
View = "View", View = "View",
} }
+167
View File
@@ -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<void> {
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);
});
});
+28
View File
@@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => {
await expect(page.locator("#connectExplorer")).toHaveCount(0); await expect(page.locator("#connectExplorer")).toHaveCount(0);
await expect(page.locator(".errorDetails")).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);
});
}); });
-2
View File
@@ -44,7 +44,6 @@
"./src/Explorer/Graph/GraphExplorerComponent/GraphData.ts", "./src/Explorer/Graph/GraphExplorerComponent/GraphData.ts",
"./src/Explorer/LazyMonaco.ts", "./src/Explorer/LazyMonaco.ts",
"./src/Explorer/Menus/NotificationConsole/ConsoleData.tsx", "./src/Explorer/Menus/NotificationConsole/ConsoleData.tsx",
"./src/Explorer/Notebook/NotebookContentItem.ts",
"./src/Explorer/OpenFullScreen.test.tsx", "./src/Explorer/OpenFullScreen.test.tsx",
"./src/Explorer/OpenFullScreen.tsx", "./src/Explorer/OpenFullScreen.tsx",
"./src/Explorer/Panes/PanelContainerComponent.test.tsx", "./src/Explorer/Panes/PanelContainerComponent.test.tsx",
@@ -107,7 +106,6 @@
"./src/hooks/useConfig.ts", "./src/hooks/useConfig.ts",
"./src/hooks/useDirectories.tsx", "./src/hooks/useDirectories.tsx",
"./src/hooks/useGraphPhoto.tsx", "./src/hooks/useGraphPhoto.tsx",
"./src/hooks/useNotebookSnapshotStore.ts",
"./src/hooks/useNotificationConsole.ts", "./src/hooks/useNotificationConsole.ts",
"./src/hooks/useObservable.ts", "./src/hooks/useObservable.ts",
"./src/hooks/useSidePanel.ts", "./src/hooks/useSidePanel.ts",