mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-19 09:02:41 +01:00
Merge remote-tracking branch 'origin/users/aisayas/sql-connection-string-login-zones' into users/aisayas/sql-connectionstring-login
This commit is contained in:
+15
-15
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+31
-540
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }),
|
|
||||||
}));
|
|
||||||
-10
@@ -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__";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,18 +413,14 @@ 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",
|
|
||||||
(_, platform, isNotebookEnabled, dbAccountProperties, userContext) => {
|
|
||||||
useNotebook.setState({ isPhoenixFeatures: isNotebookEnabled });
|
|
||||||
updateConfigContext({ platform });
|
updateConfigContext({ platform });
|
||||||
updateUserContext({
|
updateUserContext({
|
||||||
...userContext,
|
...userContext,
|
||||||
@@ -440,23 +431,16 @@ describe("createDatabaseTreeNodes", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as DataModels.DatabaseAccount,
|
} as unknown as DataModels.DatabaseAccount,
|
||||||
});
|
});
|
||||||
const nodes = createDatabaseTreeNodes(
|
const nodes = createDatabaseTreeNodes(explorer, useDatabases.getState().databases, refreshActiveTab, "");
|
||||||
explorer,
|
|
||||||
isNotebookEnabled,
|
|
||||||
useDatabases.getState().databases,
|
|
||||||
refreshActiveTab,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
expect(nodes).toMatchSnapshot();
|
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());
|
||||||
|
|
||||||
|
|||||||
@@ -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[] => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,9 +46,30 @@ 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 {
|
||||||
|
if (isResourceTokenConnectionString(connectionString)) {
|
||||||
|
setAuthType(AuthType.ResourceToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = parseConnectionString(connectionString);
|
||||||
|
if (!metadata) {
|
||||||
|
setErrorMessage(
|
||||||
|
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||||
@@ -55,27 +79,37 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(getErrorMessage(error));
|
setErrorMessage(getErrorMessage(error as Error));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResourceTokenConnectionString(connectionString)) {
|
if (isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||||
setAuthType(AuthType.ResourceToken);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = parseConnectionString(connectionString);
|
|
||||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
|
||||||
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so
|
|
||||||
// we skip the Portal Backend proxy and use the metadata parsed from the connection string.
|
|
||||||
setAccountMetadata(metadata);
|
setAccountMetadata(metadata);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mongo and Cassandra go through the Portal Backend
|
||||||
|
try {
|
||||||
const encryptedToken = await fetchEncryptedToken(connectionString);
|
const encryptedToken = await fetchEncryptedToken(connectionString);
|
||||||
setEncryptedToken(encryptedToken);
|
setEncryptedToken(encryptedToken);
|
||||||
setAuthType(AuthType.ConnectionString);
|
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
|
||||||
|
|||||||
@@ -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;`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionStringsByApiKind: Record<DataModels.ApiKind, string> = {
|
||||||
|
[DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"),
|
||||||
|
[DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"),
|
||||||
|
[DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"),
|
||||||
|
[DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"),
|
||||||
|
[DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"),
|
||||||
|
[DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should parse a connection string for every api kind", () => {
|
||||||
|
Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => {
|
||||||
|
const metadata = parseConnectionString(connectionString);
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
|
expect(metadata.apiKind).toBe(Number(apiKind));
|
||||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
});
|
||||||
expect(metadata.apiEndpoint).toBeUndefined();
|
});
|
||||||
|
|
||||||
|
// 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.apiKind).toBe(DataModels.ApiKind.SQL);
|
||||||
|
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`);
|
||||||
|
expect(metadata.apiEndpoint).toBeUndefined();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(configContext.MONGO_DNS_ZONES)(
|
||||||
|
"should parse a mongo account connection string using the %s zone",
|
||||||
|
(dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
||||||
});
|
},
|
||||||
|
|
||||||
it("should parse a valid compute mongo account connection string", () => {
|
|
||||||
const metadata = parseConnectionString(
|
|
||||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(configContext.MONGO_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.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
|
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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })),
|
|
||||||
}));
|
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user